Tool Use & Function Calling Basics
10 examples to get you started with tool definitions, a single call-and-respond cycle, and a tiny host loop that validates args and returns observations.
Read How Function Calling Actually Works Under the Hood for the full protocol. These snippets stay provider-agnostic: any chat API with native tools maps onto the same shapes.
Prerequisites
You need Python 3.10+ and comfort with JSON objects and function signatures.
python -m venv .venv
source .venv/bin/activate
# later: pip install openai pydantic jsonschemaConcept focus: Early examples use plain dicts and a fake model so you can see the protocol without a live network call. Wire a real OpenAI-compatible client when you are ready.
Basic Examples
1. A Tool Is a Schema Plus a Real Function
The model only sees the schema. Your process owns the implementation.
def get_weather(city: str, unit: str = "celsius") -> dict:
# stand-in for a real API
return {"city": city, "temp": 22, "unit": unit, "conditions": "clear"}
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city. Use for live conditions, not historical climate.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. Lisbon",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit",
},
},
"required": ["city"],
"additionalProperties": False,
},
},
}
]nameis the allowlist key your host dispatches ondescriptionsteers when the model picks the toolparameterssteers how arguments are filled
Related: JSON Schema for Tool Parameters
2. Register Tools in a Dispatch Map
Never eval model output. Map names to callables you control.
REGISTRY = {
"get_weather": get_weather,
}
def dispatch(name: str, args: dict) -> dict:
if name not in REGISTRY:
return {"ok": False, "error": f"unknown_tool:{name}"}
try:
return {"ok": True, "data": REGISTRY[name](**args)}
except TypeError as exc:
return {"ok": False, "error": "bad_args", "message": str(exc)}- Unknown names become soft errors, not process crashes
- Catch argument mismatches and return them as observations
- Expand this map as you add tools; keep one registry per agent role
3. Simulate a Model Decision: Call or Finish
Real APIs return an assistant message with tool_calls or text content.
A fake decision keeps the loop visible.
def fake_model(messages: list[dict], tools: list[dict]) -> dict:
user = next(m["content"] for m in reversed(messages) if m["role"] == "user")
has_tool_result = any(m.get("role") == "tool" for m in messages)
if "weather" in user.lower() and not has_tool_result:
return {
"type": "tool_call",
"id": "call_1",
"name": "get_weather",
"args": {"city": "Lisbon", "unit": "celsius"},
}
if has_tool_result:
last_tool = next(m for m in reversed(messages) if m["role"] == "tool")
return {"type": "final", "content": f"Based on the tool: {last_tool['content']}"}
return {"type": "final", "content": "I can check the weather if you name a city."}- Branch on
tool_callvsfinalin your host - Real models may emit several tool calls; start with one
- Keep the fake model only for learning and unit tests
4. One Call-and-Respond Cycle
Append the assistant proposal, run the tool, append the tool result.
import json
messages = [
{"role": "system", "content": "Use tools for live weather. Be concise."},
{"role": "user", "content": "What's the weather in Lisbon?"},
]
decision = fake_model(messages, TOOLS)
assert decision["type"] == "tool_call"
messages.append(
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": decision["id"],
"type": "function",
"function": {
"name": decision["name"],
"arguments": json.dumps(decision["args"]),
},
}
],
}
)
result = dispatch(decision["name"], decision["args"])
messages.append(
{
"role": "tool",
"tool_call_id": decision["id"],
"name": decision["name"],
"content": json.dumps(result),
}
)
final = fake_model(messages, TOOLS)
print(final["content"])- The
tool_call_idbinds result to proposal - Prefer JSON strings in
contentfor structured data - This single cycle is the atom of every agent framework
5. The Smallest Useful Loop
Wrap the cycle in max_turns so runs always end.
def run_agent(user_text: str, max_turns: int = 4) -> str:
messages = [
{"role": "system", "content": "Use get_weather for live conditions."},
{"role": "user", "content": user_text},
]
for _ in range(max_turns):
decision = fake_model(messages, TOOLS)
if decision["type"] == "final":
return decision["content"]
messages.append(
{
"role": "assistant",
"tool_calls": [
{
"id": decision["id"],
"type": "function",
"function": {
"name": decision["name"],
"arguments": json.dumps(decision["args"]),
},
}
],
}
)
result = dispatch(decision["name"], decision["args"])
messages.append(
{
"role": "tool",
"tool_call_id": decision["id"],
"content": json.dumps(result),
}
)
return "Stopped: max turns reached."
print(run_agent("What's the weather in Lisbon?"))- Hard caps belong in host code, not in hopeful prompts
- Return a clear stop reason to UI and metrics
- See Agent Loop Basics for the full perceive-reason-act-observe framing
Intermediate Examples
6. Validate Arguments Before Dispatch
Schema-shaped args can still be business-invalid.
ALLOWED_UNITS = {"celsius", "fahrenheit"}
def safe_get_weather(args: dict) -> dict:
city = args.get("city")
if not isinstance(city, str) or not city.strip():
return {"ok": False, "error": "city is required non-empty string"}
unit = args.get("unit", "celsius")
if unit not in ALLOWED_UNITS:
return {"ok": False, "error": f"unit must be one of {sorted(ALLOWED_UNITS)}"}
data = get_weather(city.strip(), unit)
return {"ok": True, "data": data}- Enum checks catch model improvisation
- Strip and normalize strings at the boundary
- Keep validation errors short and actionable for the next model turn
7. Wire a Real OpenAI-Compatible Client
When you leave the fake model, the branch logic is the same.
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("OPENAI_API_KEY"),
# base_url="https://openrouter.ai/api/v1", # optional gateway
)
def reason(messages: list[dict]) -> object:
return client.chat.completions.create(
model="gpt-4o-mini", # verify model id and tool support at build
messages=messages,
tools=TOOLS,
tool_choice="auto",
)
# resp.choices[0].message.tool_calls → dispatch each → append role=tool → reason again- Confirm the model supports tools before production
- Gateway base URLs (OpenRouter and others) keep the same tool fields for OpenAI-compatible routes
- Log
model, tokens, and tool names per turn
8. Force a Tool When Ground Truth Is Required
Use tool_choice when answering from memory would be wrong.
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Weather now in Lisbon?"}],
tools=TOOLS,
tool_choice={"type": "function", "function": {"name": "get_weather"}},
)- Forced calls improve reliability for lookup steps
- Do not force write/delete tools on ambiguous user intent
- After the tool result lands, call again with
tool_choice="auto"or"none"to finalize
9. Shape Errors the Model Can Use
def dispatch_with_errors(name: str, args: dict) -> dict:
if name not in REGISTRY:
return {
"ok": False,
"error_type": "unknown_tool",
"retryable": False,
"message": f"No tool named {name}. Available: {sorted(REGISTRY)}",
}
try:
return {"ok": True, "data": REGISTRY[name](**args)}
except TimeoutError:
return {
"ok": False,
"error_type": "timeout",
"retryable": True,
"message": "Upstream weather API timed out; retry once or ask the user to wait.",
}retryablehelps the model (and your host budget) decide next steps- Never return empty strings for failures
- Full recipe: Handling Tool Errors Gracefully Inside an Agent Loop
10. Score a Run: Did Tool Use Help?
def evaluate_run(final_text: str, tool_names: list[str], result_ok: bool) -> dict:
return {
"used_weather_tool": "get_weather" in tool_names,
"tool_succeeded": result_ok,
"mentions_city": "lisbon" in final_text.lower(),
"answered": bool(final_text.strip()),
}- Assert trajectory properties, not only prose quality
- Golden tests should stub tools and check call args
- Expand into suite-level evals as the tool surface grows
Related
- How Function Calling Actually Works Under the Hood - protocol deep dive
- Writing Tool Descriptions the Model Will Actually Use Correctly - selection wording
- JSON Schema for Tool Parameters: A Reference Guide - parameter reference
- Handling Tool Errors Gracefully Inside an Agent Loop - failure observations
- Tool Use & Function Calling Best Practices - ten habits
- Agent Loop Basics - loop mechanics
- AI Agents Fundamentals Basics - first agent walkthrough
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.