Quick Answer: You can build a working AI agent in Python in under 80 lines of code — an LLM connection, a tool registry, and a loop that decides when to act and when to stop.
Building an AI agent in Python is easier than most tutorials make it seem. At its core, an agent is just a loop: the LLM thinks, decides whether to call a tool, reads the result, and repeats until the task is done. No PhD required, no massive framework dependency — just Python and an API key. Tools like cowork.ink make it even simpler when you're ready to orchestrate agents across a team.
This tutorial walks you through building an AI agent from scratch, then shows you when and how to use a framework instead. You'll get complete, runnable code at every step.
A Python AI agent that can answer questions, call external tools (like a calculator and web search), maintain conversation memory, and decide autonomously when to stop. All in pure Python — no frameworks.
Prerequisites
Before you start, make sure you have:
- Python 3.10+ installed
- An API key from OpenAI, Anthropic, or Google (we'll use OpenAI in this tutorial)
- Basic Python knowledge (functions, classes, dictionaries)
Set up your project:
mkdir my-ai-agent && cd my-ai-agent
python -m venv venv && source venv/bin/activate
pip install openai python-dotenv
Create a .env file with your API key:
OPENAI_API_KEY=sk-your-key-here
Step 1 — Connect to the LLM
Every agent starts with an LLM connection. This is the "brain" that reasons about what to do next.
import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI()
def chat(messages: list[dict]) -> dict:
"""Send messages to the LLM and return the response."""
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
)
return response.choices[0].message
This function sends a conversation history to the LLM and returns its response. Simple — but not yet an agent. An agent needs tools to interact with the world.
Step 2 — Define Tools
Tools are functions the agent can call. You define both the function itself and a schema that tells the LLM what the tool does, what parameters it expects, and when to use it.
import json
# The actual tool functions
def calculate(expression: str) -> str:
"""Evaluate a math expression safely."""
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return "Error: invalid characters in expression"
try:
return str(eval(expression)) # safe with character allowlist
except Exception as e:
return f"Error: {e}"
def get_weather(city: str) -> str:
"""Get current weather for a city (mock)."""
# Replace with a real API call in production
weather_data = {
"london": "15°C, cloudy",
"new york": "22°C, sunny",
"tokyo": "18°C, rainy",
}
return weather_data.get(city.lower(), f"No data for {city}")
# Tool schemas for the LLM
tools = [
{
"type": "function",
"function": {
"name": "calculate",
"description": "Evaluate a mathematical expression",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Math expression, e.g. '2 + 2 * 3'"
}
},
"required": ["expression"]
}
}
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. 'London'"
}
},
"required": ["city"]
}
}
}
]
# Registry maps tool names to functions
tool_registry = {
"calculate": calculate,
"get_weather": get_weather,
}
The schema format follows the OpenAI function calling spec, which Anthropic and Google also support with minor variations.
The LLM never executes code directly. It returns a JSON object saying which tool to call and with what arguments. Your code then executes the function and feeds the result back. This is the foundation of how AI agents work.
Step 3 — Build the Agent Loop
The agent loop is the core pattern that makes an agent an agent. It's what separates autonomous AI from a simple chatbot. The loop follows the ReAct pattern — Reason, Act, Observe, Repeat.
def run_agent(user_message: str, system_prompt: str = None):
"""Run the agent loop until the task is complete."""
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": user_message})
max_iterations = 10 # safety limit
for i in range(max_iterations):
# 1. Think — ask the LLM what to do
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
)
assistant_message = response.choices[0].message
messages.append(assistant_message)
# 2. Check — did the LLM want to call a tool?
if not assistant_message.tool_calls:
# No tool calls = the agent is done
return assistant_message.content
# 3. Act — execute each tool call
for tool_call in assistant_message.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
# Execute the tool
func = tool_registry.get(func_name)
if func:
result = func(**func_args)
else:
result = f"Error: unknown tool '{func_name}'"
# 4. Observe — feed the result back to the LLM
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result),
})
# Loop continues — LLM will see the tool results
# and decide whether to call more tools or respond
return "Agent reached maximum iterations."
Here's what happens at each step:
- Think — the LLM receives the full conversation and decides what to do
- Check — if no tool calls are returned, the agent has its final answer
- Act — execute the requested tool(s) and capture results
- Observe — append results to the conversation so the LLM can see them
The loop repeats until the LLM responds without requesting any tools — that's the natural stop condition.
Step 4 — Add Conversation Memory
Right now our agent handles one request and exits. To make it conversational, add memory — a persistent message list across turns.
class Agent:
def __init__(self, system_prompt: str = "You are a helpful assistant."):
self.client = OpenAI()
self.messages = [{"role": "system", "content": system_prompt}]
def chat(self, user_message: str) -> str:
"""Send a message and get a response, maintaining history."""
self.messages.append({"role": "user", "content": user_message})
for _ in range(10): # max iterations
response = self.client.chat.completions.create(
model="gpt-4o",
messages=self.messages,
tools=tools,
)
msg = response.choices[0].message
self.messages.append(msg)
if not msg.tool_calls:
return msg.content
for tc in msg.tool_calls:
func = tool_registry.get(tc.function.name)
result = func(**json.loads(tc.function.arguments)) if func else "Unknown tool"
self.messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": str(result),
})
return "Max iterations reached."
Now you can have multi-turn conversations:
agent = Agent("You are a helpful math and weather assistant.")
print(agent.chat("What's 234 * 567?"))
print(agent.chat("Now divide that result by 3"))
print(agent.chat("What's the weather in Tokyo?"))
The agent remembers previous answers because self.messages persists between calls. For deeper memory strategies — including long-term and semantic memory — see our guide to AI agent memory.
Step 5 — Test Your Agent
Run a few test cases to verify everything works:
if __name__ == "__main__":
agent = Agent(
"You are a helpful assistant with access to a calculator and weather data. "
"Use tools when needed. Be concise."
)
# Test 1: Direct question (no tools needed)
print(agent.chat("What is an AI agent?"))
# Test 2: Single tool call
print(agent.chat("What's 15% of 2499.99?"))
# Test 3: Multiple tool calls
print(agent.chat("What's the weather in London and New York?"))
# Test 4: Multi-step reasoning
print(agent.chat("If London is 15°C, convert that to Fahrenheit"))
You should see the agent automatically deciding when to use tools and when to answer directly.
When to Use a Framework Instead
Building from scratch teaches you the fundamentals, but frameworks save time in production. Here's when to reach for one:
| Scenario | Recommendation |
|---|---|
| Learning / prototyping | Build from scratch (as above) |
| Complex stateful workflows | LangGraph — graph-based control flow, checkpointing |
| Multi-agent collaboration | CrewAI — role-based agents, built-in MCP support |
| Fast GPT-only prototype | OpenAI Agents SDK — working agent in ~20 lines |
| Team orchestration | cowork.ink — shared workspace, no code required |
For a detailed framework breakdown, see our LangGraph vs CrewAI vs OpenAI Agents SDK comparison. If you want to build on top of the LangChain ecosystem specifically, our LangChain tutorial walks through the fundamentals.
Quick Example: LangGraph Agent
Here's the same agent built with LangGraph for comparison:
from langgraph.prebuilt import create_react_agent
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
@tool
def calculate(expression: str) -> str:
"""Evaluate a math expression."""
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return "Error: invalid characters"
return str(eval(expression))
llm = ChatOpenAI(model="gpt-4o")
agent = create_react_agent(llm, [calculate])
result = agent.invoke(
{"messages": [{"role": "user", "content": "What's 234 * 567?"}]}
)
print(result["messages"][-1].content)
LangGraph reduces boilerplate but hides the loop. Start from scratch first, then adopt a framework when you need features like agent orchestration or persistence. For a deeper LangGraph walkthrough, see our LangGraph tutorial.
Production Best Practices
Before deploying your agent, address these concerns:
- Set iteration limits. Always cap the agent loop (we used
max_iterations = 10). Runaway agents burn tokens and money. - Validate tool inputs. Never pass unsanitized input to
eval(),exec(), or shell commands. Use allowlists like the calculator example above. - Handle errors gracefully. Wrap tool calls in try/except and return error messages the LLM can understand and recover from.
- Monitor costs. Each loop iteration is an API call. Track token usage and set budget alerts. See our AI agent cost optimization guide for strategies.
- Add logging. Log every tool call, its arguments, and results. You'll need this for debugging and agent observability.
Never give agents unrestricted shell access or database write permissions in production. Start with read-only tools and expand access carefully. Read our AI agent security guide for a complete checklist.
Get Started
You've just built a fully functional AI agent in Python — from a raw LLM call to tool-calling, memory, and an autonomous agent loop. The complete code is under 80 lines.
When you're ready to scale from a single agent to a team of collaborating agents, cowork.ink gives your entire engineering team a shared workspace for AI agent orchestration — no prompt gymnastics, no framework lock-in.
Try cowork.ink free — set up your first AI agent in minutes.