How to Build Your First AI Agent with Python

,
AI Tooling logo for AI agent and LLM development guides

An AI agent is not simply a chatbot with a more confident system prompt. In a useful engineering application, the model can decide what information or action it needs, call an approved tool, inspect the result, and continue until it can answer or stop safely.

The hard part is not making a model call. It is defining the boundary around that call: what the agent is allowed to do, how tool arguments are validated, how many steps are permitted, what happens when a tool fails, and where a human must approve an action. This guide builds that loop in Python without hiding it behind a large agent framework.

What makes a Python program an agent?

A conventional script follows a fixed sequence. An agent has a model choose among available actions during execution. The application still owns the tools and the loop; the model does not receive arbitrary Python execution privileges.

user request
|
v
model receives instructions + tool schemas
|
+-- final answer
|
+-- tool call --> application validates arguments
|
v
tool executes
|
v
result returns to model

Modern tool-calling APIs generally follow this cycle: send a request with tools, receive a tool call, execute the function in your application, send the tool result back, and repeat until the model returns a final response. That loop is the agent’s smallest useful core.

Choose one narrow task first

Do not start with “build a general autonomous agent.” Start with a task where the model needs a small amount of controlled external information. The example below answers questions about a fictional engineering inventory. It can look up a component, but it cannot modify inventory or run shell commands.

This constraint is intentional. A read-only tool makes it easier to inspect the model’s decisions before adding side effects.

Set up a small Python project

mkdir python-agent-demo
cd python-agent-demo
python -m venv .venv
source .venv/bin/activate
python -m pip install openai python-dotenv

Store the API key outside the source tree:

export OPENAI_API_KEY="your-key-here"

Use your provider’s current model and API documentation when choosing the model name. Do not commit the key, print it in debugging output, or place it inside a tool argument.

Define a tool as a strict application boundary

INVENTORY = {
"camera-front": {"quantity": 12, "location": "A-14"},
"lidar-roof": {"quantity": 4, "location": "B-03"},
}
def lookup_part(part_id: str) -> dict[str, object]:
"""Return inventory data for one known part."""
if not isinstance(part_id, str):
raise TypeError("part_id must be a string")
part_id = part_id.strip().lower()
if not part_id or len(part_id) > 64:
raise ValueError("part_id is empty or too long")
item = INVENTORY.get(part_id)
if item is None:
return {"found": False, "part_id": part_id}
return {"found": True, "part_id": part_id, **item}

The model may request this function, but your application decides whether the call is valid. Validate types and ranges again in Python even when the API accepts a JSON schema. The tool is an API boundary, not a trusted extension of the model.

Describe the tool for model selection

TOOLS = [
{
"type": "function",
"name": "lookup_part",
"description": (
"Look up the quantity and storage location for a known "
"inventory part. Use this only when the user asks about "
"a specific part."
),
"parameters": {
"type": "object",
"properties": {
"part_id": {
"type": "string",
"description": "Exact part identifier, such as camera-front",
}
},
"required": ["part_id"],
"additionalProperties": False,
},
}
]

Tool descriptions are part of the agent’s behavior. “Database tool” is too vague if several tools exist. Explain when the tool should be used, when it should not be used, and what the argument means. A narrow schema reduces the number of bad decisions the model can make.

Implement the agent loop

import json
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
def run_agent(user_text: str) -> str:
input_items = [
{
"role": "user",
"content": user_text,
}
]
for step in range(4):
response = client.responses.create(
model=os.environ["OPENAI_MODEL"],
instructions=(
"You answer inventory questions. Use lookup_part when "
"a specific part must be checked. Never invent stock data."
),
tools=TOOLS,
input=input_items,
)
input_items += response.output
function_calls = [
item for item in response.output
if item.type == "function_call"
]
if not function_calls:
return response.output_text
for call in function_calls:
if call.name != "lookup_part":
raise RuntimeError(f"Unexpected tool: {call.name}")
try:
arguments = json.loads(call.arguments)
result = lookup_part(**arguments)
except (TypeError, ValueError, json.JSONDecodeError) as error:
result = {"error": str(error)}
input_items.append({
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(result),
})
raise RuntimeError("agent exceeded the maximum number of steps")

