On this page · 14 sections
- What you are building: four parts and a loop
- Step 1: Set up the project
- Step 2: Define tools the model can choose well
- Step 3: Write the agent loop
- Step 4: Execute tools safely
- Step 5: Decide when the loop stops
- Step 6: Add memory and state
- Step 7: Port the loop to OpenAI and Gemini
- Common errors and fixes
- From demo to production: the six layers that matter
- Build from scratch, use a framework or buy a platform?
- For IT teams and engineering leads
- How eCorpIT can help
- References
Summary. To build an AI agent from scratch, write a loop that sends the conversation and JSON Schema tool definitions to a model, runs whichever tool the model asks for, appends the result and repeats until the model answers without a tool call or a step cap is hit. In Python the core is about 50 lines with no framework. Request shapes differ by vendor. Anthropic's Messages API stops with a tool_use stop reason (one of seven it documents) and expects tool_result blocks back. OpenAI's Responses API returns function_call items that you answer with function_call_output, matched on call_id. Google's Interactions API, recommended for all new Gemini development, returns function_call steps that you answer with function_result. Production adds evals, guardrails, cost control, observability, MCP for shared tools (revision 2026-07-28) and sandboxed code execution. OpenAI's hosted Evals platform shuts on 30 November 2026 and its original GPT-5 and o3 snapshots leave the API on 11 December 2026, so keep model names in configuration.
About this article. For Python developers and engineering leads who want to understand the tool calling loop before adopting a framework. On 11 November 2026 we checked every request shape, limit and date against Anthropic, OpenAI and Google API documentation, the MCP specification and PyPI, with anthropic 2.8.0, openai 3.49.0, google-genai 3.1.0 and mcp 2.3.0. The code was checked against those docs and for syntax, not run against live APIs; it is a teaching sample. Vendors change models and prices often, and no vendor paid for inclusion. Researched with AI assistance and reviewed by our editorial team.
What you are building: four parts and a loop
Anthropic's engineering guide separates workflows, where your code fixes the order of model calls, from agents, where the model decides its own steps and which tools to use. It recommends starting with the model API directly, because many agent patterns take only a few lines of code. That post dates from December 2024 and Anthropic notes its tooling has since changed, but the current API docs describe the same loop.
Every tool calling agent has four parts: a model call (conversation, system prompt, tools), tool definitions (name, description, JSON Schema), an executor that maps tool names to Python functions, and state (the message list plus anything you persist). The loop ties them together and decides when to stop.
If you have already chosen a framework, read our comparison of LangGraph, CrewAI, Mastra, PydanticAI and the OpenAI Agents SDK instead.
Step 1: Set up the project
You need Python 3.11 or later and an API key for at least one provider. The anthropic, openai and mcp packages accept 3.10, but google-genai 3.1.0 requires 3.11, as does the MCP SDK's development branch.
pip install anthropic openai google-genai "mcp[cli]"
export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY / GEMINI_API_KEY
export AGENT_MODEL=... # a current model ID from the provider's models page
Keep the model ID in configuration. OpenAI removes gpt-5, gpt-5-mini, gpt-5-nano, gpt-5-pro, o3 and o3-pro on 11 December 2026 and names GPT-5.6 models as replacements; a hard-coded ID becomes an outage that day.
Step 2: Define tools the model can choose well
A tool definition is a contract. The model never runs your code: it reads the name, description and schema, then emits a structured request that your code executes. The three main APIs use JSON Schema but differ in field names and in how results go back.
| Anthropic Messages API | OpenAI Responses API | Gemini Interactions API | |
|---|---|---|---|
| Tool object | name, description, input_schema | type function, name, description, parameters, strict | type function, name, description, parameters |
| Model requests a tool | tool_use block: id, name, input (dict) | function_call item: call_id, name, arguments (JSON string) | function_call step: id, name, arguments (dict) |
| You return the result | User message of tool_result blocks: tool_use_id, content, optional is_error | function_call_output item: call_id, output | function_result item: name, call_id, result |
| Conversation state | Resend the full messages list | Resend input plus response output, or pass previous_response_id | Pass previous_interaction_id, or set store to false and resend every step |
| Schema guarantee | strict true on the tool | strict true, additionalProperties false, every property required | tool_choice mode validated |
Sources for the table: Anthropic's tool call and strict tool use pages, and OpenAI's conversation state guide, alongside the pages above. Anthropic tool names must match the pattern ^[a-zA-Z0-9_-]{1,128}$.
What the vendors' own guidance says about writing tools:
- Descriptions matter most. Anthropic calls detailed descriptions the biggest factor in tool performance and suggests at least three or four sentences per tool, including what it does not return.
- Fewer tools, clearer names. OpenAI suggests fewer than 20 functions at the start of a turn, as a soft guideline, and notes that definitions are billed as input tokens. Anthropic recommends folding related actions into one tool and prefixing names by service, such as
github_list_prs.
- Do not make the model guess what you know. If your code already holds the order ID, OpenAI says to keep it out of the schema and pass it yourself, and to use enums so invalid calls cannot be expressed.
Step 3: Write the agent loop
This is the complete loop against Anthropic's Messages API. It uses one read-only tool so you can run it safely.
import json
import os
import anthropic
MODEL = os.environ["AGENT_MODEL"] # set to a current model ID; never hard-code it
MAX_STEPS = 10
SYSTEM = "You are an order-support agent. Use tools for facts. If a tool fails, say so."
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
TOOLS = [{
"name": "get_order_status",
"description": (
"Look up the shipping status of one order by its order ID. Use this when the user "
"asks where an order is or when it will arrive. Returns status and estimated "
"delivery date only. It cannot change, refund or cancel orders."
),
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string", "description": "Order ID, e.g. ORD-1042"}},
"required": ["order_id"],
},
}]
def get_order_status(order_id: str) -> dict:
return {"order_id": order_id, "status": "shipped", "eta": "2026-11-12"} # call your API here
REGISTRY = {"get_order_status": get_order_status}
def run_tool(name: str, args: dict) -> tuple[str, bool]:
fn = REGISTRY.get(name)
if fn is None:
return f"Unknown tool '{name}'. Available: {list(REGISTRY)}", True
try:
return json.dumps(fn(**args)), False
except Exception as exc: # return the error so the model can recover
return f"{type(exc).__name__}: {exc}", True
def run_agent(task: str) -> str:
messages = [{"role": "user", "content": task}]
for _ in range(MAX_STEPS):
resp = client.messages.create(
model=MODEL, max_tokens=1024, system=SYSTEM, tools=TOOLS, messages=messages,
)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
return "".join(b.text for b in resp.content if b.type == "text")
results = []
for block in resp.content:
if block.type == "tool_use":
output, failed = run_tool(block.name, block.input)
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": output, "is_error": failed})
messages.append({"role": "user", "content": results})
return "Stopped: step limit reached without a final answer."
print(run_agent("Where is order ORD-1042?"))
Three Anthropic formatting rules matter in this loop:
- Append the assistant turn before the results. Tool results must come immediately after the message that requested them, with no message in between.
- Answer every tool call in one user message. If Claude makes two parallel calls, send both result blocks together, placed before any text in that message.
- Add no text after the tool results. Text after the results is allowed when only your own tools were called, but Anthropic lists it as a common cause of empty replies that end with
end_turn, so leave it out.
Step 4: Execute tools safely
The executor is ordinary backend code and deserves the same care as any API handler.
Use an allow-list registry. The model can reach only the functions in REGISTRY; an unknown name returns an error string, not an exception.
Return errors to the model. Set is_error to true with an instructive message. Anthropic says Claude retries two or three times with corrections when a call is invalid, and suggests saying what went wrong and what to try next rather than a bare failure. MCP uses the same idea with an isError flag on tool results.
Validate and time out. Strict mode enforces the schema, but business rules (does this customer own this order?) belong in your function. Wrap slow tools in a timeout so one hung HTTP call cannot stall the loop.
Gate side effects. Read tools can run freely. Tools that refund, email, delete or deploy should first pass a check you control, such as a UI confirmation or policy rule, and return a needs-approval error when it fails.
Treat tool output as untrusted. Web pages, email and third-party APIs can carry instructions aimed at your model. Anthropic advises keeping such content inside tool result blocks rather than the system prompt or plain user text. Prompt injection is LLM01, the first entry, in OWASP's 2025 Top 10 for LLM applications.
Step 5: Decide when the loop stops
A model that stops calling tools is only one exit. Anthropic recommends stopping conditions such as a maximum number of iterations, and its stop-reasons guide documents the Anthropic signals below.
| Stop condition | How to detect it | What to do |
|---|---|---|
| Final answer | Anthropic stop reason end_turn; no function_call items (OpenAI) or steps (Gemini) | Return the text |
| Step cap | Loop counter reaches MAX_STEPS | Return a partial result and log the run |
| Token or cost budget | Sum input and output tokens from each response's usage field | Stop before the next call |
| Repeated identical call | Same tool and arguments three times in one run | Stop, or tell the model the approach is failing |
| Truncation | Anthropic max_tokens (possibly with an incomplete tool_use block) or model_context_window_exceeded | Retry with a higher max_tokens, or trim history |
| Refusal | Anthropic refusal, returned as a normal HTTP 200 response | Read stop_details; show a safe message or retry on a fallback model |
| Server-tool pause | Anthropic pause_turn (server-side loop limit, default 10 iterations) | Send the assistant content back unchanged |
| Human handoff | Model calls an escalate_to_human tool you define | Park the run and notify a person |
In the OpenAI and Gemini loops below, a final answer is detected only by the absence of call items, so cap and budget checks matter more there.
Step 6: Add memory and state
An agent has three kinds of memory, and only the first exists in the sample above.
Short-term memory is the transcript. The messages list is resent on every call, which is why cost grows with each step. Trim or summarise old tool results in long runs. Anthropic offers compaction and context editing (both in beta); OpenAI and Gemini can keep history server-side through previous_response_id and previous_interaction_id.
Session state lives in your database. To resume a conversation, store the message list in Postgres or Redis keyed by session ID. Anthropic's Python SDK returns Pydantic models, so convert each content block with its to_dict() helper before saving. Keep task facts (order ID, approvals granted) in your own columns rather than trusting the model to remember them.
Long-term memory is retrieval. Facts that outlive a session go into a store the agent queries through tools such as search_memory and save_memory. Anthropic's memory tool follows this pattern, with the storage implemented in your own application.
Step 7: Port the loop to OpenAI and Gemini
TOOLS, the registry and run_tool stay the same; only the request and result shapes change. Use a separate AGENT_MODEL value per provider.
OpenAI Responses API:
from openai import OpenAI
oa = OpenAI() # reads OPENAI_API_KEY
OA_TOOLS = [{"type": "function", "name": t["name"], "description": t["description"],
"parameters": {**t["input_schema"], "additionalProperties": False},
"strict": True} for t in TOOLS] # strict mode: list every property in "required"
def run_agent_openai(task: str) -> str:
items = [{"role": "user", "content": task}]
for _ in range(MAX_STEPS):
resp = oa.responses.create(model=MODEL, instructions=SYSTEM, tools=OA_TOOLS, input=items)
items += resp.output # keeps reasoning items and calls in order
calls = [i for i in resp.output if i.type == "function_call"]
if not calls:
return resp.output_text
for call in calls:
output, _ = run_tool(call.name, json.loads(call.arguments))
items.append({"type": "function_call_output", "call_id": call.call_id, "output": output})
return "Stopped: step limit reached."
OpenAI says a response may hold zero, one or several calls, and that reasoning items returned with tool calls must be passed back; appending the whole resp.output handles both.
Gemini Interactions API:
from google import genai
gc = genai.Client() # reads GEMINI_API_KEY from the environment
GM_TOOLS = [{"type": "function", "name": t["name"], "description": t["description"],
"parameters": t["input_schema"]} for t in TOOLS]
def run_agent_gemini(task: str) -> str:
inter = gc.interactions.create(model=MODEL, input=task, tools=GM_TOOLS)
for _ in range(MAX_STEPS):
calls = [s for s in inter.steps if s.type == "function_call"]
if not calls:
return inter.output_text
results = []
for call in calls:
output, _ = run_tool(call.name, call.arguments) # arguments arrive as a dict
results.append({"type": "function_result", "name": call.name, "call_id": call.id,
"result": [{"type": "text", "text": output}]})
inter = gc.interactions.create(model=MODEL, input=results, tools=GM_TOOLS,
previous_interaction_id=inter.id)
return "Stopped: step limit reached."
Google still fully supports generateContent but recommends the Interactions API for all new development. To run stateless with store=False, resend the user input, every model step (including thought steps) exactly as received, and your function_result.
Common errors and fixes
Anthropic's models that return a 400 error for forced tool use are Claude Opus 5.5, Sonnet 5.5, Fable 5.1 and 5.2, and Mythos 5.1 and 5.2; manual extended thinking blocks it on other models. The other rows come from the pages cited in Steps 3 and 7.
| Symptom | Likely cause | Fix |
|---|---|---|
| Anthropic 400 error about tool_use ids without tool_result blocks | Missing result, or a message between call and result | One tool_result per tool_use, in the next user message, results first |
| 400 error when forcing a tool | tool_choice set to any or tool on a Claude model that rejects forced tool use | Use auto with strict tool use and a prompt instruction |
| OpenAI reasoning-model tool turn fails | Only the function calls were sent back, not the reasoning items | Append the whole response output |
| JSON decode error on OpenAI arguments | Arguments arrive as a JSON string | Enable strict mode; catch the error and return it as a tool error |
| Agent repeats the same call or picks the wrong tool | Vague errors, overlapping descriptions or too many tools | Instructive errors, consolidated tools, loop detection |
From demo to production: the six layers that matter
Evals: score the path and the answer
Run a set of real tasks with known good outcomes in CI on every prompt, tool or model change, and score the tools chosen, the arguments passed and the final answer. Our guide to AI agent evals in CI/CD covers the gates. If you used OpenAI's hosted Evals platform, existing evals became read-only on 31 October 2026 and the dashboard and API are scheduled to shut on 30 November 2026; OpenAI's migration path points to Promptfoo.
Guardrails: least privilege first
The cheapest guardrail is a tool the agent does not have. Then scope credentials per tool, require approval for side effects, screen inputs and outputs, and cap consumption. In OWASP's 2025 list these map to LLM01 Prompt Injection, LLM06 Excessive Agency and LLM10 Unbounded Consumption. OWASP published a 2026 edition in August 2026; a sponsor statement in its 2 September announcement says excessive agency is now third, so check the new numbering before citing it. Our AI agent guardrails comparison prices the off-the-shelf tools.
Cost control: the history is resent every step
Agent cost grows faster than step count because each call resends everything before it. This worked example uses $3 per million input tokens and $15 per million output tokens, the list price of Claude Sonnet 4.6, which has the standard cache multipliers.
- Fixed prefix (system prompt, tool schemas, task): 2,500 tokens. Each step adds about 600 tokens of call and result, and about 150 output tokens.
- A 6-step run sends 6 × 2,500 + 600 × (0+1+2+3+4+5) = 15,000 + 9,000 = 24,000 input tokens and 6 × 150 = 900 output tokens.
- Cost per run: 24,000 × $3/1M + 900 × $15/1M = $0.072 + $0.0135 = $0.0855. At 10,000 runs a month that is $855, or about ₹80,300 at ₹93.93 to the dollar, the cross rate from the European Central Bank's euro reference rates of 10 November 2026.
- With prompt caching on the 2,500-token prefix (5-minute cache writes at 1.25x the input price, reads at 0.1x), billable input becomes 3,125 + 5 × 250 + 9,000 = 13,375 token-equivalents. Cost per run drops to $0.0401 + $0.0135 = $0.0536, about 37% less.
Caching needs a minimum prefix: 1,024 tokens for OpenAI's GPT-5.6 and later, and 512 to 4,096 tokens on Anthropic depending on the model. Anthropic charges 2x for 1-hour cache writes, and its newest models read cache below 0.1x: 0.05x on Opus 5.5 and Sonnet 5.5, 0.025x on Fable and Mythos 5.1 and 5.2. Other levers: a lower max_tokens, the step cap, smaller models and trimmed tool results. Our AI agent unit economics model turns this into cost per resolved task.
Observability: one trace per run
Record each run as a trace with a span per model call and tool call: model ID, tokens, latency, stop reason, tool name, arguments and result, with personal data redacted. Without it you cannot debug a bad answer or explain a bill. OpenTelemetry's GenAI semantic conventions now live in a dedicated repository, so take attribute names from there.
MCP: share tools across agents
Once two or more agents need the same tools, move them behind a Model Context Protocol server. MCP uses JSON-RPC 2.0 messages: clients discover tools with tools/list and invoke them with tools/call. Revision 2026-07-28 made requests stateless: it removed protocol-level sessions and the Mcp-Session-Id header, and servers that need state return explicit handles passed back as ordinary tool arguments. Tool names should be 1 to 128 characters. Servers must validate inputs, enforce access controls and rate-limit calls; clients should confirm sensitive operations, set timeouts and log tool use.
The MCP Python SDK v2 (MIT licence) turns a typed function into a server; the current PyPI release is 2.3.0, published on 2 October 2026.
from mcp.server import MCPServer
mcp = MCPServer("orders")
@mcp.tool()
def get_order_status(order_id: str) -> dict:
"""Look up the shipping status of one order by its order ID."""
return {"order_id": order_id, "status": "shipped"}
Save it as server.py and serve it with uv run mcp run server.py --transport streamable-http. In your loop, list the server's tools, map each inputSchema to your provider's schema field and route calls through the SDK's async Client, which provides call_tool(name, args). Anthropic also offers an MCP connector, in beta, that calls remote servers from the Messages API.
Sandboxing: never run model-written code in your process
Calling exec() on model-written code inside your API server hands the model your credentials and network. Run it in a disposable, locked-down environment instead. A local starting point with Docker, using standard docker run flags:
docker run --rm --network none --memory 512m --cpus 1 --pids-limit 128 \
--read-only --tmpfs /tmp:rw,size=64m --cap-drop ALL \
--security-opt no-new-privileges --user 65534:65534 \
-v "$PWD/job:/job:ro" python:3.12-slim timeout 30 python /job/main.py
That blocks the network, caps memory, CPU, processes and run time, drops capabilities and mounts the code read-only. A shared-kernel container isolates less than a microVM, so for untrusted multi-tenant code use a provider-hosted code execution tool or a managed sandbox.
Build from scratch, use a framework or buy a platform?
Anthropic's SDK tool runner (in beta) runs the loop for you, and Claude Managed Agents and Amazon Bedrock AgentCore both offer hosted agent runtimes.
| Option | Pick it when | Watch out for |
|---|---|---|
| From scratch (this guide) | 1 to 3 agents, few tools, full control of prompts and cost | You own retries, streaming, tracing and state |
| Vendor SDK helpers (such as Anthropic's tool runner) | One provider, and you want the loop handled | Lock-in to that SDK |
| Framework (LangGraph, CrewAI, Mastra, PydanticAI, OpenAI Agents SDK) | Multi-agent graphs, checkpoints, many integrations | Hidden prompts and wrong assumptions about internals |
| Managed platform (Claude Managed Agents, Amazon Bedrock AgentCore) | Hosted runtime, sandbox and identity | Platform pricing and region limits |
For IT teams and engineering leads
- Give each agent its own identity. A service account with scoped, short-lived tokens per tool, never a personal key. Tools fetch credentials server-side, so the model never sees keys.
- Log for audit, redact for privacy. Traces hold customer data, so set retention and restrict access. For personal data of people in India, factor in the Digital Personal Data Protection Act.
- Control the tool surface. Review new tools and MCP servers like code changes; keep an allow-list of approved servers.
- Plan for model retirement. Track provider deprecation pages and re-run evals before switching model IDs.
- Keep a kill switch. A configuration flag that disables side-effect tools without a redeploy.
How eCorpIT can help
eCorpIT is a Gurugram-based technology organisation, founded in 2021, assessed at CMMI Level 5 and MSME certified, with senior-led engineering teams working across AWS, Microsoft and Google platforms. Our AI and machine learning services team builds tool calling agents, MCP servers and the eval, tracing and guardrail layers that take them to production. Talk to us at /contact-us/.
Last updated: 11 November 2026.