Originally published on Homingo Learn.
Tool calling is how a model asks your code to do something (OpenAI calls it function calling; Anthropic calls it tool use). The model only proposes; your code checks the request, runs it and reports back.
How tool calling works
You send the model a list of tools, each with a name, a description and a JSON Schema for its arguments. When the model decides a tool would help, it replies with a tool call (a tool_use block in the Anthropic API) instead of, or alongside, text. Your code runs the tool and sends the result back in the next message (a tool_result block), and the model continues. With tools you define (client tools), the model never runs anything itself, which is why the checks belong in your code.
Define tools and validate their arguments
Keep each tool’s schema next to its function in a decorator, so the two are edited together. This minimal version makes every argument required (an optional third value lists the allowed values as an enum), and the runner turns any failure into a readable error for the model:
import json
TOOLS = {}
def tool(description, /, **params):
"""Register a function as a tool. params: name -> (JSON type, description[, allowed values]); all required."""
def prop(json_type, desc, enum=None):
return {"type": json_type, "description": desc, **({"enum": enum} if enum else {})}
def wrap(fn):
TOOLS[fn.__name__] = {
"fn": fn,
"spec": {
"name": fn.__name__,
"description": description,
"input_schema": {
"type": "object",
"properties": {k: prop(*v) for k, v in params.items()},
"required": list(params),
"additionalProperties": False, # the runner rejects unknown keys, so the schema says so too
},
},
}
return fn
return wrap
@tool("Current weather for a city. Use for any question about weather now.",
city=("string", "City name, e.g. 'Lisbon'"))
def get_weather(city: str) -> dict:
return {"city": city, "temp_c": 19, "sky": "clear"} # stand-in for a real API call
@tool("Convert an amount between EUR and USD (demo rates).",
amount=("number", "Amount to convert"), source=("string", "Currency code", ["EUR", "USD"]),
target=("string", "Currency code", ["EUR", "USD"]))
def convert(amount: float, source: str, target: str) -> dict:
rates = {("EUR", "USD"): 1.08, ("USD", "EUR"): 0.93}
if (source, target) not in rates:
raise ValueError(f"no rate for {source}->{target}; supported: EUR<->USD")
return {"amount": round(amount * rates[(source, target)], 2), "currency": target}
TYPES = {"string": str, "number": (int, float), "integer": int, "boolean": bool, "object": dict, "array": list}
def type_ok(value, json_type: str) -> bool:
if isinstance(value, bool) and json_type != "boolean": # True is an int in Python, not in JSON Schema
return False
if json_type == "integer" and isinstance(value, float): # JSON Schema accepts 50.0 as an integer
return value.is_integer()
return isinstance(value, TYPES[json_type])
def run_tool(call: dict) -> dict:
"""Validate and run one tool call; argument and tool failures come back as error results."""
entry = TOOLS.get(call["name"])
if entry is None:
return error(call, f"unknown tool {call['name']!r}; available: {', '.join(TOOLS)}")
schema, args = entry["spec"]["input_schema"], call.get("input")
if not isinstance(args, dict):
return error(call, "arguments must be a JSON object")
missing = [k for k in schema["required"] if k not in args]
props = schema["properties"]
wrong = [k for k, v in args.items() if k in props
and (not type_ok(v, props[k]["type"]) or v not in props[k].get("enum", [v]))]
unknown = [k for k in args if k not in schema["properties"]]
if missing or wrong or unknown:
return error(call, f"bad arguments: missing={missing} invalid={wrong} unknown={unknown}")
try:
content = json.dumps(entry["fn"](**args), default=str) # dates, decimals etc. become strings
except Exception as e: # the model gets a readable error and can try again
return error(call, f"{type(e).__name__}: {e}")
return {"type": "tool_result", "tool_use_id": call["id"], "content": content}
def error(call, message):
return {"type": "tool_result", "tool_use_id": call["id"], "content": message, "is_error": True}
print([t["spec"]["name"] for t in TOOLS.values()])
print(run_tool({"id": "t1", "name": "convert", "input": {"amount": 50, "source": "EUR", "target": "USD"}}))
print(run_tool({"id": "t2", "name": "convert", "input": {"amount": "50", "source": "EUR"}}))
print(run_tool({"id": "t3", "name": "convert", "input": {"amount": 5, "source": "EUR", "target": "EUR"}}))
# ['get_weather', 'convert']
# {'type': 'tool_result', 'tool_use_id': 't1', 'content': '{"amount": 54.0, "currency": "USD"}'}
# ... 'content': "bad arguments: missing=['target'] invalid=['amount'] unknown=[]", 'is_error': True}
# ... 'content': 'ValueError: no rate for EUR->EUR; supported: EUR<->USD', 'is_error': True}
Two details do most of the work. Errors go back as a tool_result with is_error: true instead of crashing the loop, and they say what to do differently (“supported: EUR<->USD”), so the model can correct itself on the next step. And the description is a routing rule: say when to use the tool, not just what it does. The check covers top-level names and types; for nested schemas use jsonschema.validate(args, schema) (catch jsonschema.ValidationError and return its message as an is_error result) or your provider’s strict schema mode.
The agent loop and parallel tool calls with Claude (Anthropic API)
from concurrent.futures import ThreadPoolExecutor
def chat_with_tools(call_model, user_text, max_steps=6):
"""The tool loop: call the model, run every tool it asks for, send the results back, repeat."""
messages = [{"role": "user", "content": user_text}]
specs = [t["spec"] for t in TOOLS.values()]
for _ in range(max_steps):
reply = call_model(messages, specs)
if reply.get("stop_reason") == "max_tokens": # cut off: a tool call or the answer may be incomplete
return "Stopped: reply cut off (max_tokens)."
messages.append({"role": "assistant", "content": reply["content"]})
calls = [b for b in reply["content"] if b["type"] == "tool_use"]
if not calls: # no tool requested: this is the answer
return "".join(b["text"] for b in reply["content"] if b["type"] == "text")
with ThreadPoolExecutor() as pool: # read-only calls run at the same time; map keeps their order
results = list(pool.map(run_tool, calls))
# Anthropic API: all results for one assistant turn go back together, in one user message.
messages.append({"role": "user", "content": results})
return "Stopped: too many tool steps."
# Stand-in model so the example runs offline. With the Anthropic SDK this is:
# client.messages.create(model=..., max_tokens=1024, tools=specs, messages=messages).model_dump(exclude_none=True)
def fake_model(messages, specs):
last = messages[-1]["content"]
if isinstance(last, str): # first turn: ask for two tools at once
return {"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "a1", "name": "get_weather", "input": {"city": "Lisbon"}},
{"type": "tool_use", "id": "a2", "name": "convert", "input": {"amount": 50, "source": "EUR", "target": "USD"}},
]}
results = {r["tool_use_id"]: json.loads(r["content"]) for r in last} # the demo assumes both succeed
w, c = results["a1"], results["a2"]
return {"stop_reason": "end_turn", "content": [{"type": "text", "text": f"{w['city']}: {w['temp_c']}°C and {w['sky']}. 50 EUR is {c['amount']} USD."}]}
print(chat_with_tools(fake_model, "Weather in Lisbon, and what is 50 EUR in dollars?"))
# Lisbon: 19°C and clear. 50 EUR is 54.0 USD.
The model asked for both tools in one turn. The loop runs them at the same time and returns all the results together in a single user message, each matched to its call by tool_use_id. Run read-only tools in parallel like this; run tools that change state one at a time, in order. The thread pool helps with I/O-bound tools such as API calls. The loop also checks stop_reason: a reply cut off at max_tokens can hold an incomplete tool call, so it is never run or returned as the answer. At the step cap this snippet returns a stop message; to get an answer instead, make one last call with tool_choice={"type": "none"} so the model must reply from what it has. The max_steps cap is the simplest guard against a model that keeps calling tools; see how to stop an AI agent infinite loop for repeat detection and finish checks.
OpenAI function calling in Python (Chat Completions and Responses)
- Chat Completions: tools are
{"type": "function", "function": {"name": ..., "description": ..., "parameters": {...}}};parametersis the same JSON Schema asinput_schemaabove. - Calls arrive in
message.tool_calls, with the arguments as a JSON string to parse (json.loads, and treat a parse failure as a tool error). - Append the assistant message that carries
tool_callsfirst, then each result as its own message withrole: "tool"and the matchingtool_call_id. Skipping the assistant message is the most common Chat Completions error. - Responses API: tools are flat (
{"type": "function", "name": ..., "parameters": {...}}), calls arrive asfunction_callitems and results go back asfunction_call_outputitems with the samecall_id.
The Chat Completions loop (openai SDK), reusing the tools and run_tool from above.
def chat_with_tools_openai(client, model, user_text, max_steps=6):
"""The same loop on OpenAI Chat Completions, reusing TOOLS and run_tool from above."""
tools = [{"type": "function", "function": {"name": s["name"], "description": s["description"],
"parameters": s["input_schema"]}}
for s in (t["spec"] for t in TOOLS.values())]
messages = [{"role": "user", "content": user_text}]
for _ in range(max_steps):
choice = client.chat.completions.create(model=model, messages=messages, tools=tools).choices[0]
if choice.finish_reason == "length":
return "Stopped: reply cut off (max_tokens)."
msg = choice.message
messages.append(msg) # the assistant message carrying tool_calls goes in BEFORE the tool messages
if not msg.tool_calls:
return msg.content
for tc in msg.tool_calls: # one role "tool" message per call, matched by tool_call_id
try:
args = json.loads(tc.function.arguments) # arguments arrive as a JSON string
except json.JSONDecodeError:
args = None # run_tool answers "arguments must be a JSON object"
result = run_tool({"id": tc.id, "name": tc.function.name, "input": args})
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result["content"]})
return "Stopped: too many tool steps."
Forcing or disabling tool calls. Both APIs let you steer the choice. Anthropic: tool_choice is {"type": "auto"} (the default), {"type": "any"} (must use some tool), {"type": "tool", "name": "convert"} (must use that one) or {"type": "none"}; add "disable_parallel_tool_use": true to get at most one call per turn. OpenAI: tool_choice is "auto", "required", "none" or a named function, and parallel_tool_calls=False turns parallel calls off.
Writing tools the model uses well
- Few, distinct tools. Two tools that overlap (“search” and “lookup”) make the model guess. Merge them or make the descriptions say when each applies.
- Small, typed arguments. An enum or a number is easier to get right than a paragraph of open-ended text the tool must parse.
- Compact results. Return the fields the model needs, not the whole API response; every result is read back into the context.
- Read-only by default. Tools that change things (send, delete, pay) should ask a person first or run only in a sandbox.
- Retries live in the tool layer. A rate limit or a timeout should be retried by your code before the model sees an error. Retry only idempotent calls, or pass an idempotency key, so a send or a payment never runs twice; see retries and rate limits in Python.
Common mistakes
- Trusting the arguments. The model can send a wrong type, a missing field or a made-up tool name. Validate before running.
- Raising inside the loop. One exception ends the whole run. Catch it in the runner and return it as a tool error.
- Losing a result. Every call needs its result: with the Anthropic API all results for one turn go back together in the next user message; with OpenAI, one tool message per call. A missing one is an API error.
- No step cap. A loop without one can run until the budget is gone.
Tools are one layer of an agent, alongside the loop, memory and checks. See AI agent memory in Python and the tool layer of a multi-agent research system.
Multi-agent systems: build an AI research team: Six modules, each with a short video lesson, a quiz check and a hands-on coding round that adds a tested piece of the team to your own repo. Together they cover the planner contract, handoffs, the tool layer, shared memory, the source checker, budgets, traces and an eval gate. The capstone is your team running live on a topic of your choice, with its reports, eval scores and code in a public GitHub repo. For builders who know Python and have called a model API. Certificate on completion. See the course
Disclosure: this guide is by Homingo, the team behind the course linked above. Everything in it works without it.
Top comments (0)