The maximum step count is not optional in production. A tool can return data that causes the model to call another tool repeatedly, or a prompt can accidentally create a loop. A bounded loop turns an infinite behavior into an inspectable failure.

Notice that the application appends the model’s output items before sending tool results. The model needs the call and its matching result in the conversation state. Dropping that relationship can produce confusing invalid-request errors or cause the model to lose track of what it asked for.

Run it and inspect the result

export OPENAI_MODEL="your-current-tool-capable-model"
python -c 'from agent import run_agent; print(run_agent("Where is the lidar roof unit?"))'

A successful answer should be grounded in the returned tool data, for example that the lidar-roof item has quantity 4 and location B-03. The exact wording depends on the model. Do not assert a specific output sentence unless you captured it from your own run.

For debugging, log the selected tool name, validated arguments, duration, and a request ID. Avoid logging secrets or sensitive tool results. A trace that only contains the final answer is not enough to explain why the agent made a bad decision.

Common issues that appear in the first agent

The model returns a final answer without using the tool

Check the tool description, the system instructions, and the request. If the user asks for a specific part but the description sounds optional or unrelated, the model may answer from its prior context. State that inventory facts must come from the tool and test with a request containing an unknown part.

The API rejects the tool definition

Tool schemas are API-specific. Check whether your provider expects function definitions nested under a function key or uses the newer Responses-style shape. Also check required properties, JSON types, and whether additionalProperties is supported by the selected endpoint.

JSON arguments fail to parse

Never concatenate the model’s argument string into a shell command or SQL statement. Parse it with json.loads, validate the result, and return a structured error to the model. If malformed arguments occur frequently, simplify the schema and reduce the number of optional fields.

The agent loops until the request times out

Set a maximum number of steps, record every tool call, and inspect whether the tool result actually answers the requested question. A tool that returns a large or ambiguous payload can make the model call it again. Narrow the response and add a stop condition for repeated calls.

A tool raises an exception

Catch expected application errors at the tool boundary and return a safe, concise error object. Do not expose stack traces, credentials, or internal file paths to the model. Unexpected exceptions should be recorded with a request ID and usually fail the operation rather than being silently converted into a plausible answer.

The result is technically correct but operationally unsafe

A read-only lookup and a delete-account tool should not have the same approval path. Separate read and write capabilities, require confirmation for consequential actions, use least-privilege credentials, and enforce authorization in the tool implementation. The model is not an access-control layer.

When to add a framework

Start with the raw loop when you need to understand state, tool calls, retries, and limits. Add a framework when you have repeated orchestration patterns such as handoffs, tracing, guardrails, sessions, or structured evaluation. A framework can reduce boilerplate, but it can also hide the exact message sequence that you need to debug.

For a single lookup tool, a direct API integration is often enough. For a larger system, consider an agent SDK, MCP servers for reusable external capabilities, or a graph-style workflow when the steps are mostly deterministic. “Agent” is not a reason to give the model control over every part of the application.

A production handoff checklist

  • Define the task and the tools the agent actually needs.
  • Validate every tool argument in application code.
  • Bound model steps, tool duration, output size, and total cost.
  • Log decisions with request IDs while redacting sensitive data.
  • Separate read-only tools from side-effecting tools.
  • Add approval before destructive or externally visible actions.
  • Test unknown inputs, tool failures, duplicate calls, and provider errors.
  • Keep a deterministic path for tasks that do not require model choice.

Conclusion

Your first Python AI agent should be small enough that you can explain every model turn and every tool call. The useful abstraction is not “the model does everything.” It is a bounded loop in which the model proposes an action and your application validates, executes, observes, and limits it.

Once that boundary is reliable, you can add more tools, persistent state, MCP servers, or a framework. Keep the control loop visible long enough to learn where the system actually fails.

For further reading, see the official function calling guide, the Agents overview, and ByteAtlas’s LLM Wiki guide for persistent knowledge workflows.

Leave a Reply

Your email address will not be published. Required fields are marked *