Tool Use & the Agent-Computer Interface
The loop showed that the model calls tools. This shows how to design them so it calls the right one, with the right arguments, and recovers when they fail. Tool design — not model capability — is the root cause of most agent failures. The tools are the agent-computer interface: the UI of the machine, and the place reliability is won or lost.
- Design tools for the agent, not for developers. A tool the model reads is documentation, not an API. Don't expose raw REST endpoints or DB tables.
- Curate, don't enumerate. Narrow job-specific tools — but consolidate granular APIs into stable domain tools, and cap what the model sees at ~15.
- The schema is the contract. Tight types, enums, no naked strings, flat objects,
additionalProperties: false. A loose schema makes the model guess. - Errors are instructions. A structured, actionable error feeds the model's self-correction. A raw stack trace makes it flail.
- Keep tools stateless and writes idempotent. State lives in graph state, not in the tool; every write is safe to call twice.
1 · A tool is a contract, not an API
In the loop article, the model emitted a tool request and your code ran it. The quality of that interaction is set entirely by what the model can see: the tool's name, its description, and its parameter schema. That's the whole interface. The model reasons from those words and nothing else — so a tool is model-facing documentation, written for a non-deterministic reader, not for another engineer.
This is the reframe that matters: you are not exposing a function, you are writing a contract between a deterministic system (your code) and a non-deterministic one (the agent). The most common production failure is treating the two the same — pointing the agent at a REST API built for developers and calling it a tool. The Agent-Computer Interface is the UI of the LLM era: just as humans degrade in front of a messy desktop, models degrade when forced to parse sloppy, un-typed, overloaded endpoints.
2 · How a tool actually works: a brain in a jar
Every best-practice rule below follows from one fact that's easy to miss: the model can't actually do anything. It runs on the provider's servers, in a sealed box with no network, no filesystem, no database — only text in and text out. It's a brain in a jar. So when an agent "looks up a ticket," the model isn't reaching your database. It can't. It has no hands. The only thing it can do is produce text asking for the lookup — and your code, which does have hands, does the real work.
That single constraint explains the entire mechanism. Walk it once, slowly, and the magic disappears.
Before anything runs, you write an ordinary Python function and map its name (a string) to it. Remember this dictionary — it's the whole trick.
def get_ticket_status(ticket_id):
# the REAL network call to your REAL database lives here
return requests.get(f"https://internal.company.com/tickets/{ticket_id}").json()
TOOL_MAP = {"get_ticket_status": get_ticket_status} # the string -> the function
You don't invent a format for this — the model provider's API gives you a specific structure to fill in. Its endpoint accepts a tools field, and each tool is an object with a fixed shape: a name, a description, and an input_schema spelling out the arguments and their types. You translate your Python function into that structure (by hand, or your framework does it for you) and POST it alongside the system instruction and the user's question.
"tools": [ # the provider-defined structure
{
"name": "get_ticket_status", # must match your TOOL_MAP key
"description": "Look up a ticket's status by ID.",
"input_schema": { # the arguments, as a typed contract
"type": "object",
"properties": {"ticket_id": {"type": "string"}},
"required": ["ticket_id"]
}
}
]
The model receives this as text describing what tools exist. It never receives your Python — and couldn't run it even if it did.
The model reads the question and the tool description, pattern-matches, and instead of replying to the user it emits a different kind of text — a block that names the tool and fills in the argument. This is "deciding to use a tool": producing one specific string. It's a dead piece of JSON. Nothing has happened to your database yet.
{ "type": "tool_use", "name": "get_ticket_status", "input": { "ticket_id": "TKT-1024" } }
Your app receives that JSON and does the only thing that matters in this whole article — it uses the name the model said as a key into your dictionary, and calls the function it finds. There is no magic step. The model said the word "get_ticket_status"; your code looks the word up and runs the real thing.
name = response.name # "get_ticket_status" (a string the model produced)
args = response.input # {"ticket_id": "TKT-1024"}
result = TOOL_MAP[name](**args) # <-- THE WHOLE TRICK.
# look the string up, call the real function.
# THIS is the requests.get to your database.
That's it. The "intelligence" that turns the model's text into a real action is one dictionary lookup and one function call — written by you. The model never ran anything. Your code did, because the model named which one to run.
Your function returned {"status": "resolved"} — but the model doesn't know that. It's frozen, and the provider is stateless: it remembers nothing between calls. So you POST a second time and re-send the whole conversation — system, tools, the question, the model's "I want the tool" message, and the result you just computed. Now the model reads the result sitting at the end and writes the real answer: "Yes, ticket TKT-1024 is resolved." Two API calls, because the model has to stop and ask, your code answers, and the model must be re-shown everything to continue.
(Re-sending everything sounds wasteful — and §7 is about exactly that cost. The saving grace: the system prompt and tools array didn't change between the two calls, so the provider serves them from its prompt cache instead of reprocessing them. You re-transmit the text, but you don't re-pay full price for the unchanged part.)
3 · One tool call, in raw JSON
Now that you've seen the mechanism, here is the same round trip as the literal JSON on the wire — the actual payloads that cross between your app and the provider for that one ticket question. The story above is what these four payloads are doing.
POST https://api.anthropic.com/v1/messages
{
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"system": "You are a support agent. Use tools to answer questions "
"about tickets. Never guess a ticket's status.", // system_prompt
"tools": [ // your tool, as TEXT
{
"name": "get_ticket_status",
"description": "Fetch the status of an EXISTING ticket by ID. "
"Do NOT use to create or search tickets.",
"input_schema": {
"type": "object",
"properties": {
"ticket_id": {"type": "string", "pattern": "^TKT-[0-9]{6}$"}
},
"required": ["ticket_id"],
"additionalProperties": false
}
}
],
"messages": [ // the conversation
{"role": "user", "content": "Is ticket TKT-1024 resolved?"} // user_message
]
}
stop_reason: tool_use — that's your signal to run the tool, not print text.
// The provider's response body:
{
"id": "msg_01X...",
"role": "assistant",
"stop_reason": "tool_use", // <-- "I want to call a tool", not "I'm done"
"content": [
{
"type": "tool_use",
"id": "toolu_01A...", // remember this id — you echo it back in step 4
"name": "get_ticket_status", // WHICH function to call
"input": {"ticket_id": "TKT-1024"} // the ARGUMENTS it filled in from the schema
}
]
}
// The model has no ability to run anything. It just filled out the
// form your schema defined. Nothing has touched your systems yet.
import anthropic, requests
client = anthropic.Anthropic()
# Your real implementation — the model never sees or runs this.
def get_ticket_status(ticket_id: str) -> dict:
r = requests.get(f"https://api.internal.company.com/tickets/{ticket_id}")
return r.json()
TOOL_MAP = {"get_ticket_status": get_ticket_status}
# 'resp' is the step-2 response object.
for block in resp.content:
if block.type == "tool_use":
fn = TOOL_MAP[block.name] # match "get_ticket_status" -> real function
out = fn(**block.input) # ACTUALLY calls your backend API now
# out == {"status": "resolved", "owner": "Priya", "updated_at": "..."}
# package the result, tagged with the SAME id the model gave us
tool_result = {
"type": "tool_result",
"tool_use_id": block.id, # ties the answer to the request
"content": str(out),
}
POST https://api.anthropic.com/v1/messages
{
"model": "claude-sonnet-4-5",
"system": "...", // re-sent
"tools": [ ... ], // re-sent
"messages": [
{"role": "user", "content": "Is ticket TKT-1024 resolved?"},
{"role": "assistant", "content": [ {"type":"tool_use", "id":"toolu_01A...",
"name":"get_ticket_status",
"input":{"ticket_id":"TKT-1024"}} ]},
{"role": "user", "content": [
{"type": "tool_result",
"tool_use_id": "toolu_01A...", // matches the model's request
"content": "{'status': 'resolved', 'owner': 'Priya'}"} // the OBSERVATION
]}
]
}
// NOW the model replies with text:
// "Yes — ticket TKT-1024 is resolved (owner: Priya)." stop_reason: "end_turn"
Two things this makes obvious. First, the tools array is the only instruction the model gets about your systems — a vague description or a loose type isn't a style problem, it's the single source of truth the model fills the form from. Second, the provider is a stateless amnesiac: your system prompt and entire tools array are re-sent on every turn (steps 1 and 4 are nearly identical), which is exactly why a fat tool list is expensive and why §7 exists. Everything in the rest of this primer follows from these two facts.
(Shown as the raw mechanism so nothing is hidden. In production you rarely hand-write the TOOL_MAP or the dispatch line — a framework like LangGraph reads your Python functions by introspection to generate the tools JSON automatically, and its tool node does the name-lookup-and-call for you. But that's all it's doing: the TOOL_MAP[name](**args) you just saw is the mechanism the framework builds for you. The convenience hides the dictionary; it doesn't change it.)
4 · Curate the tool layer: narrow, but consolidated
Two forces pull in opposite directions, and getting the balance right is the highest-impact decision in tool design.
A tool called get_invoice_by_id(invoice_id) is far safer than a generic query_database(sql) that hands raw SQL to an LLM. Narrow tools constrain the model to valid actions; broad tools hand it a loaded gun. The classic failure is "unfiltered API exposure" — APIs that return hundreds of fields, opaque IDs, and cryptic error codes the agent has to navigate.
If you have 50 microservices, do not expose 50 tools. Binding dozens of tools causes two failures at once: attention dilution (the model mixes up similarly-named tools like get_user_account vs fetch_user_profile) and parameter confusion (it pulls an argument meant for one tool into another's schema). The answer is an ACI abstraction layer that consolidates granular developer APIs into a few stable, multi-purpose domain tools.
create_jira_ticket() update_jira_assignee() add_jira_comment() close_jira_ticket()
Four near-identical names the router confuses. Multiply across services and the context drowns.
manage_jira_issue(
action: "create"|"assign"
|"comment"|"close",
issue_id, value
)
One stable tool, an action enum, flat params. Note: keep the params flat — don't hide them in a nested payload object (see §5).
Force 1 pushes you to split tools narrower; Force 2 pushes you to fold them back together. They meet at a rule of thumb the field has converged on: cap what any single agent sees at roughly 15 tools. Past that, you've hit the ceiling of one model's attention — the cue to split tools across specialized sub-agents by domain, the routing problem §7 is devoted to.
5 · Schema hygiene: design for model intention
Models build their tool calls by reading your JSON schema. Ambiguous schema, non-deterministic output. Four rules do most of the work — the code shows a weak tool, a hardened one, and boundary validation.
# WEAK — the model has to guess almost everything.
{
"name": "get_data", # vague: get what data?
"description": "Gets data from the system.", # no when-to-use, no when-NOT
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string"}, # naked string: invites hallucination
"filters": { # deeply nested: more syntax errors
"type": "object",
"properties": {"meta": {"type": "object"}}
}
}
# no 'required', no 'additionalProperties', no enums
}
}
# Result at scale: wrong-tool selection, hallucinated args, bloated context.
# HARDENED — every field removes a guess. Rules 1-4 applied.
{
"name": "get_ticket_status", # distinct, task-specific name
"description": (
"Fetch the current status of a support ticket by ID. "
"Returns status, owner, last-update time. "
"Use when the user asks about an EXISTING ticket. "
"Do NOT use to create tickets or to search by keyword." # negative constraint
),
"input_schema": {
"type": "object",
"properties": { # FLAT — no nesting (Rule 3)
"ticket_id": {
"type": "string",
"pattern": "^TKT-[0-9]{6}$", # constrain the format
"description": "Ticket ID, e.g. TKT-001234. "
"CRITICAL: ask the user if missing; do not invent one."
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "critical"], # Rule 1: no naked strings
"description": "Filter by priority. Cuts out variants like 'P1'/'urgent'."
},
"include_history": {"type": "boolean", "default": false} # sensible default
},
"required": ["ticket_id"], # explicit required vs optional
"additionalProperties": false # reject anything unexpected
}
}
# Validate inputs AND outputs at the boundary — non-negotiable in production.
from pydantic import BaseModel, Field
from typing import Literal
class GetTicketInput(BaseModel):
ticket_id: str = Field(pattern=r"^TKT-[0-9]{6}$")
priority: Literal["low","medium","high","critical"] | None = None
include_history: bool = False
class TicketResult(BaseModel):
status: Literal["open", "pending", "resolved", "closed"]
owner: str
updated_at: str
def get_ticket_status(raw_input: dict) -> dict:
args = GetTicketInput(**raw_input) # 1. validate the model's args
record = db.fetch_ticket(args.ticket_id) # (raises on hallucinated input)
return TicketResult(**record).model_dump() # 2. shape OUTPUT — only clean fields escape
# Catches hallucinated parameters before they reach your database, and stops
# 40-field raw API responses from bloating the model's context.
The four rules in plain form:
- Rule 1 — no naked strings: any field with a finite value set becomes an enum, which cuts out variants like "P1" or "urgent."
- Rule 2 — descriptions are inline prompts: use explicit negatives ("do NOT calculate relative offsets like 'last week'; ask for calendar dates").
- Rule 3 — flat objects: forcing the model to build
args.metadata.user.settings.thememultiplies syntax errors — keep params flat and rehydrate app-side. - Rule 4 — bound it: required-vs-optional explicit,
additionalProperties: false, and ~5–7 parameters max.
6 · Errors are instructions for self-correction
A tool error is not an application failure — it is Observation state the model needs for its next Think phase. Never crash the loop, and never return a generic "Internal Server Error." Catch the exception at the tool boundary, sanitize it, and format an instructive payload that tells the model how to fix its approach. Then the reflection loop from the Agent Loop article handles it seamlessly: the model reads the syntax failure, realizes it hallucinated a column name, and re-emits a corrected call on the next tick.
# Return errors the MODEL can act on — not stack traces.
import json
def execute_sql_query(query: str) -> str:
try:
return json.dumps({"status": "success", "data": db.execute(query)})
except SqlParsingException as e:
# Catch, extract metadata, feed back as an instructive Observation.
return json.dumps({
"status": "TOOL_ERROR",
"error_type": "SYNTAX_MALFORMATION",
"system_message": f"DB rejected the SQL. Line {e.line}: {e.message}.",
"remediation_hint": "Check for invalid column names against the "
"schema, then rewrite and retry the query."
})
# Consistent keys (status / error_type / system_message / remediation_hint)
# mean the model learns ONE error shape and self-corrects across every tool.
# Every tool that MUTATES state must be safe to call twice.
# Agents retry, networks fail, the loop may re-issue a call whose
# confirmation never arrived. Without this, retries = duplicate charges.
def transfer_funds(raw_input: dict) -> dict:
args = TransferInput(**raw_input) # includes an idempotency_key
existing = ledger.find_by_key(args.idempotency_key)
if existing: # already done? return original result
return {"status": "success", "data": existing, "note": "already_processed"}
result = ledger.execute_transfer(
amount=args.amount, to=args.to_account,
idempotency_key=args.idempotency_key, # the write itself is keyed
)
return {"status": "success", "data": result}
# Rule: any create / send / pay / delete tool takes an idempotency_key,
# written so a second identical call has no second side effect.
# Keep tools as pure, stateless functions. State lives in graph state,
# injected explicitly on every call — never stored inside the tool.
# BAD: stateful tool — concurrent drift, tracking bugs, race conditions
class UserSessionTool:
def __init__(self):
self.active_user_id = None # <-- do NOT do this
# GOOD: stateless tool — deterministic and safe across parallel turns
@tool
def update_user_record(user_id: str, updates: dict, current_state: dict) -> dict:
# Everything needed to run is injected at the boundary; nothing is held.
return service.apply(user_id, updates)
# Statelessness at the tool boundary makes the execution layer immune to
# concurrency bugs and state drift, so it scales predictably as the horizon grows.
7 · The >20-tools problem: route, don't dump
Tools aren't free — each definition sits in context on every turn. Connect to dozens of MCP servers exposing hundreds of tools and those definitions alone can consume most of the context window before the model reads the user's message. If your agent genuinely needs that many tools, you must route instead of binding them all. Four production patterns, in rough order of reach:
Start with a minimal always-on set; surface more only when the task needs them (a skill file declares the servers it needs, the host connects on demand). The model only ever sees what's relevant now.
Route every call through one stable call_tool(name, args) so the tools array never changes mid-conversation — which also preserves prompt caching (re-sorting the array invalidates the cache).
A lightweight, fast router classifies the user's intent, picks a domain, and hands off to a sub-agent initialized with only that domain's ~10 tools. Attention dilution is contained by design.
Before the loop ticks, embed the user query and cosine-match it against a vector store of tool descriptions. Bind only the top 5–10 tools for that turn. Best when the tool count is in the hundreds.
The hierarchical-router pattern is also a topology decision — splitting tools across sub-agents by function (a billing sub-agent, a scheduling sub-agent) isolates context by job and recovers the large reasoning-quality loss that comes from carrying irrelevant tools. Here's that pattern concretely: a cheap, fast router classifies intent and hands off to a sub-agent that carries only its own small tool cluster. That thread continues in the multi-agent primer.
from typing import Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
query: str
domain: Literal["billing", "scheduling", "support"] | None
# A lightweight, FAST model does one classification turn. No execution
# tools are bound here — the router only decides where to send the work.
def router(state: State):
domain = classify(state["query"]) # cheap model -> "billing" | "scheduling" | ...
return {"domain": domain}
g = StateGraph(State)
g.add_node("router", router)
g.add_node("billing", billing_subagent) # carries ONLY its ~4 billing tools
g.add_node("scheduling", scheduling_subagent) # carries ONLY its ~5 calendar tools
g.add_node("support", support_subagent) # carries ONLY its ~5 support tools
g.add_edge(START, "router")
g.add_conditional_edges("router", lambda s: s["domain"],
{"billing": "billing", "scheduling": "scheduling", "support": "support"})
app = g.compile()
# Each sub-agent only ever transmits its own small, stable tools array,
# so its cached prefix matches turn-to-turn and the model never sees the
# hundred tools it doesn't need this turn.
8 · Evaluate your tools — the discipline most teams skip
You cannot tell which tools the model finds "ergonomic" by reading them — you measure. Stand up a prototype, build a golden set of realistic tasks (strong ones need many tool calls, sometimes dozens), and run the model against it. There are three things worth measuring, and each maps to a specific failure you've already met in this primer:
| Measure | A low score signals | The fix |
|---|---|---|
Did it pick the right tool? Selection accuracy on the golden set |
Attention dilution (§4) — similarly-named tools the router confuses. | Rename for distinctness, or consolidate into one domain tool. |
Did it fill the arguments right? Hallucinated, missing, or malformed fields |
A loose schema (§5) — the model is left to guess. | Tighten types, add enums, flatten the nesting. |
Did it respect the boundaries? Fires tools the description forbade here |
Weak negative constraints (§5) that aren't landing. | Sharpen the "do NOT use when…" lines. |
The discipline that separates working agents from demos: when a score drops, fix the tool, not the system prompt. Narrow the schema, flatten the parameters, rename for distinctness, or split one overloaded tool into two. Tool contracts and prompts are one design surface — co-design them, measure them together, and put the eval in CI so a schema change can't silently regress selection accuracy.
9 · The tool-design checklist
Everything above, in one place — the contract to hold every tool to.
| Dimension | The rule |
|---|---|
| Name | Distinct, task-specific snake_case |
| Description | Says when to use and when not to |
| Types | Enums for any finite value set — no naked strings |
| Shape | Flat params — no deep nesting |
| Bounds | additionalProperties: false, ~5–7 params |
| Surface | Consolidate granular APIs into stable domain tools |
| Output | Minimal, flattened return shape |
| Errors | Structured, with a remediation hint |
| Writes | Idempotency key on every write |
| State | Stateless tools; state in graph, injected |
| Validation | Validate input and output (Pydantic) |
| Scale | Route, don't dump, past ~15–20 tools |
Conclusion: the interface is the product
The loop article showed that the model calls tools; this one showed that how you design those tools decides whether the agent works. The tools are the agent-computer interface — the UI of the machine — and most of what looks like a model mistake is really an interface mistake: a vague name it confused, a loose schema it guessed at, a stack trace it couldn't recover from, a hundred tools it couldn't see past.
So write tools for a non-deterministic reader. Keep them narrow and job-specific but consolidated; give them tight, flat schemas and descriptions that draw their own boundaries; return errors that teach recovery. Keep them stateless, make every write idempotent, validate both ends, and route rather than dump when the count climbs. That is the same discipline the loop article ended on — reasoning fluid, control flow rigid, state deterministic — and your tools are the concrete anchors of that deterministic state. Get the interface right and the model finally has something it can use reliably.
Agent Memory Architectures