Skip to content

How to Create an AI Agent is published by General Action, the company that makes Emdash. When we recommend Emdash, we are recommending our own product. About this site

Build an AI agent in Python

A minimal AI agent in Python is a single loop: call the model with a list of tools, run whichever tool it asks for, add the result to the conversation, and call the model again until it answers. The version on this page is about 75 lines, depends only on the official anthropic package, and answers questions about a folder of text files.

  1. Python 3.10 or newer. The current anthropic package on PyPI requires it.
  2. An API key. Create one in the Claude Console. The SDK reads it from the ANTHROPIC_API_KEY environment variable, as the quickstart describes.
  3. A funded account. This is not free to run. Each request is billed per token, and one run of the agent makes several requests. Read the pricing page and check your billing settings first.

Set up a folder with two sample notes:

Terminal window
pip install anthropic
export ANTHROPIC_API_KEY="your-api-key-here"
mkdir notes
echo "Standup 3 Nov: launch moved to 21 Nov because the payment tests are failing." > notes/standup.txt
echo "Shopping: oat milk, coffee filters." > notes/shopping.txt

The export line is for macOS and Linux shells. On Windows, set the environment variable the way your shell expects. Never paste the key into the script itself.

Save this as agent.py next to the notes folder and run python agent.py.

agent.py
from pathlib import Path
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
MODEL = "claude-opus-5-5"
NOTES = Path("notes").resolve() # the only folder the agent can see
MAX_STEPS = 10 # stop a run that goes in circles
SYSTEM = "Answer from the notes folder. Search and read before you answer."
tools = [
{
"name": "search_notes",
"description": "Return the names of files in the notes folder whose text contains the query.",
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string", "description": "A word or phrase, e.g. launch"}},
"required": ["query"],
},
},
{
"name": "read_note",
"description": "Return the full text of one file in the notes folder.",
"input_schema": {
"type": "object",
"properties": {"name": {"type": "string", "description": "A file name, e.g. standup.txt"}},
"required": ["name"],
},
},
]
def notes():
"""The .txt files that really live in the notes folder. Links that point elsewhere are skipped."""
return [p for p in sorted(NOTES.glob("*.txt")) if p.is_file() and p.resolve().parent == NOTES]
def run_tool(name, tool_input):
if name == "search_notes":
query = tool_input["query"].lower()
hits = [p.name for p in notes() if query in p.read_text().lower()]
return "\n".join(hits) or "No matching notes."
if name == "read_note":
match = [p for p in notes() if p.name == tool_input["name"]]
if not match:
raise ValueError("There is no note with that name in the notes folder.")
return match[0].read_text() or "(empty file)"
raise ValueError(f"Unknown tool: {name}")
def run_agent(task):
messages = [{"role": "user", "content": task}]
for _ in range(MAX_STEPS):
response = client.messages.create(
model=MODEL, max_tokens=4096, system=SYSTEM, tools=tools, messages=messages
)
if response.stop_reason != "tool_use":
text = "".join(block.text for block in response.content if block.type == "text")
return text or f"Stopped without an answer: {response.stop_reason}"
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f"-> {block.name}({block.input})")
result = {"type": "tool_result", "tool_use_id": block.id}
try:
result["content"] = run_tool(block.name, block.input)
except Exception as exc: # tell the model what failed instead of crashing
result.update(content=str(exc), is_error=True)
tool_results.append(result)
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
return f"Stopped after {MAX_STEPS} steps without finishing."
print(run_agent("When is the launch, and why did the date change?"))

anthropic.Anthropic() creates the client and picks up your key from the environment. MODEL is a model ID from Anthropic’s models overview, which suggests starting with Claude Opus 5.5 if you are unsure which to use. Model IDs are retired over time, so take the current one from that page.

tools is a list of plain dictionaries. Each has the three fields the API expects for a tool you define yourself:

Field What it holds
name The identifier the model uses to ask for the tool
description Plain-language text on what the tool does and returns
input_schema A JSON Schema object describing the inputs

The description does more work than it appears to. It is the only thing the model knows about your function, and Anthropic’s tool definition guide calls detailed descriptions “by far the most important factor in tool performance”. The ones here are short to keep the listing readable. Lengthen them in a real agent.

run_tool is ordinary Python with no AI in it. It receives the tool name and the inputs the model chose, and returns a string.

