Using Function Calling¶
How to wire function calling into an application: define tools, run the loop, run calls safely, and get it ready for production. For per-vendor field names, see Providers Compared.
1. Define a tool¶
A tool is a name, a description, and a JSON Schema for its parameters. The description is the most important part, because the model chooses tools by reading it.
{
"name": "get_weather",
"description": "Get the daily forecast for one city. Use for any weather question. Returns temp_c and condition.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name in English, e.g. 'Tokyo'."},
"date": {"type": "string", "format": "date", "description": "ISO date (YYYY-MM-DD), local to the city."},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius"}
},
"required": ["city", "date"],
"additionalProperties": false
}
}
2. Run the loop¶
The loop is the same everywhere: call the model, run the tool calls it returns, send the results back, and repeat until it answers in text.
import json
from openai import OpenAI
client = OpenAI() # or OpenAI(base_url="http://localhost:8000/v1", api_key="x") for vLLM/Ollama
tools = [{"type": "function", "function": GET_WEATHER}] # GET_WEATHER = the definition above
messages = [{"role": "user", "content": "Weather in Tokyo and Paris tomorrow?"}]
for _ in range(10): # hard cap on iterations
resp = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
msg = resp.choices[0].message
messages.append(msg)
if not msg.tool_calls:
print(msg.content)
break
for call in msg.tool_calls: # may be several (parallel calls)
args = json.loads(call.function.arguments) # arguments arrive as a JSON *string*
result = run_tool(call.function.name, args)
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})
import json
import anthropic
client = anthropic.Anthropic()
tools = [{"name": "get_weather", "description": GET_WEATHER["description"],
"input_schema": GET_WEATHER["parameters"]}]
messages = [{"role": "user", "content": "Weather in Tokyo and Paris tomorrow?"}]
for _ in range(10):
resp = client.messages.create(model="claude-opus-5-5", max_tokens=16000,
tools=tools, messages=messages)
messages.append({"role": "assistant", "content": resp.content}) # keep the full content
if resp.stop_reason != "tool_use":
print("".join(b.text for b in resp.content if b.type == "text"))
break
results = []
for block in resp.content:
if block.type == "tool_use": # input arrives as an *object*
out = run_tool(block.name, block.input)
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": json.dumps(out), "is_error": out.get("status") == "ERROR"})
messages.append({"role": "user", "content": results}) # ALL results in ONE message
The SDK also has a tool runner (client.beta.messages.tool_runner) that runs this loop for you.
from google import genai
from google.genai import types
client = genai.Client()
# Automatic function calling (Python SDK): pass a typed, docstringed Python function
# and the SDK runs the loop for you.
resp = client.models.generate_content(
model=MODEL,
contents="Weather in Tokyo and Paris tomorrow?",
config=types.GenerateContentConfig(tools=[get_weather]),
)
print(resp.text)
For the manual loop, declare the tool with types.FunctionDeclaration, read the functionCall parts, and send functionResponse parts back in a user content. Return the model's parts unchanged, because Gemini 3 needs its thought signatures back. See Providers.
3. Run calls safely¶
Never eval a tool call. Look the name up in an allowlist, validate the arguments, and always return structured output.
from jsonschema import validate, ValidationError
REGISTRY = {"get_weather": (get_weather, GET_WEATHER["parameters"])}
def run_tool(name: str, args: dict) -> dict:
if name not in REGISTRY:
return {"status": "ERROR", "error": "unknown_tool", "hint": f"Use one of {list(REGISTRY)}"}
fn, schema = REGISTRY[name]
try:
validate(args, schema)
except ValidationError as e:
return {"status": "ERROR", "error": "invalid_arguments", "detail": e.message,
"hint": "Fix the argument and call again."}
try:
return {"status": "OK", "data": fn(**args)}
except TimeoutError:
return {"status": "ERROR", "error": "timeout", "hint": "Retry once, then tell the user."}
except Exception as e:
return {"status": "ERROR", "error": type(e).__name__, "detail": str(e)[:500]}
- Parallel calls: run them concurrently (
asyncio.gatheror a thread pool), but send the results back in the call order, each with its id. - Errors are results: return them to the model with a hint, never drop them. See Tool Output.
- Unknown tool or bad JSON: answer with an error result so the model can correct itself.
4. Control the model¶
| Need | How |
|---|---|
| Let the model decide | tool_choice: auto (the default) |
| Force any tool | required / any. Not accepted by some newest models: use auto + an instruction |
| Force one tool | Name it in tool_choice, or use structured outputs |
| Forbid tools on this turn | tool_choice: none, or leave out tools |
| One call at a time | parallel_tool_calls: false (OpenAI) / disable_parallel_tool_use: true (Anthropic) |
| Schema-exact arguments | strict: true |
| Restrict to a subset | Send only those tools, or use an allowed-tools list where supported |
5. Production checklist¶
- Iteration cap and an overall timeout per request.
- Allowlist + schema validation before running anything.
- Confirmation gate (human in the loop) for destructive actions: pay, delete, send.
- Idempotency keys on write tools, so retries can't double-charge.
- Least privilege: the tool runs with the user's permissions, never an admin key.
- Truncate or summarize huge outputs before returning them; paginate.
- Treat tool output as untrusted data (prompt injection). See Security.
- Log every call: name, args, latency, result status. Trace them across turns.
- Cache tool definitions (prompt caching): keep the tool list stable and in the same order.
- Too many tools? Keep about 20-30 in context; above that, use tool search.
- Stream long responses; with streamed tool arguments, validate the complete JSON before running.
- Evaluate before every model or prompt change (Evaluation).
6. Common pitfalls¶
| Symptom | Cause / fix |
|---|---|
| Model invents argument values | Missing "ask if unknown" instruction; add enums and descriptions |
| Calls the wrong one of two similar tools | Descriptions overlap: say when to use each and when not to |
| Calls a tool for small talk | Tool descriptions too broad; add no-call examples to the system prompt |
| Stops making parallel calls | Results were split across several messages; send them together |
400 on the follow-up request |
Missing or mismatched call ids, or results not right after the call |
| JSON parse errors | Use strict mode; parse with a JSON parser, never regex |
| Loops forever | Same call, same result: add an iteration cap and an "if unchanged, stop" rule |
Test your tools before you ship
Paste your tool definitions into the toolcalling.ai workspace, try the edge cases from The Recipe, and compare how different models handle them.