What is this for? To explain tool calling (also called function calling)—how an LLM chooses external actions and your app runs them.
Why does it exist? The model does not magically gain network access. Your host (application code) grants it. That is exactly why this pattern is powerful and dangerous.
Without tools, the model invents a weather report.
With tools, it emits something like get_weather(city="Delhi"); your code calls the API; the model turns the JSON into a user-facing answer.
Structure beats brittle "please output JSON" begging. The app—not the model—owns credentials, allowlists, and audits.
| Plain-English idea | What it means |
|---|---|
| Tool schema | Name, description, and argument types for each tool |
| Tool description | Text the model reads to decide when and how to call a tool |
| Observation | The result returned by a tool after an action |
| Host / runtime | Your code that validates and executes tool calls |
The agent loop inside each tool call:
observe -> think -> act (tool) -> observe (result) -> think again
Example:
fetch_server_logs(server_id='db-12', minutes_ago=10)
-> 'Database connection pool exhausted'
A good tool description tells the model:
get_order(order_id) not run_sql(query).{ok: false, error: "..."} so the model can recover.OpenAI-style sketch with local validation (provider SDKs differ; the host duties stay the same).
import json
from dataclasses import dataclass
TOOLS = {
"get_weather": {
"description": "Current weather for a city. Use when user asks about weather.",
"properties": {"city": {"type": "str"}},
"required": ["city"],
},
"fetch_server_logs": {
"description": "Fetch recent logs for a server. Use for outage debugging.",
"properties": {"server_id": {"type": "str"}, "minutes_ago": {"type": "int"}},
"required": ["server_id", "minutes_ago"],
},
}
@dataclass
class ToolCall:
name: str
arguments: dict
def validate(call: ToolCall) -> str | None:
spec = TOOLS.get(call.name)
if not spec:
return "unknown tool"
for req in spec["required"]:
if req not in call.arguments:
return f"missing {req}"
return None
def execute(call: ToolCall) -> dict:
if call.name == "get_weather":
return {"city": call.arguments["city"], "temp_c": 38, "cond": "clear"}
if call.name == "fetch_server_logs":
return {"log": "Database connection pool exhausted"}
return {"error": "not implemented"}
def handle_model_tool_payload(payload: str) -> str:
data = json.loads(payload)
call = ToolCall(data["name"], data["arguments"])
if err := validate(call):
return json.dumps({"ok": False, "error": err})
result = execute(call)
return json.dumps({"ok": True, "result": result})
print(handle_model_tool_payload(
'{"name":"get_weather","arguments":{"city":"Delhi"}}'
))
print(handle_model_tool_payload(
'{"name":"drop_database","arguments":{}}'
))
command: string invites injection into shells.Document each tool like an external API: auth, rate limits, error codes, and example successful payloads. Put those examples in the tool description—models imitate what they see.
Add a canary tool in staging that only echoes args; run adversarial prompts that try to call write tools and assert the host blocked them.
Prefer returning ok/error envelopes over throwing opaque exceptions into the prompt. Models recover better from structured failure than from truncated stack traces.
Some model APIs propose several tools in one turn. Execute read-only tools in parallel if safe; serialize writes. After results return, re-validate the model's next proposal—a later write may depend on a forged interpretation of an earlier read.
Treat tool schemas like public APIs. Additive optional fields are fine; renaming or removing fields needs a version suffix (get_order_v2) and a deprecation window.
Tool calling is a structured handshake: the model proposes typed actions, your host validates and executes them, and results return as data—never as unchecked power.