Two details matter. Both tools get their files from one helper, notes(), which lists only the .txt files that really sit in the notes folder. read_note looks the requested name up in that list and never builds a path from the model’s input, so a request for ../secrets.txt or an absolute path finds nothing. A symbolic link in the folder that points somewhere else is left out of the list, so neither tool will search or read through it. And failures are raised as exceptions, which the loop turns into a message the model can read.

Keeping the check in one place is the point. If search_notes and read_note each had their own idea of which files are allowed, the weaker one would set the real limit.

Each pass through the for loop is one request to the Messages API with five arguments: the model, a max_tokens cap on the reply, the system prompt, the tools and the conversation so far.

The response carries a stop_reason. When it is "tool_use", the model is asking you to act, and response.content contains one or more tool_use blocks. Each block has an id, a name and an input. For each one, the code builds a matching tool_result block:

Field What it holds
type Always "tool_result"
tool_use_id The id of the request this answers
content What the tool returned, as a string
is_error True when the tool failed, so the model can retry or explain

Then two messages are appended. First the model’s whole reply, as response.content and not only its text, with the role assistant. Second, one user message holding every result for that turn. Anthropic’s guide to handling tool calls requires the results to follow the request immediately, with nothing in between.

Any stop_reason other than "tool_use" ends the run. Normally that is "end_turn", meaning the model has answered. Anthropic’s explanation of the loop lists the others, such as "max_tokens" and "refusal". In those cases there may be no text, so the function reports the reason.

MAX_STEPS is the safety net. If the model is still asking for tools after ten passes, the run stops and says so. Without a limit like this, a confused agent keeps spending.

Every tool call is printed on a line beginning with ->, so you can watch the agent work: typically a search_notes call, then a read_note call on the file it found, then the answer. The model chooses the calls, so the exact sequence can differ from one run to the next.

Try a question the notes cannot answer, such as “Who approved the budget?”. A well-behaved run searches, finds nothing and tells you so. If yours invents an answer, tighten the system prompt and run it again. That small experiment is your first test case.

  • Swap the tools. Keep the dictionary shape, add a branch to run_tool, and write a description that says when to use the tool and what it returns.
  • Rewrite SYSTEM to state the job, the limits and what to do when the agent is unsure.
  • Add a confirmation step before any tool that writes. A plain input("Allow this? ") inside run_tool is enough to start with.
  • Write down ten questions and the answers you expect, then run them after every change. Testing and guardrails shows how.

The Anthropic SDK also ships a beta tool runner that drives this loop for you: you decorate Python functions and it builds the schemas and handles the round trips. The last section of Anthropic’s tutorial shows it. Write the loop by hand once anyway. You will debug faster when you know what the helper is doing.

The same design works with other providers. Only the names change, so use their guides for the exact request shapes: OpenAI function calling and Gemini function calling.

When the loop needs more than this, such as pausing for approval or resuming after a crash, see AI agent frameworks compared. For how tool descriptions and schemas work in more depth, see Tools and MCP.

Frequently asked questions

How many lines of Python does a basic AI agent need?

About 75, including two tool definitions. The loop itself is under 25 lines. It calls the model, runs each tool the model asks for, appends the results to the conversation and repeats until the model answers.

Do I need an API key to run this agent, and does it cost money?

Yes to both. The script calls Anthropic's API, which needs a key from the Claude Console, and every request is billed to that account by the number of tokens sent and received. One run of this agent makes several requests. Check the current pricing page before you run it repeatedly.

Can I build the same agent with OpenAI or Gemini instead of Claude?

Yes. The pattern is identical with any model that supports function calling. You describe the tools, the model returns a structured request, your code runs it and returns the result. The parameter and field names differ between providers, so follow that provider's own function-calling guide for the exact shapes.

Why does the loop have a maximum number of steps?

Because nothing else guarantees the agent will stop. A model can keep requesting tools without making progress, and every request costs money. A fixed step limit ends the run and tells you it did not finish.

Is it safe to run this example on my computer?

The two tools can only read text files that sit directly inside one folder named notes. Names that point anywhere else, and links that lead out of the folder, are refused. The agent cannot write, delete or run commands. That changes as soon as you add a tool that can, so add approval and tighter limits before you do.