Every AI agent framework ships thousands of lines of code. The core pattern underneath is a while loop.
LangChain wraps it in AgentExecutor. CrewAI wraps it in Crew.kickoff(). The OpenAI Agents SDK wraps it in Runner.run(). Strip all that away and you get 50 lines of Python that do the same thing: call the model, check if it wants to use a tool, execute the tool, feed the result back, repeat.
This article shows you that loop. No framework. No abstractions. Just the OpenAI Python SDK, a dictionary of functions, and a while loop.
Why Build Without a Framework
Three reasons.
You understand what breaks. When your LangChain agent silently drops a tool call, you need to know whether the issue is in the prompt, the tool schema, or the framework's message handling. Knowing the raw loop means you can diagnose in minutes instead of hours.
You control the loop. Frameworks make opinionated decisions about retry logic, max iterations, and error handling. When those decisions don't match your use case, you fight the framework. With the raw loop, you change one line.
You ship faster for simple cases. Not every agent needs memory, planning, and multi-agent coordination. A tool-calling loop with 3 functions handles 80% of agent use cases in production.
The Pattern
Every tool-calling AI agent follows the same 4-step cycle:
1. Send messages + tool definitions to the model
2. Check if the model wants to call a tool
- Yes → execute the tool, append the result, go to step 1
- No → return the model's response
That's it. The model decides when to call tools and when to stop. Your code handles execution. The loop runs until the model says "I'm done."
Step 1: Define Your Tools
Tools are JSON schemas that tell the model what functions are available. Each tool needs a name, description, and parameter schema.
Here are two tools — one to get the current weather, one to convert between temperature units:
import json
from openai import OpenAI
client = OpenAI() # uses OPENAI_API_KEY env var
# The actual functions your tools call
def get_weather(location: str) -> str:
"""Simulated weather lookup."""
weather_data = {
"San Francisco, CA": {"temp": 15, "unit": "celsius", "condition": "foggy"},
"New York, NY": {"temp": 22, "unit": "celsius", "condition": "sunny"},
"London, UK": {"temp": 11, "unit": "celsius", "condition": "rainy"},
}
data = weather_data.get(location, {"temp": 20, "unit": "celsius", "condition": "unknown"})
return json.dumps(data)
def convert_temperature(value: float, from_unit: str, to_unit: str) -> str:
"""Convert between celsius and fahrenheit."""
if from_unit == "celsius" and to_unit == "fahrenheit":
result = (value * 9 / 5) + 32
elif from_unit == "fahrenheit" and to_unit == "celsius":
result = (value - 32) * 5 / 9
else:
result = value
return json.dumps({"value": round(result, 1), "unit": to_unit})
Now define the tool schemas that the model reads:
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather in a given location.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and state, e.g. San Francisco, CA"
}
},
"required": ["location"],
"additionalProperties": False
}
}
},
{
"type": "function",
"function": {
"name": "convert_temperature",
"description": "Convert a temperature value between celsius and fahrenheit.",
"parameters": {
"type": "object",
"properties": {
"value": {"type": "number", "description": "Temperature value to convert"},
"from_unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
"to_unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["value", "from_unit", "to_unit"],
"additionalProperties": False
}
}
}
]
# Map function names to actual Python functions
available_functions = {
"get_weather": lambda args: get_weather(args["location"]),
"convert_temperature": lambda args: convert_temperature(
args["value"], args["from_unit"], args["to_unit"]
),
}
The available_functions dictionary is the bridge between the model's tool calls and your Python code. The model outputs a function name and arguments. You look up the function and call it.
Step 2: The Agent Loop
Here is the complete loop. 50 lines. No framework:
def agent_loop(user_message: str, max_iterations: int = 10) -> str:
"""Run a tool-calling agent loop until the model stops calling tools."""
messages = [
{"role": "system", "content": "You are a helpful assistant. Use tools when needed."},
{"role": "user", "content": user_message},
]
for i in range(max_iterations):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
choice = response.choices[0]
assistant_message = choice.message
# Append the assistant's response to the conversation
messages.append(assistant_message)
# If the model didn't call any tools, we're done
if not assistant_message.tool_calls:
return assistant_message.content
# Execute each tool call and append results
for tool_call in assistant_message.tool_calls:
function_name = tool_call.function.name
function_args = json.loads(tool_call.function.arguments)
print(f" Tool call: {function_name}({function_args})")
# Look up and execute the function
func = available_functions.get(function_name)
if func:
result = func(function_args)
else:
result = json.dumps({"error": f"Unknown function: {function_name}"})
# Send the result back to the model
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
return "Max iterations reached without a final response."
Three things to notice:
The loop is bounded. max_iterations prevents infinite tool-calling cycles. Every production agent needs this. Without it, a confused model can call tools forever.
Tool results go back as role: "tool" messages. Each result includes the tool_call_id from the model's request. This is how the API matches results to requests — especially when the model calls multiple tools in parallel.
The exit condition is simple. When assistant_message.tool_calls is None or empty, the model is done calling tools and has produced a text response. Return it.
Step 3: Run It
answer = agent_loop("What's the weather in San Francisco? Give me the temperature in Fahrenheit.")
print(answer)
Output:
Tool call: get_weather({'location': 'San Francisco, CA'})
Tool call: convert_temperature({'value': 15, 'from_unit': 'celsius', 'to_unit': 'fahrenheit'})
The weather in San Francisco is currently foggy. The temperature is 59°F (15°C).
The model called get_weather first, got back 15°C, then called convert_temperature to convert it to Fahrenheit. Two tool calls, one loop, zero framework code.
Adding Error Handling
Production agents fail. APIs timeout. Functions throw exceptions. The raw loop makes error handling explicit:
def agent_loop_robust(user_message: str, max_iterations: int = 10) -> str:
"""Agent loop with error handling for production use."""
messages = [
{"role": "system", "content": "You are a helpful assistant. Use tools when needed."},
{"role": "user", "content": user_message},
]
for i in range(max_iterations):
try:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
timeout=30,
)
except Exception as e:
return f"API call failed: {e}"
choice = response.choices[0]
assistant_message = choice.message
messages.append(assistant_message)
if not assistant_message.tool_calls:
return assistant_message.content
for tool_call in assistant_message.tool_calls:
function_name = tool_call.function.name
function_args = json.loads(tool_call.function.arguments)
func = available_functions.get(function_name)
if func:
try:
result = func(function_args)
except Exception as e:
result = json.dumps({"error": str(e)})
else:
result = json.dumps({"error": f"Unknown function: {function_name}"})
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
return "Max iterations reached."
Two changes: API calls are wrapped in try/except, and tool execution catches exceptions and sends the error message back to the model. The model can then decide what to do — retry, try a different approach, or tell the user.
The Same Pattern With Anthropic
The core loop is identical. The API fields change. Here is the Anthropic equivalent, using the anthropic Python SDK:
import anthropic
anthropic_client = anthropic.Anthropic() # uses ANTHROPIC_API_KEY env var
# Anthropic uses input_schema instead of parameters
anthropic_tools = [
{
"name": "get_weather",
"description": "Get the current weather in a given location.",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and state, e.g. San Francisco, CA"
}
},
"required": ["location"]
}
}
]
def anthropic_agent_loop(user_message: str, max_iterations: int = 10) -> str:
"""Agent loop using Anthropic's Messages API."""
messages = [{"role": "user", "content": user_message}]
for i in range(max_iterations):
response = anthropic_client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
system="You are a helpful assistant. Use tools when needed.",
tools=anthropic_tools,
messages=messages,
)
# Append assistant response
messages.append({"role": "assistant", "content": response.content})
# If the model stopped naturally, extract text and return
if response.stop_reason != "tool_use":
return "".join(
block.text for block in response.content if block.type == "text"
)
# Execute tool calls and send results back
tool_results = []
for block in response.content:
if block.type == "tool_use":
func = available_functions.get(block.name)
result = func(block.input) if func else json.dumps({"error": "unknown"})
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})
return "Max iterations reached."
Three differences from OpenAI:
-
Tool schema key:
input_schemainstead ofparametersnested underfunction. -
Stop reason: Check
response.stop_reason == "tool_use"instead of checkingtool_callson the message. -
Tool results: Sent as a
usermessage withtool_resultcontent blocks, not as separaterole: "tool"messages.
The loop structure is the same. Call the model. Check for tool calls. Execute. Append results. Repeat.
When to Use a Framework Instead
This raw loop handles single-agent, tool-calling workflows. Use a framework when you need:
- Multi-agent orchestration. Multiple agents handing off tasks to each other. LangGraph or the OpenAI Agents SDK handle this with graph-based routing.
- Persistent memory. Conversation history that survives across sessions. Frameworks integrate with vector stores and memory backends.
- Streaming with tool calls. Streaming partial responses while tool calls are in progress requires careful chunk handling that frameworks abstract away.
- Guardrails and moderation. Input/output filtering, content policies, and safety checks that run on every loop iteration.
For everything else, 50 lines of Python gets you further than you think.
The Complete Script
Here is the full working example in one file. Copy it, set your OPENAI_API_KEY, and run it:
import json
from openai import OpenAI
client = OpenAI()
def get_weather(location: str) -> str:
weather_data = {
"San Francisco, CA": {"temp": 15, "unit": "celsius", "condition": "foggy"},
"New York, NY": {"temp": 22, "unit": "celsius", "condition": "sunny"},
"London, UK": {"temp": 11, "unit": "celsius", "condition": "rainy"},
}
data = weather_data.get(location, {"temp": 20, "unit": "celsius", "condition": "unknown"})
return json.dumps(data)
def convert_temperature(value: float, from_unit: str, to_unit: str) -> str:
if from_unit == "celsius" and to_unit == "fahrenheit":
result = (value * 9 / 5) + 32
elif from_unit == "fahrenheit" and to_unit == "celsius":
result = (value - 32) * 5 / 9
else:
result = value
return json.dumps({"value": round(result, 1), "unit": to_unit})
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather in a given location.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and state, e.g. San Francisco, CA"
}
},
"required": ["location"],
"additionalProperties": False
}
}
},
{
"type": "function",
"function": {
"name": "convert_temperature",
"description": "Convert a temperature value between celsius and fahrenheit.",
"parameters": {
"type": "object",
"properties": {
"value": {"type": "number", "description": "Temperature value"},
"from_unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
"to_unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["value", "from_unit", "to_unit"],
"additionalProperties": False
}
}
}
]
available_functions = {
"get_weather": lambda args: get_weather(args["location"]),
"convert_temperature": lambda args: convert_temperature(
args["value"], args["from_unit"], args["to_unit"]
),
}
def agent_loop(user_message: str, max_iterations: int = 10) -> str:
messages = [
{"role": "system", "content": "You are a helpful assistant. Use tools when needed."},
{"role": "user", "content": user_message},
]
for i in range(max_iterations):
response = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=tools
)
assistant_message = response.choices[0].message
messages.append(assistant_message)
if not assistant_message.tool_calls:
return assistant_message.content
for tool_call in assistant_message.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
func = available_functions.get(func_name)
result = func(func_args) if func else json.dumps({"error": "unknown"})
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
return "Max iterations reached."
if __name__ == "__main__":
answer = agent_loop("What's the weather in San Francisco in Fahrenheit?")
print(answer)
50 lines of core logic. Two tools. One loop. A working AI agent.
Follow @klement_gunndu for more AI engineering content. We're building in public.
Top comments (0)