Containerize and Deploy an AI Agent
Build a FastAPI AI agent, containerize it with Docker, and ship it via CI/CD.
Introduction
30 Second Summary
Every time you ask an AI assistant a question, there is an entire system behind the scenes receiving your request, calling the right model, and sending back a response. Getting that system from a developer's laptop into a reliable, automated production pipeline is the core challenge of AI DevOps.
In this project, you will build an AI agent API that uses OpenAI tool calling to answer questions by executing real Python functions. You will containerize it with Docker and wire up a GitHub Actions pipeline that automatically tests and builds your application on every push.
What You'll Build
A FastAPI application that serves an AI agent with tool calling capabilities, packaged in a Docker container and wired to a GitHub Actions CI/CD pipeline.
By the end of this project, you'll have:
- A working AI agent API that uses OpenAI tool calling to execute real Python functions like a calculator and a date/time tool.
- A production-ready Docker container that packages your agent and runs identically on any machine.
- A GitHub Actions CI/CD pipeline that automatically runs tests and builds your Docker image on every push.
- Secret Mission: add a health check endpoint and integrate it into both your Dockerfile and CI/CD workflow.
Are there any prerequisites?
You should be comfortable with Python basics and Git basics (clone, commit, push). You also need a GitHub account, Docker Desktop installed on your machine, and an OpenAI API key.
Set Up the Project and Dependencies
Before writing any AI agent code, you need a clean workspace. A dedicated project folder, version control, and an isolated Python environment prevent dependency conflicts and keep your code reproducible across every machine.
In this step, you'll create the project directory, initialize Git, set up a virtual environment, and install every package your agent needs.
In this step, get ready to:
- Create the project directory and initialize Git.
- Install Python packages and pin their versions in a requirements file.
- Build the file structure for the application and tests.
Create the project directory and virtual environment
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and initialize a Git repository by running these commands:
mkdir ai-agent-api
cd ai-agent-api
git init
What do these commands do?
- mkdir ai-agent-api creates a new folder called ai-agent-api on your Desktop.
- cd ai-agent-api moves your terminal into that folder so all future commands run inside it.
- git init turns the folder into a Git repository so you can track changes and push to GitHub later.
- You should see Initialized empty Git repository in the terminal output.
Seeing 'command not found' for git?
Make sure Git is installed on your machine. You can download it from git-scm.com.
After installing, close and reopen your terminal, then try git init again. Help me troubleshoot my Git installation.
Now you need an isolated Python environment for this project. A virtual environment keeps the packages you install here separate from your system-wide Python installation.
- Create a Python virtual environment and activate it by running these commands:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
What is a virtual environment?
A virtual environment is an isolated Python installation specific to your project. Packages you install inside it stay contained here, so they never interfere with other Python projects on your machine.
- You should see (venv) at the beginning of your terminal prompt.
Don't see (venv) in your prompt?
- Make sure you ran the activation command for your operating system. The macOS and Windows commands are different.
- On Windows, if you see a script execution policy error, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell, then try the activate command again.
- Help me activate my Python virtual environment.
Install packages and create the requirements file
- Install all the packages your project needs by running this command:
pip install fastapi==0.135.3 uvicorn==0.34.0 openai==2.33.0 pytest==9.0.3 httpx==0.28.1 python-dotenv==1.1.0
What does each package do?
- fastapi is the web framework you'll use to build the API. FastAPI provides automatic request validation, OpenAPI docs, and async support out of the box.
- uvicorn is the ASGI server that runs your FastAPI application.
- openai is the Python SDK for calling the OpenAI API. Your agent uses it for tool calling.
- pytest is the testing framework you'll use to write automated tests for your agent.
- httpx is an HTTP client that FastAPI's test client requires under the hood.
- python-dotenv loads environment variables from a .env file so you can keep secrets out of your code during local development.
- You should see Successfully installed followed by a list of package names in the terminal output.
Seeing an install error?
- Confirm your virtual environment is active. You should see (venv) in your terminal prompt. If not, run the activation command from the previous step.
- Make sure you have Python 3.13 installed. Check by running python3 --version (macOS) or python --version (Windows).
- Help me fix my pip install error.
Your packages are installed, but anyone else who clones this project needs to know the exact versions. A requirements.txt file locks those versions down.
- Create the requirements.txt file by running this command:
macOS
cat > requirements.txt << 'EOF'
fastapi==0.135.3
uvicorn==0.34.0
openai==2.33.0
pytest==9.0.3
httpx==0.28.1
python-dotenv==1.1.0
EOF
Windows
@"
fastapi==0.135.3
uvicorn==0.34.0
openai==2.33.0
pytest==9.0.3
httpx==0.28.1
python-dotenv==1.1.0
"@ | Set-Content requirements.txt
Why pin package versions?
Pinning exact versions (like fastapi==0.135.3) ensures everyone who works on the project gets identical dependencies. Without pinning, pip install fastapi could grab a different version months later and break your code.
This file also tells Docker and your CI/CD pipeline exactly what to install when they build your project from scratch.
- Confirm the file was created correctly by running:
cat requirements.txt
- You should see six packages listed with pinned version numbers.
✔️ Awesome, I've got everything!
You're all set. Your requirements.txt is ready to go.
ⓧ I'd like to double check the full code
Here is the complete requirements.txt file. Make sure yours matches exactly.
fastapi==0.135.3
uvicorn==0.34.0
openai==2.33.0
pytest==9.0.3
httpx==0.28.1
python-dotenv==1.1.0
Create the project file structure
Your project needs two main directories. The app directory holds your FastAPI application code, and the tests directory holds your automated tests.
- Create the directories and placeholder files by running these commands:
macOS
mkdir app tests
touch app/__init__.py tests/__init__.py Dockerfile .dockerignore
Windows
mkdir app
mkdir tests
New-Item app\__init__.py -ItemType File
New-Item tests\__init__.py -ItemType File
New-Item Dockerfile -ItemType File
New-Item .dockerignore -ItemType File
What are these files for?
- __init__.py files tell Python that app/ and tests/ are importable packages, not just regular folders.
- Dockerfile will define how to package your entire application into a Docker container.
- .dockerignore lists files Docker should skip when building the image, like your virtual environment and .env secrets.
- Confirm the files were created by running:
ls app tests
- You should see __init__.py listed under both app and tests.
- Confirm your root-level files by running:
ls
- You should see Dockerfile, app, requirements.txt, tests, and venv in the output.
Where is .dockerignore?
Files starting with a dot are hidden by default. Run ls -a (macOS) or ls -Force (Windows) to see .dockerignore in the listing.
Missing files or directories?
- Make sure you're inside the ai-agent-api folder. Run pwd to check your current directory.
- If a file is missing, re-run the creation command for it. The commands are safe to run again.
- Help me fix my project file structure.
Your project foundation is solid. Next up, you'll build the AI agent that uses OpenAI tool calling to answer questions with real Python functions.
Build the AI Agent with Tool Calling
Your project environment is ready. Now it is time to build the core of this project: an AI agent that can actually do things, not just generate text.
Most chatbots can only respond with words. An agent with tool calling can run real Python functions to calculate answers, fetch live data, or trigger actions. You will build two tools (a calculator and a clock), wire them into an OpenAI agent, and serve the whole thing as an API.
In this step, get ready to:
- Define two tools the AI agent can call (a calculator and a date/time tool).
- Write the agent logic that sends questions to OpenAI and handles tool call responses.
- Create a FastAPI application with a POST endpoint and test it locally.
Create the tool definitions and handlers
Before the agent can use tools, it needs two things: a description of each tool (so OpenAI knows what is available) and a Python function that runs when the tool is called.
What is tool calling?
Tool calling is a feature of the Chat Completions API where you send the model a list of available functions described in JSON schema format. Instead of answering a question directly, the model can respond with a request to call one of those functions.
Your code then runs the function, sends the result back to the model, and the model produces a final answer using that result. The model never runs your code itself. It only decides which function to call and what arguments to pass.
- Open app/tools.py in your editor and add the tool definitions by pasting this code:
import json
from datetime import datetime
# Define tools using OpenAI's JSON schema format
# Each tool has a name, description, and parameter schema
TOOL_DEFINITIONS = [
{
"type": "function",
"function": {
"name": "calculate",
"description": "Evaluate a mathematical expression and return the result. Supports basic arithmetic like addition, subtraction, multiplication, and division.",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The math expression to evaluate, e.g. '2 + 2' or '10 * 5 / 2'"
}
},
"required": ["expression"],
"additionalProperties": False
},
"strict": True
}
},
{
"type": "function",
"function": {
"name": "get_current_time",
"description": "Get the current date and time.",
"parameters": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": False
},
"strict": True
}
}
]
What does this code do?
TOOL_DEFINITIONS is a list of tool descriptions that you will send to OpenAI alongside the user's question. Each entry describes one function the model is allowed to call.
- calculate accepts a math expression string like "2 + 2" and returns the result.
- get_current_time takes no arguments and returns the current date and time.
- "strict": True tells OpenAI to only pass arguments that match your schema exactly.
You won't see a change yet. The next chunk adds the functions that actually execute these tools.
- Below the TOOL_DEFINITIONS list, add the tool handlers and the execution function by pasting this code:
# Map tool names to the Python functions that run them
TOOL_HANDLERS = {
"calculate": lambda args: str(eval(args["expression"])),
"get_current_time": lambda args: datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
def execute_tool(tool_name: str, arguments: str) -> str:
# Parse the JSON arguments string from OpenAI
parsed_args = json.loads(arguments)
handler = TOOL_HANDLERS.get(tool_name)
if handler is None:
return json.dumps({"error": f"Unknown tool: {tool_name}"})
try:
result = handler(parsed_args)
return json.dumps({"result": result})
except Exception as e:
return json.dumps({"error": str(e)})
What does this code do?
- TOOL_HANDLERS is a dictionary that maps each tool name to a Python function. When OpenAI says "call calculate", your code looks up "calculate" in this dictionary and runs the matching lambda.
- execute_tool() is the glue function. It receives the tool name and a JSON string of arguments from OpenAI, parses the arguments, finds the right handler, runs it, and returns the result as JSON.
- The try/except block catches errors (like invalid math) and returns a clean error message instead of crashing.
About eval() in production
The calculate tool uses Python's eval() to evaluate math expressions. This is fine for a learning project. In a production application, you would use a safe math parser instead because eval() can execute arbitrary Python code.
- Save app/tools.py.
There is no output to verify yet. The agent logic you build next will use these tools.
Seeing a syntax error when saving?
- Check that False and True are capitalized. Python uses True/False, not true/false.
- Make sure every opening brace { and bracket [ has a matching closing one. The nested dictionaries in TOOL_DEFINITIONS are deep, so a missing bracket is easy to overlook.
- Why is my tools.py showing a syntax error?
✔️ Awesome, I've got everything!
Great. Double check you have saved app/tools.py before moving on.
ⓧ I'd like to double check the full code
import json
from datetime import datetime
TOOL_DEFINITIONS = [
{
"type": "function",
"function": {
"name": "calculate",
"description": "Evaluate a mathematical expression and return the result. Supports basic arithmetic like addition, subtraction, multiplication, and division.",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The math expression to evaluate, e.g. '2 + 2' or '10 * 5 / 2'"
}
},
"required": ["expression"],
"additionalProperties": False
},
"strict": True
}
},
{
"type": "function",
"function": {
"name": "get_current_time",
"description": "Get the current date and time.",
"parameters": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": False
},
"strict": True
}
}
]
TOOL_HANDLERS = {
"calculate": lambda args: str(eval(args["expression"])),
"get_current_time": lambda args: datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
def execute_tool(tool_name: str, arguments: str) -> str:
parsed_args = json.loads(arguments)
handler = TOOL_HANDLERS.get(tool_name)
if handler is None:
return json.dumps({"error": f"Unknown tool: {tool_name}"})
try:
result = handler(parsed_args)
return json.dumps({"result": result})
except Exception as e:
return json.dumps({"error": str(e)})
Create the agent logic
The tools are defined, but they need a brain to decide when to use them. The agent is the loop that takes a user's question, sends it to OpenAI, checks if the model wants to call a tool, runs that tool, and sends the result back for a final answer.
How does the agent loop work?
Here is the flow your agent follows for every question:
- Send the user's question and the list of available tools to OpenAI.
- If OpenAI responds with a tool call request, run the matching Python function locally.
- Send the tool result back to OpenAI so it can write a final, human-readable answer.
- If OpenAI did not request a tool call, return its direct response immediately.
- Open app/agent.py and add the agent function by pasting this code:
import json
from openai import OpenAI
from app.tools import TOOL_DEFINITIONS, execute_tool
def run_agent(question: str, client: OpenAI, model: str = "gpt-4o-mini") -> str:
# Build the conversation with a system prompt and the user's question
messages = [
{
"role": "system",
"content": (
"You are a helpful assistant with access to tools. "
"Use the provided tools to answer questions when appropriate. "
"Always use the calculate tool for math questions instead of computing yourself."
),
},
{"role": "user", "content": question},
]
# Send the question to OpenAI with the tool definitions attached
response = client.chat.completions.create(
model=model,
messages=messages,
tools=TOOL_DEFINITIONS,
tool_choice="auto",
)
response_message = response.choices[0].message
# If the model wants to call tools, execute them and send results back
if response_message.tool_calls:
messages.append(response_message)
for tool_call in response_message.tool_calls:
tool_result = execute_tool(
tool_call.function.name,
tool_call.function.arguments,
)
# Append each tool result so the model can use it
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_result,
}
)
# Send the updated conversation back for a final answer
second_response = client.chat.completions.create(
model=model,
messages=messages,
)
return second_response.choices[0].message.content
# If no tools were called, return the model's direct response
return response_message.content
What does this code do?
- run_agent() accepts the user's question, an OpenAI client, and an optional model name (defaulting to gpt-4o-mini).
- The system prompt tells the model to use the calculate tool for math instead of guessing. This makes tool usage reliable.
- tool_choice="auto" lets the model decide whether it needs a tool for each question. For a greeting like "Hi there", it responds directly. For "What is 2 + 2?", it calls the calculator.
- The if response_message.tool_calls block handles the two-step flow: execute the tool locally, then send the result back to OpenAI for a final answer.
- Save app/agent.py.
You won't see any output yet. The next substep wraps this agent in an API endpoint so you can send it questions.
Seeing an import error in your editor?
- If your editor underlines from app.tools import ..., make sure your app/__init__.py file exists. It can be empty, but it must be there so Python treats app/ as a package.
- Confirm your virtual environment is activated. If you see a generic Python path instead of your project's venv, the installed packages will not be found.
- Help me fix my import error in agent.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/agent.py before moving on.
ⓧ I'd like to double check the full code
import json
from openai import OpenAI
from app.tools import TOOL_DEFINITIONS, execute_tool
def run_agent(question: str, client: OpenAI, model: str = "gpt-4o-mini") -> str:
messages = [
{
"role": "system",
"content": (
"You are a helpful assistant with access to tools. "
"Use the provided tools to answer questions when appropriate. "
"Always use the calculate tool for math questions instead of computing yourself."
),
},
{"role": "user", "content": question},
]
response = client.chat.completions.create(
model=model,
messages=messages,
tools=TOOL_DEFINITIONS,
tool_choice="auto",
)
response_message = response.choices[0].message
if response_message.tool_calls:
messages.append(response_message)
for tool_call in response_message.tool_calls:
tool_result = execute_tool(
tool_call.function.name,
tool_call.function.arguments,
)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_result,
}
)
second_response = client.chat.completions.create(
model=model,
messages=messages,
)
return second_response.choices[0].message.content
return response_message.content
Create the FastAPI application and test it
You have tools and an agent. Now you need a way for the outside world to talk to it. FastAPI lets you wrap your agent in an HTTP API with just a few lines of code.
- Open app/main.py and add the application setup by pasting this code:
import os
from fastapi import FastAPI
from pydantic import BaseModel
from openai import OpenAI
from dotenv import load_dotenv
from app.agent import run_agent
# Load environment variables from .env file for local development
load_dotenv()
# Create the FastAPI application
app = FastAPI(title="AI Agent API", version="1.0.0")
# Initialize the OpenAI client with your API key
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
What does this code do?
- load_dotenv() reads your .env file and loads its values into environment variables. This keeps your API key out of your code.
- FastAPI(title=...) creates the web application. FastAPI automatically generates interactive API docs you can use for testing.
- os.getenv("OPENAI_API_KEY") reads the key from the environment instead of hardcoding it. This pattern works across local development, Docker, and CI/CD.
You won't see a change yet. The next chunk adds the API endpoint that ties everything together.
- Below the client line, add the request/response models and the endpoint by pasting this code:
# Define the shape of incoming requests and outgoing responses
class AskRequest(BaseModel):
question: str
class AskResponse(BaseModel):
answer: str
# POST /ask - accepts a question and returns the agent's answer
@app.post("/ask", response_model=AskResponse)
def ask_agent(request: AskRequest):
answer = run_agent(request.question, client)
return AskResponse(answer=answer)
What does this code do?
- AskRequest and AskResponse are Pydantic models. They define what fields the API expects in the request body and what it returns. FastAPI uses them to validate input and generate documentation automatically.
- @app.post("/ask") creates a POST endpoint at /ask. When someone sends a JSON body with a question field, it calls run_agent() and returns the answer.
- Save app/main.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/main.py before moving on.
ⓧ I'd like to double check the full code
import os
from fastapi import FastAPI
from pydantic import BaseModel
from openai import OpenAI
from dotenv import load_dotenv
from app.agent import run_agent
load_dotenv()
app = FastAPI(title="AI Agent API", version="1.0.0")
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
class AskRequest(BaseModel):
question: str
class AskResponse(BaseModel):
answer: str
@app.post("/ask", response_model=AskResponse)
def ask_agent(request: AskRequest):
answer = run_agent(request.question, client)
return AskResponse(answer=answer)
All three files are saved. Time to run the API and see the agent in action.
Your application reads the OpenAI API key from a .env file. You need to create this file in the root of your project before starting the server.
- Create a file called .env in the root of your ai-agent-api folder (next to requirements.txt) and add the following line:
OPENAI_API_KEY=your-api-key-here
- Replace your-api-key-here with your actual OpenAI API key.
- Save the .env file.
Never commit your .env file
The .env file holds your secret API key. Later when you set up .dockerignore and push to GitHub, this file will be excluded. For now, just keep it local.
- Start the development server by running this command in your terminal:
uvicorn app.main:app --reload
What does this command do?
- uvicorn is the web server that runs your FastAPI application.
- app.main:app tells uvicorn to look for the app object inside the app/main.py file.
- --reload restarts the server automatically whenever you save a file. Useful during development.
You should see output like Uvicorn running on http://127.0.0.1:8000 in your terminal. The server is now running.
FastAPI automatically generates an interactive docs page where you can test your endpoint without any extra tools.
- Open your browser and go to http://localhost:8000/docs.
- Click on the POST /ask endpoint to expand it.
- Click Try it out.
- Replace the example JSON in the request body with:
{
"question": "What is 25 * 4 + 10?"
}
- Click Execute.
You should see a JSON response with an answer field containing the agent's response. The answer should mention 110 because the agent used the calculate tool to evaluate the expression.
Server not starting or seeing an error?
- If you see ModuleNotFoundError, make sure your virtual environment is activated and that you ran pip install -r requirements.txt in Step 1.
- If you get a 401 Unauthorized or AuthenticationError in the response, double check that your .env file contains a valid OpenAI API key with no extra spaces or quotes around it.
- If the browser shows "This site can't be reached", make sure you are visiting http://localhost:8000/docs (not https) and that the uvicorn server is still running in your terminal.
- Help me troubleshoot my FastAPI server.
- Once you have confirmed the agent responds, press Ctrl+C in the terminal to stop the server.
Your AI agent is alive and answering questions. Next up, you will package this entire application into a Docker container so it runs identically on any machine.
Containerize the Application with Docker
Your AI agent API is working locally. You can start the server, send questions, and get back answers powered by OpenAI tool calling. The problem is that "works on my machine" does not work for anyone else.
Docker solves this by packaging your application, its dependencies, and its runtime environment into a single container. In this step, you will write a Dockerfile, build an image, and run your containerized agent API.
In this step, get ready to:
- Write a Dockerfile that packages your AI agent API into a container image.
- Create a .dockerignore file to keep unnecessary files out of the image.
- Build the Docker image and test the containerized API locally.
Write the Dockerfile
A Dockerfile is a set of instructions that tells Docker how to build your image. Each instruction creates a layer in the image, and Docker caches layers that have not changed.
- Create a new file called Dockerfile in the root of your project folder (next to requirements.txt). Note that the file has no extension.
- Paste the following code into the file:
# Use a minimal Python image to keep the container small
FROM python:3.13-slim
# Set the working directory inside the container
WORKDIR /app
# Copy requirements first and install (cached if requirements.txt hasn't changed)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the application code
COPY app/ ./app/
# Document the port the app listens on
EXPOSE 8000
# Start the FastAPI server
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
What does this Dockerfile do?
- python:3.13-slim is a lightweight base image. The slim variant strips out extras like compilers and docs, cutting the image size significantly.
- requirements.txt is copied and installed before the application code. This is a layer caching trick. Docker re-uses cached layers when nothing has changed, so your dependencies only reinstall when requirements.txt itself changes.
- --no-cache-dir tells pip not to store downloaded packages in a cache directory. There is no point caching inside a container image because nobody runs pip install again after the image is built.
- --host 0.0.0.0 binds the server to all network interfaces inside the container. Without this, the server only listens on 127.0.0.1 and would be unreachable from outside the container.
- Save Dockerfile.
- You will not see a change yet. The next substep builds this Dockerfile into a runnable image.
File not recognized as a Dockerfile?
Make sure the file is named exactly Dockerfile with a capital D and no file extension. Names like Dockerfile.txt or dockerfile will cause problems.
Help me troubleshoot my Dockerfile naming issue.
✔️ Awesome, I've got everything!
Great. Double check you have saved your Dockerfile.
ⓧ I'd like to double check the full code
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Create a .dockerignore file
When Docker builds an image, it sends everything in the current directory to the Docker engine as the build context. A .dockerignore file tells Docker which files to skip, keeping your image lean and preventing sensitive files like .env from leaking into the container.
- Create a new file called .dockerignore in the root of your project folder (next to Dockerfile).
- Paste the following into the file:
.git
.gitignore
.env
__pycache__
*.pyc
venv
.venv
tests
.github
README.md
Why exclude these files?
- .git and .github contain version control history and CI/CD workflows. Neither is needed to run the application.
- .env holds your API key. You never want secrets baked into a container image.
- venv, __pycache__, and *.pyc are local Python artifacts. The container installs its own dependencies from requirements.txt.
- tests are for development only. They run in CI, not inside the production container.
- Save .dockerignore.
- You will not see a visible change yet. This file takes effect when you build the Docker image in the next substep.
✔️ Awesome, I've got everything!
Great. Double check you have saved your .dockerignore file.
ⓧ I'd like to double check the full code
.git
.gitignore
.env
__pycache__
*.pyc
venv
.venv
tests
.github
README.md
Build and run the container
Your Dockerfile and .dockerignore are ready. Time to build an image from these instructions and run it as a live container.
- If your local uvicorn server from the previous step is still running, stop it by pressing Ctrl+C in that terminal. This frees up port 8000 for the container.
- Make sure Docker Desktop is running. Press Cmd+Space (macOS) or the Windows key (Windows), type Docker Desktop, and press Enter to open it.
- Build the Docker image by running this command from your project root:
docker build -t ai-agent-api:latest .
What does this command do?
- docker build reads the Dockerfile in the current directory and executes each instruction to create a container image.
- -t ai-agent-api:latest tags the image with the name ai-agent-api and the version label latest. Tags make it easy to reference the image later.
- The . at the end tells Docker to use the current directory as the build context.
- The build takes about a minute as Docker downloads the base image and installs your dependencies.
- Confirm the image was created by running:
docker images
You should see ai-agent-api listed with the latest tag.
Build failing with an error?
- If you see Cannot connect to the Docker daemon, Docker Desktop is not running. Open it and wait for the engine to start before retrying.
- If you see no such file or directory, make sure you are running the command from your project root (the folder that contains Dockerfile).
- If a pip install step fails inside the build, check that your requirements.txt has no typos in the package names or version numbers.
Help me debug my Docker build error.
Now that the image is built, you can start a container from it.
- Run the container by executing this command, replacing your-api-key-here with your actual OpenAI API key:
docker run -p 8000:8000 -e OPENAI_API_KEY=your-api-key-here ai-agent-api:latest
What do these flags mean?
- -p 8000:8000 maps port 8000 on your machine to port 8000 inside the container. Without this, you cannot reach the API from your browser or terminal.
- -e OPENAI_API_KEY=... passes your API key as an environment variable into the container. This is how you inject secrets at runtime without baking them into the image.
You should see uvicorn startup output in your terminal, ending with a line like Uvicorn running on http://0.0.0.0:8000. The container is now serving your AI agent.
The container is running in the foreground, so you need a second terminal to send a test request.
- Open a new terminal window or tab.
- Send a test request to the containerized API by running:
macOS
curl -X POST http://localhost:8000/ask \
-H "Content-Type: application/json" \
-d '{"question": "What is 2 + 2?"}'
Windows
curl.exe -X POST http://localhost:8000/ask -H "Content-Type: application/json" -d "{\"question\": \"What is 2 + 2?\"}"
You should see a JSON response with an answer field containing the agent's response. The answer includes the result of 2 + 2, calculated by your calculate tool.
Not getting a response?
- If you see Connection refused, check the terminal where you ran docker run. Make sure uvicorn started successfully and you see the Uvicorn running message.
- If the container exited immediately, look at the error output. A missing or invalid OPENAI_API_KEY can cause a startup crash. Double check you replaced your-api-key-here with your real key.
- If you see port is already allocated, your local uvicorn or another process is still using port 8000. Stop it and try again.
Help me troubleshoot my containerized API.
- Once you have confirmed the response, stop the container by pressing Ctrl+C in the terminal where it is running.
Why is this a big deal?
The same image you just ran on your laptop can run on any machine with Docker installed. A teammate, a staging server, or a cloud provider would all get the exact same behavior. That is the core promise of containerization.
Your AI agent is now containerized and portable. Next up, you will write automated tests that mock the OpenAI API so your pipeline can verify the agent works without spending a cent on API calls.
Write Automated Tests with Mocked LLM Calls
Your AI agent is built and containerized. You can run it locally or inside Docker, and it answers questions using real tool calls.
Before wiring up a CI/CD pipeline, you need automated tests that can run without a real OpenAI API key. Real LLM calls are slow, expensive, and non-deterministic. Production AI pipelines solve this by mocking the LLM responses so tests run fast, free, and produce the same result every time.
In this step, get ready to:
- Create test fixtures that mock OpenAI API calls.
- Write five tests covering tool calls, direct responses, tool execution, and the API endpoint.
- Run the full test suite locally with pytest.
Create test fixtures in conftest.py
pytest uses a special file called conftest.py to share reusable test setup across all test files. Any fixture you define here is automatically available to every test function in the same directory.
- Open tests/conftest.py in your editor and add the following code:
import pytest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import app
# Fixture that creates a test HTTP client for our FastAPI app
@pytest.fixture
def test_client():
return TestClient(app)
# Fixture that creates a fake OpenAI client using MagicMock
@pytest.fixture
def mock_openai_client():
return MagicMock()
What does this code do?
This file defines two fixtures that your tests will use:
- test_client creates a TestClient that sends HTTP requests to your FastAPI app without starting a real server.
- mock_openai_client creates a MagicMock object that pretends to be an OpenAI client. You can program it to return whatever responses you want, so your tests never call the real API.
- Save tests/conftest.py.
Why mock OpenAI calls?
Every call to OpenAI costs money, takes 1-3 seconds, and can return different text each time. Tests need to be free, fast, and deterministic. Mocking replaces the real API with a fake that returns exactly what you tell it to.
This is the same pattern production AI teams use in their CI pipelines.
✔️ Awesome, I've got everything!
Make sure you have saved tests/conftest.py before moving on.
ⓧ I'd like to double check the full code
import pytest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def test_client():
return TestClient(app)
@pytest.fixture
def mock_openai_client():
return MagicMock()
Write tests for the agent
Your test file needs to cover the two main paths through your agent: one where the model calls a tool, and one where it answers directly. You will also test the tool execution functions and the FastAPI endpoint.
- Open tests/test_agent.py and add the imports, a helper function, and the first test:
from unittest.mock import MagicMock, patch
from app.agent import run_agent
from app.tools import execute_tool
# Helper to build a fake tool_call object that looks like OpenAI's response
def _make_tool_call(tool_call_id, name, arguments):
tool_call = MagicMock()
tool_call.id = tool_call_id
tool_call.function.name = name
tool_call.function.arguments = arguments
return tool_call
# Test the full tool-calling flow: model requests a tool, we execute it, model gives final answer
def test_agent_with_tool_call(mock_openai_client):
# First API call: model asks to use the calculate tool
first_message = MagicMock()
first_message.tool_calls = [
_make_tool_call("call_123", "calculate", '{"expression": "2 + 2"}')
]
first_message.content = None
# Second API call: model gives a final answer using the tool result
second_message = MagicMock()
second_message.tool_calls = None
second_message.content = "The result of 2 + 2 is 4."
first_response = MagicMock()
first_response.choices = [MagicMock(message=first_message)]
second_response = MagicMock()
second_response.choices = [MagicMock(message=second_message)]
# Program the mock to return different responses on each call
mock_openai_client.chat.completions.create.side_effect = [
first_response,
second_response,
]
result = run_agent("What is 2 + 2?", mock_openai_client)
assert result == "The result of 2 + 2 is 4."
assert mock_openai_client.chat.completions.create.call_count == 2
What does this test do?
_make_tool_call is a helper that builds fake tool call objects. OpenAI's response includes nested attributes like tool_call.function.name. MagicMock creates these nested attributes automatically when you assign to them.
test_agent_with_tool_call simulates the full tool-calling loop. The mock returns a tool call on the first API call and a final answer on the second. The test confirms the agent made exactly two API calls and returned the correct answer.
- Below the first test, add a test for when the model answers directly without calling any tools:
# Test the direct-answer flow: model responds without using any tools
def test_agent_without_tool_call(mock_openai_client):
message = MagicMock()
message.tool_calls = None
message.content = "Hello! How can I help you today?"
response = MagicMock()
response.choices = [MagicMock(message=message)]
mock_openai_client.chat.completions.create.return_value = response
result = run_agent("Hi there!", mock_openai_client)
assert result == "Hello! How can I help you today?"
assert mock_openai_client.chat.completions.create.call_count == 1
What does this test do?
This covers the simpler path where the model just replies directly. The mock sets tool_calls to None, so the agent skips the tool execution branch entirely. The test confirms only one API call was made.
- Below that, add three more tests. These verify tool execution and the FastAPI endpoint:
# Test the calculate tool directly with a simple expression
def test_execute_tool_calculate():
result = execute_tool("calculate", '{"expression": "10 * 5"}')
assert '"result": "50"' in result
# Test that calling an unknown tool returns an error
def test_execute_tool_unknown():
result = execute_tool("unknown_tool", '{}')
assert "error" in result
# Test the /ask endpoint end-to-end with a mocked agent
def test_ask_endpoint(test_client):
with patch("app.main.run_agent", return_value="Mocked answer"):
response = test_client.post("/ask", json={"question": "What is 2+2?"})
assert response.status_code == 200
assert response.json() == {"answer": "Mocked answer"}
What do these tests cover?
- test_execute_tool_calculate calls execute_tool directly to confirm the calculator works outside of the agent loop.
- test_execute_tool_unknown checks that passing a tool name that does not exist returns an error instead of crashing.
- test_ask_endpoint uses patch to replace the entire run_agent function with a fake return value, then sends a POST request to /ask to verify the full HTTP round trip.
- Save tests/test_agent.py.
✔️ Awesome, I've got everything!
Make sure you have saved tests/test_agent.py before running the tests.
ⓧ I'd like to double check the full code
from unittest.mock import MagicMock, patch
from app.agent import run_agent
from app.tools import execute_tool
def _make_tool_call(tool_call_id, name, arguments):
tool_call = MagicMock()
tool_call.id = tool_call_id
tool_call.function.name = name
tool_call.function.arguments = arguments
return tool_call
def test_agent_with_tool_call(mock_openai_client):
first_message = MagicMock()
first_message.tool_calls = [
_make_tool_call("call_123", "calculate", '{"expression": "2 + 2"}')
]
first_message.content = None
second_message = MagicMock()
second_message.tool_calls = None
second_message.content = "The result of 2 + 2 is 4."
first_response = MagicMock()
first_response.choices = [MagicMock(message=first_message)]
second_response = MagicMock()
second_response.choices = [MagicMock(message=second_message)]
mock_openai_client.chat.completions.create.side_effect = [
first_response,
second_response,
]
result = run_agent("What is 2 + 2?", mock_openai_client)
assert result == "The result of 2 + 2 is 4."
assert mock_openai_client.chat.completions.create.call_count == 2
def test_agent_without_tool_call(mock_openai_client):
message = MagicMock()
message.tool_calls = None
message.content = "Hello! How can I help you today?"
response = MagicMock()
response.choices = [MagicMock(message=message)]
mock_openai_client.chat.completions.create.return_value = response
result = run_agent("Hi there!", mock_openai_client)
assert result == "Hello! How can I help you today?"
assert mock_openai_client.chat.completions.create.call_count == 1
def test_execute_tool_calculate():
result = execute_tool("calculate", '{"expression": "10 * 5"}')
assert '"result": "50"' in result
def test_execute_tool_unknown():
result = execute_tool("unknown_tool", '{}')
assert "error" in result
def test_ask_endpoint(test_client):
with patch("app.main.run_agent", return_value="Mocked answer"):
response = test_client.post("/ask", json={"question": "What is 2+2?"})
assert response.status_code == 200
assert response.json() == {"answer": "Mocked answer"}
- Run the full test suite by running this command:
pytest -v
You should see all five tests pass with green PASSED markers next to each test name.
Seeing test failures?
- If you see ModuleNotFoundError: No module named 'app', make sure you are running pytest from the project root folder (the one containing the app/ directory), not from inside the tests/ folder.
- If you see fixture 'mock_openai_client' not found, check that conftest.py is saved inside the tests/ directory, not in the project root.
- If you see SyntaxError, double check that the JSON strings inside the test use escaped quotes correctly. Compare your code against the full file in the double check tab above.
Still stuck? Ask for help.
Five passing tests, zero API calls. Your test suite is ready for automation. Next up, you will set up a GitHub Actions pipeline that runs these tests and builds your Docker image on every push.
Set Up CI/CD with GitHub Actions
Your tests pass locally and your Docker container runs the API. The missing piece is automation.
Right now, every code change requires you to manually run pytest and rebuild the Docker image. A CI/CD pipeline handles both of those jobs automatically every time you push code. In this step, you'll use GitHub Actions to create that pipeline.
In this step, get ready to:
- Push your project to a new GitHub repository with a stored API key secret.
- Write a GitHub Actions workflow with test and build jobs.
- Verify the pipeline triggers automatically on every push.
Push your project to GitHub
Before you can automate anything, your code needs to live in a remote GitHub repository. You'll also store your OpenAI API key as a repository secret so the pipeline can access it without exposing it in code.
- Open your browser and go to github.com/new to create a new repository.
- Enter ai-agent-api as the repository name.
- Leave the repository set to Public.
- Do NOT check Add a README file. Leave all initialization options unchecked.
- Click Create repository.
You'll land on a setup page with instructions for pushing an existing repository. Keep this page open.
Your pipeline will need the OpenAI API key to run the agent. GitHub repository secrets store sensitive values so your workflow can use them without hardcoding them in files.
- In your new repository, click the Settings tab at the top of the page.
- In the left sidebar, click Secrets and variables, then click Actions.
- Click New repository secret.
- Enter OPENAI_API_KEY in the Name field.
- Paste your OpenAI API key into the Secret field.
- Click Add secret.
Why store the API key as a repository secret?
Repository secrets are encrypted values that GitHub Actions workflows can access at runtime. They never appear in logs or code.
This is the third secrets management pattern you've used in this project. Locally, you loaded the key from a .env file. In Docker, you passed it with the -e flag. In CI/CD, you use repository secrets.
Now push your existing project code to the new repository. Your project already has a Git repository initialized from Step 1.
- Commit all your project files and push them to GitHub by running these commands:
git add app/ tests/ requirements.txt Dockerfile .dockerignore
git commit -m "Initial commit: AI agent API"
git branch -M main
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/ai-agent-api.git"]]
git push -u origin main
What do these commands do?
- git add stages your application code, tests, requirements, Dockerfile, and .dockerignore for commit. You're adding specific paths instead of everything so that files like .env (which contains your API key) are never committed.
- git branch -M main renames your default branch to main to match GitHub's default.
- git remote add origin connects your local repository to the GitHub repository you just created.
- git push -u origin main uploads your code to GitHub and sets main as the default upstream branch.
- Refresh your GitHub repository page in the browser.
You should see your app/ folder, tests/ folder, Dockerfile, requirements.txt, and .dockerignore listed in the repository.
Push failed or permission denied?
- If you see remote: Permission denied, make sure you are logged into GitHub in your terminal. Run gh auth login if you have the GitHub CLI installed, or set up an SSH key or personal access token.
- If you see remote origin already exists, your repository already has a remote set. Run git remote -v to check the current remote URL.
Help me push my code to GitHub
Finally, create the directory structure for your GitHub Actions workflow file.
- Create the .github/workflows/ directory by running this command:
mkdir -p .github/workflows
Why this specific directory?
GitHub Actions looks for workflow files in the .github/workflows/ directory of your repository. Any .yml file placed here is automatically recognized as a workflow definition.
Write the CI/CD workflow
A GitHub Actions workflow is a YAML file that defines what happens automatically when you push code. Your workflow will have two jobs: one to run your tests, and one to build the Docker image.
The build job only runs if the test job passes. This fail-fast pattern saves time and resources by catching problems early.
- Create a new file called ci.yml inside the .github/workflows/ directory you just created, then paste in the following code:
name: AI Agent CI/CD
# Trigger on pushes and pull requests to the main branch
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
# Limit the workflow to read-only access
permissions:
contents: read
jobs:
# Job 1: Run the test suite
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests
run: pytest -v
# Job 2: Build the Docker image (only runs if tests pass)
build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v5
- name: Build Docker image
run: docker build -t ai-agent-api:${{ github.sha }} .
What does this workflow do?
This file defines the entire CI/CD pipeline. Here is what each section does:
- on: triggers the workflow on every push or pull request to the main branch.
- The test job checks out your code, installs Python 3.13, installs your dependencies from requirements.txt, and runs pytest -v.
- The build job has needs: test, which means it only runs after the test job passes.
- ${{ github.sha }} tags the Docker image with the unique commit hash so you can trace every image back to the exact code that built it.
Why separate test and build jobs?
Production CI/CD pipelines separate tests from builds so they fail fast. If a test breaks, the pipeline stops immediately instead of wasting time building a Docker image from broken code.
The needs: test dependency chain is a standard GitHub Actions pattern you'll see in most real-world repositories.
- Save .github/workflows/ci.yml.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete ci.yml file for comparison:
name: AI Agent CI/CD
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests
run: pytest -v
build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v5
- name: Build Docker image
run: docker build -t ai-agent-api:${{ github.sha }} .
Push the workflow and verify the pipeline
Your workflow file is ready locally. The moment you push it to GitHub, the pipeline will trigger for the first time.
- Stage, commit, and push the workflow file by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add CI/CD workflow"
git push
What happens after you push?
GitHub detects the new .yml file in .github/workflows/ and immediately starts running your workflow. The test job runs first. If all five of your tests pass, the build job kicks off and builds the Docker image.
- In your browser, go to your GitHub repository.
- Click the Actions tab at the top of the page.
You should see a workflow run called Add CI/CD workflow (matching your commit message). It may show a yellow dot while running or a green check if it already passed.
- Click the workflow run to open it.
- Click the test job to expand its steps. You should see the Run tests step showing all five tests passing with green checkmarks.
- Click the build job. You should see the Build Docker image step completing successfully.
Seeing a red X on the test or build job?
- Click the failing job to see the error logs. The most common cause is a missing dependency in requirements.txt. Make sure httpx is listed (the FastAPI test client depends on it).
- If the build job fails, check that your Dockerfile was pushed. Run git status locally to see if any files are unstaged.
- If you see python-version: 3.13 not found, make sure your ci.yml uses python-version: "3.13" with quotes around the version number.
Help me debug my failing GitHub Actions workflow
Now verify that the pipeline triggers automatically on future pushes. You'll make a small change to your system prompt and push it.
- In app/agent.py, find this line inside the system prompt:
"You are a helpful assistant with access to tools. "
- Add friendly before assistant, so the line now reads:
"You are a helpful, friendly assistant with access to tools. "
- Save app/agent.py.
- Commit and push the change by running these commands:
git add app/agent.py
git commit -m "Update system prompt"
git push
- Go back to the Actions tab in your GitHub repository.
You should see a second workflow run called Update system prompt appear. It triggered automatically because you pushed to main. Both jobs should pass again with green checkmarks.
Every push is now automated
From this point on, every push to main automatically runs your test suite and builds a fresh Docker image. If a test breaks, the build never starts. This is the exact workflow AI DevOps engineers manage in production.
Your AI agent now has a fully automated pipeline. Push code, tests run, Docker image builds. Next up, you'll take on the Secret Mission and add a production-grade health check to your application.
Secret mission
Add a Health Check Endpoint
Your pipeline builds and tests your code. But how do you know the container actually starts up and serves traffic? Add a health check endpoint and wire it into Docker and CI/CD for true production readiness.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs mostly locally, so ongoing costs are minimal. The only cost-bearing resource is your OpenAI API key, which is only charged when you send requests.
Resources you used:
- Local project directory with Python source code, tests, and Dockerfile.
- Python virtual environment (venv).
- Docker image (ai-agent-api).
- Docker container running on port 8000.
- GitHub repository with your code and Actions workflow.
- GitHub repository secret (OPENAI_API_KEY).
- Local .env file containing your OpenAI API key.
Keep everything running
No action needed. Choose this if you are still actively building on this project or planning to extend it soon.
- Your Docker container may still be running in the background. You can check by running docker ps in your terminal.
- Your GitHub Actions workflow will run on every push to main. This is free for public repositories and uses your free Actions minutes for private repositories.
- Your OpenAI API key is only charged when you send requests. No idle costs.
Pause - I'll come back to this later
Shut down running processes to free up memory, but keep your files and repository intact so you can pick up where you left off.
- Stop any running Docker containers by running:
docker stop $(docker ps -q --filter ancestor=ai-agent-api)
- Stop the local uvicorn server if it is still running by pressing Ctrl+C in the terminal.
- Keep your project directory, GitHub repository, and Docker image. They do not cost anything while idle.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to clean everything up.
- Stop and remove Docker containers by running:
docker stop $(docker ps -q --filter ancestor=ai-agent-api)
docker rm $(docker ps -aq --filter ancestor=ai-agent-api)
- Remove the Docker image by running:
docker rmi ai-agent-api
- Delete your local project directory from your Desktop.
- Delete the GitHub repository by going to your repository page, clicking Settings, scrolling to the bottom, and clicking Delete this repository.
- Delete the .env file if it still exists on your machine. This contains your OpenAI API key.
- If you created the OpenAI API key specifically for this project, revoke it from your OpenAI dashboard at platform.openai.com.
Mission accomplished
That's a Wrap!
Nice work! You just built the exact pipeline that AI DevOps teams use to ship GenAI applications to production.
You went from an empty folder to a fully containerized AI agent API with automated testing and continuous integration. Push code, tests run, image builds. That is a real production workflow.
Here's what you learned:
- Built an AI agent API using OpenAI tool calling and FastAPI, with real tool execution for math and date/time queries.
- Containerized the application with Docker using production best practices like slim base images, layer caching, and a .dockerignore file.
- Set up a CI/CD pipeline with GitHub Actions that automatically runs mocked tests and builds the Docker image on every push.
- Secret Mission: Added a health check endpoint and wired it into both Docker and the CI/CD pipeline for production readiness.
Ready to quiz yourself?