A language model on its own can only talk. This chapter is about giving it the ability to act — to look something up, edit a file, issue a refund — and about shaping that ability so the model actually uses it correctly.
By the end you should be able to:
- Write a tool definition from scratch.
- Recognize when a choice between two overlapping tools is effectively a coin flip, and rewrite the descriptions so it stops being one.
- Explain why an MCP server you did not write sits inside your security boundary.
- Diagnose the failure where “the agent has the right tool but never uses it.”
Two terms to pin down first
A tool is a function you expose to the model. You describe it, the model asks for it by name with arguments, your code runs it, and you hand the answer back. The model never runs anything itself.
The harness is the ordinary program you write around the model: the loop that sends a request, reads the reply, executes any tool the model asked for, appends the answer, and sends the next request. Harness engineering builds one from scratch.
Bad tool design is the most common cause of “the model is dumb” complaints that are actually bugs in the harness.
MCP, in plain words, before any detail
MCP (Model Context Protocol) is a published convention for how a tool provider describes and exposes its tools.
Here is the problem it solves. Without such a convention, every agent that wants to talk to Slack writes its own Slack adapter, and every agent that wants GitHub writes its own GitHub adapter. The integration work multiplies as agents × providers.
MCP fixes the shape of that connection. A provider publishes one small program that lists its tools in a standard format, and any agent implementing the client half of the protocol can pick those tools up without writing provider-specific code.
MCP is a wire format — an agreed way of encoding messages over a connection — and not a service you deploy. Mcp model context protocol derives that claim carefully, including what it does not buy you.
What goes in, and what comes back
Before any mechanism, fix the shape of the whole interaction. Three pieces of JSON carry it end to end. (JSON — JavaScript Object Notation — is the plain-text data format the API speaks: nested objects of keys and values.) Everything else in this chapter is a consequence of these three pieces.
1. The tool definition — what you send
A tool definition (often called a tool schema) is a JSON object with three fields:
name— the identifier the model uses to ask for the tool.description— English prose written for the model to read.input_schema— a description of the arguments, written in JSON Schema, a standard notation for saying “this field is a string, that one is required.”
You put a list of these in the tools array of every request. Here is one complete definition — note that the description is a sentence for a reader, while input_schema is a machine-checkable structure:
{
"name": "search_docs",
"description": "Search internal product documentation. Returns the top 5 passages, each with a source id.",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to search for, in plain words"}
},
"required": ["query"]
}
}
2. The tool call — what comes back
When the model decides to use that tool, the response contains a tool_use block: the tool’s name, the arguments it chose, and an id used to match your eventual answer back to this request.
The response’s stop_reason field — the API’s one-word explanation of why the model stopped generating — is set to "tool_use". That is your harness’s signal that the model is waiting on you.
Read the id in the block below; you will send it back verbatim in step 3:
{
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9",
"name": "search_docs",
"input": {"query": "refund window"}
}
3. The tool result — what you send next
You run the search, then append a tool_result block carrying the same tool_use_id back to the conversation, and call the API again. The model now sees the answer and writes its reply.
Notice two things in the block below: tool_use_id matches the id from step 2 exactly, and the whole thing is sent with "role": "user".
{"role": "user", "content": [
{"type": "tool_result",
"tool_use_id": "toolu_01A09q90qw90lq917835lq9",
"content": "[{\"source\": \"policy-14\", \"text\": \"Refunds are accepted within 45 days of delivery for unopened items.\"}]"}
]}
Why a user turn, when a program produced the text?
This is the most counterintuitive fact in the mechanism, and it follows from there being only two roles on the wire.
The conversation alternates user and assistant. assistant means “tokens this model generated.” The tool output was not generated by the model — it came from outside, exactly like a human’s message does.
So user here does not mean “a person typed this.” It means “input from the world, which the model must now read.” Your harness is speaking on the world’s behalf.
That framing is also why Mcp model context protocol’s security argument bites: anything you put in a tool_result arrives with the same standing as text a user typed.
The loop, in one line
Definitions in, one tool_use out, one tool_result back in, repeat.
That is the entire mechanism. The rest of this chapter is about the consequences: where those definitions physically sit in the prompt and what that costs, why the description field decides whether step 2 ever happens, and how the picture changes when there are fifty tools instead of one.
1. What a tool actually is, mechanically
What actually happens between a tool definition going out and a tool result coming back? Less than most people assume — and the little that does happen puts your definitions in a place with consequences for cost and design.
There is no function-calling engine
The most useful thing to know is what isn’t there: no separate service, no callback registry, no special channel. It is text in and text out the whole way down, which is the mechanism Structured output is a guarantee not a request derives.
Five steps:
Step 1 — your tool definitions are serialized into the prompt. They are converted to text and placed ahead of the system prompt (the standing instructions you set once for the whole conversation), in front of everything else. They become ordinary tokens — the sub-word chunks a model reads and is billed by, roughly four characters of English each — and you pay for them on every single turn.
Step 2 — the model emits a structure. It has been trained to produce a particular block shape when it wants to act, rather than answering in prose.
Step 3 — constrained decoding can keep that structure valid, but you have to ask for it. This step has several moving parts, so take them one at a time:
- The runtime is the provider’s own code wrapped around the model. It is not your code and it is not the model. It includes the loop that picks one token at a time, a compiler that turns your schema into a grammar, and a parser that turns the finished text back into blocks.
- A grammar is a machine-checkable rule set that, at any point in the half-written output, says exactly which characters may legally come next.
- At each step the model produces one raw score — a logit — per possible next token. The runtime can set the logit of every token that would break your schema to negative infinity, which makes those tokens have probability exactly zero. That is logit masking.
- Masking is what
strict: trueturns on, and it is off by default. Strict schemas and what they do not buy is where the line between the two gets drawn precisely.
Step 4 — the runtime parses the block out of the generated text and sets stop_reason: "tool_use".
Step 5 — you execute the tool and append a tool_result.
The path a tool call takes through your code
The diagram below traces one tool call from the model, through your harness, and back. The thing to look at is the two red edges and the single red node they both land on.
flowchart LR
D["Tool definitions<br/>serialized into the prompt"] --> M((Model))
M -->|tool_use block| H[Harness]
H --> V{Validate + authorize}
V -->|ok| E[Execute]
V -->|deny| R1["tool_result<br/>is_error: true"]
E -->|ok| R2[tool_result]
E -->|throw| R1
R1 --> M
R2 --> M
style M fill:#1d3557,color:#fff
style V fill:#bc6c25,color:#fff
style R1 fill:#9d0208,color:#fff
linkStyle 4 stroke:#9d0208,stroke-width:2px
linkStyle 6 stroke:#9d0208,stroke-width:2px
Everything downstream of the tool_use edge is your code. The model asking to act is the only part the model does. V{Validate + authorize} is yours: before E[Execute] runs, the harness checks the arguments against your schema and checks the caller against your policy.
A clean run takes the ok edge and returns the plain tool_result node.
The two red edges are the part people miss. If policy says no, the deny edge fires. If execution raises an exception, the throw edge fires. Both converge on the same node: tool_result with is_error: true.
That convergence is the lesson. A denial and an exception are not raised into your loop and do not end the turn. Each comes back as an ordinary tool_result block carrying is_error: true, appended to messages exactly like a success would be.
So the model sees the refusal on its next turn and can react: ask the user for confirmation, pick a different tool, or fix the argument it got wrong. That is why Two different failures with two different fixes’s “make errors actionable” is a design rule and not a nicety — the error string is the next prompt.
Three consequences of step 1
Step 1 said tool definitions render first, as ordinary tokens, on every turn. Three consequences fall out of that, and they explain almost every tool-design rule in the rest of this chapter.
Consequence 1: tools live in the cacheable prefix. They render first, ahead of system. That matters because of prompt caching: the API can skip re-processing a request’s opening stretch of tokens if those bytes are identical to a previous request’s.
The savings are large and published, not estimated. A cached read bills at 0.1× the base input rate, and the write that creates the cache entry costs 1.25×. So 10,000 tokens of tools that would normally cost the equivalent of 10,000 tokens cost the equivalent of 1,000 once cached.
The catch is that the match is a prefix match — one changed byte invalidates everything from that byte onward. Since tools sit at position zero, a tool list that varies per user invalidates the entire cache for everyone. Prompt caching derived derives why from the way attention works.
Consequence 2: tool count costs tokens on every turn. Take 50 tools at roughly 150 tokens of schema each: 50 × 150 = 7,500 tokens of standing overhead, resent on every request, for the life of the conversation.
Treat the 150 as an order-of-magnitude stand-in rather than a measurement. A two-field tool with a one-line description lands well under it; a nested schema with six described properties lands well over. The same 150 is used again in the namespacing arithmetic of Tool selection at scale, so if you are sizing a real tool set, count your own with a token counter and substitute. The shape of both arguments survives any value you plug in.
Consequence 3: the description is prompt text. It is not metadata sitting in a registry somewhere. It is instructions the model reads on every turn, competing for attention with your system prompt and the whole conversation.
2. The description is the highest-leverage text in your system
Pick up that third consequence, because it has teeth. The description field — not the schema, not the executor — determines whether a tool ever fires, and prose alone can resolve the same user turn two different ways.
Who reads which field
The three fields of a tool definition are read by different consumers and do different jobs. The row to notice is the middle one: it is the only field that decides whether the tool fires at all.
| Field | Read by | Job |
|---|---|---|
name | Model | Disambiguate from siblings |
description | Model | Decide when the tool fires |
input_schema | Model + your validator | Constrain arguments |
Modern models under-trigger, and what that means for you
The rule: a description that only says what a tool does will under-trigger. The model reads it, judges that it already knows the answer, and answers from memory. So state the trigger condition, the boundary, and the return shape — be prescriptive about when to call it, not just what it does.
The evidence: modern models call tools conservatively. They reason more and reach for tools less than earlier generations did. Since the rest of the chapter rests on this premise, be straight about how well attested it is.
The direction is well documented. Providers’ model-migration notes tell you to expect a drop in tool-call rate when a working prompt moves onto a newer model, and to add explicit trigger conditions to recover it. Practitioners report the same thing.
The magnitude is not something this chapter can give you. It depends on the pair of models and on your own prompt. Treat the direction as reliable and design against it; check the migration guidance for the specific model you are moving to.
Two descriptions of the same tool
Below are two definitions of the same tool. Same name, same schema object — the asserts at the bottom prove the two dictionaries literally share one schema. The only thing that changes is the English.
SCHEMA = {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
}
# Weak — states what, not when. Under-triggers.
weak = {
"name": "search_docs",
"description": "Searches the knowledge base.",
"input_schema": SCHEMA,
}
# Strong — four parts, annotated.
strong = {
"name": "search_docs",
"description": (
"Search internal product documentation. " # 1 what
"Call this whenever the user asks about product behavior, " # 2 when
"pricing, or policy — do not answer those from memory, "
"since docs change weekly. "
"Do not call it for general programming questions, or for " # 3 when not
"anything already answered earlier in this conversation. "
"Returns the top 5 passages, each with a source id." # 4 returns
),
"input_schema": SCHEMA,
}
# The schema is identical. Everything below is caused by the prose alone.
assert weak["input_schema"] is strong["input_schema"]
assert weak["name"] == strong["name"]
The anatomy of the strong description, mapped onto the comment numbers above:
- What it does, in one clause (
# 1). This is the part every description already has. - When to call it — the positive trigger, phrased as a condition, with the reason attached (
# 2). “Docs change weekly” is doing real work: it lets the model generalize the rule to a question you did not anticipate, instead of pattern-matching the three nouns you listed. - When not to call it — the boundary (
# 3). This clause is what stops sibling tools from overlapping, and it is the one most often missing. - What comes back (
# 4), so the model can plan the next step instead of guessing at the shape of the result.
The same user turn, both ways
Below is one user message run twice against one model with one schema. Only the description differs. Watch the stop_reason line in each run — that is the only observable difference.
user: What's your refund window?
── tools=[weak] ────────────────────────────────────────────────────────
assistant: Refund windows are typically 30 days from delivery, though this
can vary by product category.
stop_reason: "end_turn" ← no tool_use block was ever emitted
── tools=[strong] ──────────────────────────────────────────────────────
assistant: (tool_use) search_docs({"query": "refund window"})
stop_reason: "tool_use"
user: (tool_result) [{"source": "policy-14", "text": "Refunds are
accepted within 45 days of delivery for unopened items…"}]
assistant: 45 days from delivery, for unopened items (policy-14).
stop_reason: "end_turn"
This failure is the one that gets shipped.
The weak run did not error. It did not warn. It did not look wrong. It produced a fluent, confident, plausible answer from the model’s own memory of its training data — and the real policy is 45 days, so it was also wrong.
Nothing in your logs distinguishes it from a correct answer. The only observable difference is a missing tool_use block and a stop_reason of "end_turn" where you expected "tool_use".
That is what “the description is prompt text, not metadata” cashes out to. description is the only field that decides whether the tool fires at all, and a description that omits the trigger condition is an outage you cannot see. The schema, the executor, and the retrieval index were all fine in both runs.
Sibling overlap, written out
The boundary clause earns its keep when two tools could plausibly answer the same question. This is the failure that Two different failures with two different fixes’s table calls selection.
In the code below, compare the two lists. The first pair has no mutual exclusion; the second pair has each description naming the other tool by its exact name.
# Overlapping — both descriptions are true, and neither excludes the other.
# "What's your refund window?" matches both. The model guesses.
overlapping = [
{"name": "search_web", "description": "Searches the web."},
{"name": "search_docs", "description": "Searches documentation."},
]
# Disambiguated — each description names its sibling and rules itself out.
disambiguated = [
{"name": "search_web", "description": (
"Search the public web for third-party services, current events, or "
"anything not specific to this product. Do not use it for questions "
"about our own behavior, pricing, or policy — use search_docs."
)},
{"name": "search_docs", "description": (
"Search this product's internal documentation. Do not use it for "
"general programming questions or third-party services — use "
"search_web."
)},
]
assert all("search_" in t["description"] for t in disambiguated) # each names its sibling
Notice what the fix is not. It is not a longer description of what each tool does — that is what the overlapping pair already has.
It is a negative clause in each description that names the other tool by its exact name. The model reads both descriptions in the same prefix on the same turn, so a boundary stated in one is visible while it is considering the other.
Two tools with no mutual exclusion are a coin flip, and a coin flip shows up in your test suite as flakiness rather than as a bug.
What interviewers probe: “Your agent has the right tool but never uses it.” First check the description’s trigger condition; second check whether a sibling’s description overlaps; third check whether the system prompt implies the model already knows the answer. Prompt-level nudging is the fourth fix, not the first.
3. Designing the tool surface
A well-described tool still has to be the right kind of tool. The question isn’t “what can the agent do” but “what shape should each action have” — one broad shell tool, or many typed ones the harness can see into.
The decision, as three questions
The flowchart below asks three yes/no questions in order. Any single yes routes to a dedicated tool; only three noes route to bash.
flowchart TD
A[An action the agent needs] --> Q1{Needs a permission gate?}
Q1 -->|Yes| DED[Dedicated tool]
Q1 -->|No| Q2{Needs custom UI,<br/>an audit trail, or a<br/>staleness check?}
Q2 -->|Yes| DED
Q2 -->|No| Q3{Must the harness know<br/>it is parallel-safe?}
Q3 -->|Yes| DED
Q3 -->|No| BASH[Give it bash]
style DED fill:#2d6a4f,color:#fff
style BASH fill:#40916c,color:#fff
Question 1: does it need a permission gate? A permission gate is a checkpoint where your code, or a human, approves the action before it runs — because it is destructive, expensive, or governed by policy.
Question 2: does it need custom UI, an audit trail, or a staleness check?
- Custom UI — a rendered widget rather than a line of log text.
- An audit trail — a durable record of who did what.
- A staleness check — a guarantee that the thing being written has not changed since it was read.
Question 3: must the harness know whether the action is parallel-safe? Parallel-safe means two of them can run at the same time without corrupting each other.
Three noes is the common case, because most of what an agent does is read-only inspection your harness has no reason to intercept.
The questions are ordered by the cost of getting them wrong: a missing permission gate is a security incident, a missing audit trail is a compliance finding, a missing parallel-safety hint is only a latency regression.
Why bash blocks all five capabilities
A call like bash("curl -X POST ...") hands your harness an opaque string — a blob of shell text with no structure your code can inspect.
Every action has that same shape, so the harness cannot tell a grep from a git push. It therefore cannot:
- Gate it — you’d have to parse arbitrary shell to know if it’s destructive.
- Render it — there are no typed arguments to build a user interface from.
- Audit it — your log says “ran a command.”
- Parallelize it — a read-only
grepand agit pushlook identical, so everything serializes. - Enforce invariants — a dedicated
edittool can reject a write if the file changed since the model read it.sed -icannot.
That last one is the cleanest example. A staleness check is impossible through bash and trivial through a typed tool, because the typed tool receives the content the model thought it was editing and can compare it against what is actually on disk. Case study 03 builds exactly that.
Rule of thumb: start with bash for breadth; promote an action to its own tool the moment you need to gate, render, audit, parallelize, or check an invariant.
Practical rules
- 5–15 tools is comfortable. Past roughly 20, selection accuracy degrades from schema volume alone — Tool selection at scale covers what to do about it. Both figures are practitioner rules of thumb, not a published threshold: nothing changes at exactly 20, and the real number depends on how distinct your descriptions are. Treat 20 as the point at which you should start measuring selection accuracy rather than the point at which it breaks.
- One tool, one job. A combined
manage_user(action="create"|"delete")moves the decision out of the schema, where a gate can see it and a validator can check it, and into a string, where neither can. - Use
enumfor closed sets. Anenumis a JSON Schema keyword listing the only values a field may take. It is the cheapest accuracy win available: it narrows the field to a list the model reads on every turn, and once you pair it withstrict: true(Strict schemas and what they do not buy) it stops being persuasion at all — the logit masking of What a tool actually is mechanically makes any other value unemittable. - Return structure, not prose. Tool results are context you pay for on every subsequent turn.
{"rows": 3, "total_cents": 4120}beats a paragraph saying the same thing. - Truncate loudly. Say
"...truncated; 4,812 rows total, call again with a filter". Never silently cut — the model cannot ask for what it does not know is missing. - Make errors actionable.
"Error: city 'Pariss' not found. Did you mean 'Paris'?"gets fixed in one turn, because that string is the next prompt.
4. Strict schemas — and what they do not buy
The strict flag is misjudged in both directions: people assume they have its guarantees when they don’t, and assume it checks things it never will. The line to hold onto runs between the shape of an argument, which the model can guarantee, and its meaning, which only your code can check.
The default, because it is the thing people get wrong
What a tool actually is mechanically said the schema is serialized into the prompt as text. By default that is all it is: instructions the model has been trained to follow, and usually does. Nothing masks a token on your schema’s behalf.
You will reliably get back a parseable JSON object in input — the failure mode is not corrupt bytes — but the API promises nothing about whether that object matches what you declared. Concretely, all three of these are possible:
- A field you typed
integerarrives as the string"2". - A field you listed in
requiredis missing. - A field you never declared shows up.
Every row of Two different failures with two different fixes’s table is a real, observed failure precisely because this is the default.
What strict: true adds
Setting strict: true converts the schema from a request into a guarantee. The runtime compiles it into a grammar and applies the logit masking of What a tool actually is mechanically at every decode step, so an argument that violates the schema has probability zero and cannot be emitted.
strict gives you three things the default does not have:
- Declared types are enforced —
2, never"2". - Every field in
requiredis present. - No undeclared field appears.
The tool name is guaranteed to be one you actually declared, too.
It requires two things in the schema in exchange:
additionalProperties: false, which forbids fields you did not declare.- A complete
requiredlist naming every field that must be present.
The definition below has both, plus an enum on reason. Notice that strict sits at the top level of the tool object, next to name — not inside input_schema.
TOOLS = [{
"name": "refund_order",
"description": (
"Issue a refund for an order. Call only after confirming the order ID "
"with the customer and checking it is within the 30-day window."
),
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Format: ORD-12345"},
"amount_cents": {"type": "integer", "description": "Must be > 0"},
"reason": {
"type": "string",
"enum": ["damaged", "not_received", "wrong_item", "other"],
},
},
"required": ["order_id", "amount_cents", "reason"],
"additionalProperties": False,
},
}]
Strict fixes shape, never semantics
Consider these arguments against the schema above:
{"order_id": "ORD-99999", "amount_cents": 500000, "reason": "other"}
Every field is the right type, present, and drawn from the right set. The call passes strict cleanly. It would also refund $5,000 for an order that does not exist.
Business validation — does this order exist, does this amount match it, is this customer entitled to it — stays in your harness, always. This distinction is worth stating explicitly in an interview, because it is the boundary between what the model guarantees and what you must.
5. Two different failures with two different fixes
Strict or not, tool calls still go wrong, and the first diagnostic question is which way: a selection failure means the model picked the wrong tool; an extraction failure means right tool, wrong arguments. Each has its own fixes, and they do not transfer.
Interviewers love this distinction because conflating the two is the tell that someone has read about tools rather than debugged them.
The symptom table
Each row below starts from something you would actually see in a log, names the underlying failure, and gives the fix.
| Symptom | Diagnosis | Fix |
|---|---|---|
Calls search_web when it should call search_docs | Selection — overlapping descriptions | Sharpen triggers; state each tool’s boundary (“do not use for X”) |
Right tool, date="next Tuesday" | Extraction — under-specified schema | "format": "date", example in the property description, strict: true |
| Invents a tool name | Hallucinated capability | Return is_error listing real names; check whether the system prompt promises something the tool set lacks |
null for a required field | Ambiguity about optionality — and no strict, so required is advice (Strict schemas and what they do not buy) | Make it genuinely optional with a default, or say in the description what to do when unknown (usually: ask). strict: true stops the omission; it cannot invent the value |
The extraction fix ladder
For extraction failures specifically, try these in order. Each rung is cheaper and more reliable than the one below it:
- A
descriptionon the property, not just on the tool, containing a literal example of the value you want. - An
enumor aformat(a named string pattern such asdate). strict: true.- A tool-use example inside the definition showing a correctly-shaped call.
- Prompt engineering — last.
Why that order, and be careful about which rungs are which.
Only rungs 2 and 3 are structural — they are the pair that reaches the constrained decoding of What a tool actually is mechanically, which sets illegal tokens to probability zero. Rung 2 declares the constraint and rung 3 arms it: an enum with no strict is a strong hint, an enum under strict: true is unemittable-if-wrong.
Rungs 1 and 4 are prose. A description and a worked example are English the model reads; there is no grammar to compile from them.
So why do rungs 1 and 4 still outrank plain prompt engineering? Because of where they sit, not what they are.
Prose inside the schema is part of the tool block, which What a tool actually is mechanically showed renders at position zero, ahead of system. It is in the prefix that gets re-read on every single turn, immediately adjacent to the field it constrains.
Prose in the prompt is a single instruction somewhere in a conversation that keeps growing around it. Persuasion degrades as context fills: retrieval accuracy is measurably worst for material sitting in the middle of a long window (Why quality degrades in long contexts shows the curve).
So a schema constraint at turn 40 is exactly as strong as at turn 1; a property description is nearly so, because it is re-read at turn 40 too; a prompt instruction is neither.
Then make the error message teach
The function below rejects a badly formatted date. Read the error string, not the regex — it carries a fact the model does not otherwise have.
import re
def parse_date(value: str) -> str:
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
return value
raise ValueError(
f"date must be YYYY-MM-DD, got {value!r}. "
"Today is 2026-07-30 — resolve relative dates before calling."
)
Providing today’s date in the error is what closes the loop. Without it, the model cannot resolve “next Tuesday” no matter how many times you restate the format, because the missing piece is a fact it does not have rather than a rule it did not follow.
6. Tool selection at scale
Everything so far assumed a tool set small enough for the model to weigh every description against the others. At 50+ tools two things degrade at once:
- Cost. Every schema sits in context on every turn.
- Quality. Selection accuracy drops as the model works through more near-identical descriptions.
The three ways out
There are three, and they are not equals.
flowchart TD
P[50+ tools] --> A["Router<br/>classify, then load a tool subset"]
P --> B["Tool search<br/>model queries the catalog"]
P --> C["Namespacing<br/>fewer, broader tools"]
style B fill:#2d6a4f,color:#fff
Tool search — the green branch — is the only one of the three that leaves the cached prefix intact. Its schemas are appended to the tool list, never swapped into position zero, so the bytes already cached stay byte-identical.
The router and namespacing are perfectly good options; they just win on a different axis.
The three branches are not interchangeable — each trades a different resource. In the table below, read the last column first, because cache behavior is what actually decides this at scale.
| Branch | Who narrows the set | Cost you pay | Effect on the cached prefix |
|---|---|---|---|
| Router | A cheap classifier model, before the main call | You maintain the routing table by hand | Clean — the subset is fixed before the request is built |
| Tool search | The model itself, mid-turn | One extra round trip | Preserved — schemas are appended, never swapped |
| Namespacing | You, at design time | Arguments move out of the schema | Clean — but the surface is smaller than the real action space |
Router — what does the classifying
A small, fast model (Haiku-tier; the general pattern is Routing) reads the user turn and emits a label such as billing or technical. Your code looks that label up in a table mapping labels to tool subsets.
The upside is determinism: the same request always loads the same subset, so nothing about the prefix is a surprise. It is also cheap.
The cost in the table above is one line and does not look like much, but it is the one that bites in production: you own that mapping, and it rots silently. Add a tool, forget the table entry, and the tool simply never loads. No error, no warning — and the symptom is the one at the top of Two different failures with two different fixes’s table (“the agent has the right tool but never uses it”) with a cause that is not in the description at all.
Namespacing — and the contradiction with §3 you should notice
Namespacing means collapsing six GitHub tools into one github tool with an action enum:
github(action="create_issue"|"add_comment"|"merge_pr", …)
That is exactly the shape Designing the tool surface’s rule 2 forbids. Both rules are right, and the tension is worth being able to state out loud, because it is the difference between reciting rules and understanding them:
rule 2 optimizes for: per-action checkability — a gate can see "merge_pr"
in the schema, not buried in a string
namespacing optimizes: prefix size — 6 schemas at ~150 tokens becomes 1
The trade flips when schema volume becomes the dominant failure. At 15 tools you are nowhere near the accuracy cliff, so you keep the checkability and pay the tokens. At 200 tools the tokens are the problem, and you buy prefix back by giving up the thing rule 2 was protecting.
If you namespace, you have to reintroduce by hand the gate you just lost: dispatch on action inside the handler before doing anything destructive, because your generic permission layer can no longer see what is about to happen.
Broader tools also hide arguments from the schema in a second way. An enum on action can still be logit-masked, but the other properties now have to be optional, since only some actions use them. That drops exactly the required constraint Strict schemas and what they do not buy relies on.
Why tool search preserves the cache
Tool search is a built-in tool whose job is finding other tools. The model queries a catalog mid-turn, and the schemas it finds are appended to the tool list rather than swapping it out.
That word “appended” is the whole argument. If loading a tool replaced the tool block, it would change the very front of the prefix and invalidate everything behind it (Prompt caching derived). Appending leaves the existing prefix byte-identical, so the cache survives.
That design choice is precisely why tool search beats a naive “swap in the relevant tools” approach, and saying so demonstrates you understand the caching mechanism rather than the feature list.
A working tool list has three kinds of entry, and two naming details worth catching before you copy the pattern.
tools = [
# Never deferred — this is the tool that does the searching.
{"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"},
# Always loaded: the two or three you know every request needs.
{"name": "get_time", "description": "...", "input_schema": {...}},
# Deferred: name + description stay searchable, schema stays out of context.
{"name": "get_weather", "description": "...", "input_schema": {...},
"defer_loading": True},
# ...47 more, all deferred
]
Detail 1: the dated type string. tool_search_tool_regex_20251119 names a dated variant of the search tool rather than a version of it. The date labels a frozen behaviour contract, so a later date does not mean “newer, prefer this one.” Sandboxed code execution works through a case where two dated variants of the same tool are live at once and the earlier date is the one you want.
Detail 2: defer_loading: True keeps a tool’s schema out of context until it is needed. Two constraints govern it, and they interact:
- The search tool itself can never carry
defer_loading. - At least one ordinary tool must remain non-deferred.
Keeping one everyday tool loaded — get_time above — satisfies the second unambiguously, which is why the example is written that way rather than deferring literally everything.
One round trip, concretely
A round trip here means one extra request-and-response with the model before the real work starts.
The regex variant of tool search means the model emits a search pattern, not a sentence. In the transcript below, notice that the result arrives with no turn of yours in between:
assistant: (server_tool_use) tool_search_tool_regex({"pattern": "weather|forecast"})
result: 2 matches — get_weather, get_hourly_forecast
→ their full input_schema blocks are APPENDED to the tool list
assistant: (tool_use) get_weather({"city": "Paris"})
server_tool_use is a different block type from the tool_use of the opening section, and the difference is who runs it.
A tool_use block is a request to you: your loop stops, executes, and sends a tool_result back.
A server_tool_use block is the provider telling you it already ran something on its own infrastructure. The search happened inside the same API call, and no tool_result is owed. That is why no turn of yours appears between the search and its result. Only the last line — the ordinary tool_use for get_weather — is work your harness has to do.
The two recovered schemas land after everything already in the prefix, so the bytes ahead of them are untouched and still cache-readable. You pay one extra model call and roughly the cost of the two schemas you actually needed, instead of 50 schemas on every turn of the entire session.
7. MCP — Model Context Protocol
Where What a tool actually is mechanically described the tool definitions you write by hand, MCP is about tool definitions somebody else wrote and published. It standardizes how a tool provider exposes tools, so that one Slack integration works with any agent that speaks the protocol — and the same mechanism that makes that possible is what puts a stranger’s text in your prompt.
N×M versus N+M — and what actually shrinks
Below are the same two agents and two providers, without MCP on the left and with it on the right. Count the arrows in each box.
flowchart LR
subgraph Before["Without MCP — N×M"]
A1[Agent A] -->|custom code| T1[Slack]
A1 -->|custom code| T2[GitHub]
A2[Agent B] -->|custom code| T1
A2 -->|custom code| T2
end
subgraph After["With MCP — N+M"]
B1["Agent A<br/>MCP client"] -->|MCP| S1[Slack server]
B1 -->|MCP| S2[GitHub server]
B2["Agent B<br/>MCP client"] -->|MCP| S1
B2 -->|MCP| S2
end
There are still four arrows on the right. MCP is a wire format, not a broker you deploy. There is no hub in the middle, and Agent A still opens its own connection to the Slack server and its own connection to the GitHub server.
What drops from N×M (agents times providers) to N+M (agents plus providers) is the number of integrations somebody has to write:
- Without MCP, every agent writes custom code per provider. With 2 agents and 2 providers that is
2 × 2 = 4adapters. - With MCP, each of the N agents implements the client spec once and each of the M providers implements the server spec once:
2 + 2 = 4.
Four either way in this tiny picture — the saving only shows at scale. With 20 agents and 20 providers it is 20 + 20 = 40 instead of 20 × 20 = 400.
Connections stay one per pair in both worlds. If you take away “MCP is infrastructure I need to stand up,” you have the wrong object in mind.
The moving parts, and one second of it running
Three nouns, and none of them is a hosted service:
- A server is a separate process that advertises tools, resources, and prompts. Usually it is a package you install and run —
npx @acme/slack-mcpor a Python entry point — owned by whoever owns the API it wraps. - A client is the piece of code inside your harness that speaks MCP to exactly one server. One MCP client per server — that is what the boxes on the right-hand side of the diagram are.
- The host is your agent application, holding one client per connected server and merging what they all report.
The lifecycle lands right back in What a tool actually is mechanically’s mechanics.
At connect time the client sends a message called initialize, then calls tools/list. It gets back a name, a description, and an input_schema for every tool the server offers — the same three fields you would have written by hand.
The host splices those entries into the very same tools array from What a tool actually is mechanically: the one that renders first, ahead of system, and is billed on every turn. From the model’s point of view nothing is different; an MCP tool and a hand-written tool are the same JSON in the same block.
When the model emits a tool_use for one of them, the host routes it to the owning client, which issues tools/call with those arguments and turns the response back into a tool_result.
That is also what makes the security stakes mechanical rather than a vibe: “its descriptions are injected into your prompt” is just tools/list output landing in prefix position 0.
Transport — and why it is a trust boundary, not a footnote
A transport is how the client’s bytes reach the server. There are two, and they sit on opposite sides of a trust boundary.
stdio. The client spawns the server as a child process on your machine and talks to it over standard input and standard output, the two ordinary pipes every command-line program already has. No network, no ports, no authentication — because there is nothing to authenticate to. The process runs as you, with your filesystem and your credentials. The “auth” is that you chose to run it.
HTTP/SSE. SSE (Server-Sent Events) is a one-way server-to-client stream over plain HTTP. Here the server is a remote endpoint someone else operates, so you get real authentication, real multi-tenancy (one server safely serving many separate customers), and a real network dependency — plus the ordinary questions about who sees your arguments.
Where a stdio server is configured
A local stdio server is configured as a command and its arguments, in JSON that is easy to mistake for part of a request.
This JSON is not sent to the API and is not part of any request. It is a config file on your own disk, owned by the host application — the agent app you run, which reads it at startup to learn which servers to spawn.
Each host picks its own path and filename. An editor extension, a desktop client, and your own harness will each have their own. So the mcpServers key is the part that is standardized by convention; the location is not.
What reaches the model is downstream of this: the host launches the command, calls tools/list, and splices the answers into the tools array.
{
"mcpServers": {
"slack": {
"command": "npx",
"args": ["-y", "@acme/[email protected]"],
"env": { "SLACK_BOT_TOKEN": "${SLACK_BOT_TOKEN}" }
}
}
}
Read the version pin. The advice “pin server versions” only parses once you can see that @1.4.2 — a local server is a package that updates, and an update ships new tool descriptions straight into your prefix.
The three primitives, with one instance each
MCP servers expose three kinds of thing, and they are distinguished by who controls them. That distinction gets asked about in interviews because it is really a question about who can initiate an action.
| Primitive | Controlled by | Analogy | What one actually looks like |
|---|---|---|---|
| Tools | The model | Functions it may call | send_message(channel, text) — the model decides when |
| Resources | The application | Files/data it may read | An address string (a URI) such as file:///repo/README.md; the server lists it, and your host decides whether to read it in — it does not enter context on its own |
| Prompts | The user | Slash-command templates | /review-pr expanding to a filled-in instruction the user invokes deliberately |
Only the tools row lets the model act unprompted: resources are pull-only and gated by your code, and prompts fire only when a human types them.
That is the whole answer to “how is a resource different from a tool” — not the data-versus-action distinction people reach for first, but who holds the trigger.
What MCP does not solve
Selection accuracy at scale, authorization, and cost all remain yours. Tool selection at scale is unaffected by whether the fifty tools came from a protocol or from your own code.
The security point worth volunteering
A third-party MCP server is an untrusted input source sitting in your prefix.
Its tool descriptions arrive via tools/list and are spliced into your prompt. So a malicious or compromised server can place instruction-like text directly into the highest-attention region of your context.
That is prompt injection: attacker-controlled text reaching the model in a position where it reads as instruction rather than as data (Prompt injection builds the full treatment).
The usual defense against injection is content-wrapping — putting untrusted text inside explicit delimiters and telling the model that anything within them is data, not orders. An MCP tool description bypasses that entirely, because it never travels through your content pipeline. It is a first-class field of the tool block.
Pin server versions, diff descriptions on update, and don’t grant an MCP-sourced tool more authority than the server deserves.
What interviewers probe: “Why MCP instead of just writing functions?” It’s integration economics (N+M rather than N×M) plus a discovery and permission model. If you own both sides, plain functions are simpler and you should say so.
8. Sandboxed code execution
Sometimes the right tool surface is not a longer tool list at all. Giving an agent an interpreter is often better than giving it 30 tools: it can compose, loop, and filter without one round trip to the model per step.
A sandbox is the isolated environment that code runs in: a container with its own filesystem and its own limits, so that nothing the model writes can touch your machine or your data.
What each layer defends against
Four constraints hang off the sandbox, and each one closes a specific escape.
flowchart TD
M((Model)) -->|code| S[Sandbox]
S --> N[No network<br/>or allowlist only]
S --> F[Ephemeral FS]
S --> R[CPU / mem / wall-clock caps]
S --> NR[Non-root, dropped caps]
S -->|stdout / stderr / exit code| M
style S fill:#2d6a4f,color:#fff
style M fill:#1d3557,color:#fff
- No network, allowlist only — the default is no outbound traffic at all. A sandbox with outbound network is an exfiltration channel for anything the code can read, and a download channel for a second stage of an attack. Open specific hosts when you must; never open “the internet.”
- Ephemeral FS — the filesystem (FS) the container sees is its own writable layer, with none of your directories mounted into it, and it is thrown away when the session ends. Nothing the model writes survives, so a poisoned file cannot become the next session’s input.
- CPU / mem / wall-clock caps — limits on processor time, memory, and elapsed real time. The cheapest denial-of-service attack against your own product is
while True: pass, and without a wall-clock cap that loop bills until something else notices. - Non-root, dropped caps — the code runs as an unprivileged user, and Linux capabilities (the fine-grained privileges that together make up root, such as “may mount filesystems” or “may load kernel modules”) are dropped. Even a container-breakout technique then lands on a process that cannot do anything interesting with the escape.
- stdout / stderr / exit code — standard output, standard error, and the process’s numeric exit status are the only things that come back to the model. That narrow return channel is itself a control: the model reads results, not the container’s state.
Two choices worth defending in an interview
Fresh container per session, not per call. Per-call is stricter but throws away the working directory between steps, which breaks the composition that made the interpreter worth having — the model can no longer write data.csv in one step and read it in the next. Per-session keeps that continuity while still guaranteeing that nothing crosses between users, which is the boundary that actually matters.
Never run model-generated code with exec() inside your own process, and don’t reach for RestrictedPython. Interviewers ask this specifically to see which you pick.
Sandboxing by language subset — allowing only a safe-looking slice of Python — is escapable. Introspection chains like ().__class__.__bases__[0].__subclasses__() walk from a harmless empty tuple up to every class the interpreter has loaded, and out of any pure-Python jail. The boundary has to be at the operating-system or hypervisor level, where escaping requires a kernel bug rather than cleverness.
Declaring the tool, and reading the result
The snippet below declares a provider-hosted sandbox and then reads what comes back.
resp = client.messages.create(
model="claude-opus-5",
max_tokens=8192,
tools=[{"type": "code_execution_20260521", "name": "code_execution"}],
messages=[{"role": "user", "content": "Std dev of the values in sales.csv"}],
)
for block in resp.content:
if block.type == "bash_code_execution_tool_result":
print(block.content.stdout, block.content.return_code)
The block type names the thing that ran. bash_code_execution_tool_result decomposes into three parts:
bash— the sandbox runs the model’s code through a shell.code_execution— that is the tool.tool_result— it is the same result-block shape as everywhere else in this chapter.
Its content carries the three fields the diagram called the narrow return channel: stdout, stderr, and return_code.
Like the tool-search result in Tool selection at scale, this is a server result. The container ran inside the API call, so there is nothing for your loop to execute and no tool_result to send back.
Programmatic tool calling — the composition upgrade
Standard tool use is one round trip per action, and every intermediate result lands in context and is then resent on every following turn. Here is what three data-heavy lookups cost:
read profile → 8k tokens into context
lookup orders → 40k tokens into context
check stock → 12k tokens into context
60k tokens carried for the rest of the session
With programmatic tool calling (PTC), the model writes a script that calls your tools as ordinary functions. Here is what it actually emits — five lines, and every one of them matters:
# Emitted by the model, executed inside the sandbox.
profile = read_profile(uid) # 8k of JSON — stays in the sandbox
orders = lookup_orders(uid, limit=500) # 40k of JSON — stays in the sandbox
recent = [o for o in orders if o["ts"] > profile["signup_ts"]]
stock = check_stock([o["sku"] for o in recent]) # 12k — stays in the sandbox
print(f"{len(recent)} orders since signup; {sum(stock.values())} units in stock")
read_profile, lookup_orders, and check_stock are your tools, exposed to the sandbox as callable functions. That exposure is explicit, not implicit: you declare the code-execution tool and mark each custom tool as callable from inside it by listing that tool version in an allowed_callers field.
Two version strings appear in this section, and the older one is not a typo.
A tool type like code_execution_20260521 is a dated variant of the tool, not a release number. The provider ships several at once and you pick the one whose behaviour you want, the same way you pick a model.
code_execution_20260521is the current general-purpose variant. It is what the earlier example declares.code_execution_20260120is the variant that adds a persistent interpreter session and the ability for your tools to be called from inside the container. Programmatic tool calling needs this one.
So the string with the earlier date is the one carrying the newer capability.
Why that is not a contradiction. The date stamps the moment that variant’s behaviour was frozen — the point after which the same inputs are promised to keep producing the same outputs. It does not stamp the day the variant became available to you.
Those are different events, and they can happen in either order. A variant can be pinned in January and published months later, behind a beta, after the general-purpose one pinned in May is already in wide use.
So the date is a name, not a rank. code_execution_20260120 and code_execution_20260521 are two tools that happen to share a prefix. Comparing their dates tells you which contract was settled first and nothing about which is newer or more capable. The only way to know which one has the feature you need is to look it up.
Here it is the 20260120 one, and you must both declare it and name it in allowed_callers. Note that the same string appears twice in the snippet below:
tools = [
{"type": "code_execution_20260120", "name": "code_execution"},
{"name": "read_profile", "description": "...", "input_schema": {...},
"allowed_callers": ["code_execution_20260120"]},
]
The two strings have to match. allowed_callers names the caller by its exact tool type, so pairing a tool marked callable-from-20260120 with a declared 20260521 container exposes nothing.
When read_profile runs, the container pauses, the call is routed back out to your harness by exactly the same path a normal tool_use takes, and your code executes it. The return value is then handed to the running script, not appended to messages.
The list comprehension on line 3 of the script is the whole point: it is the model’s reasoning expressed as code that runs where the data is, instead of as a turn that requires the data to be shipped to the model first. Only the print crosses back.
one round trip, 60k tokens processed inside the sandbox, ~200 tokens returned
Token cost scales with the final answer instead of with the intermediate data.
Hold the number of steps fixed and let the size of the intermediate payloads grow. Standard tool use adds tokens to context linearly in that size; programmatic tool calling adds none. In the usual notation, context cost is O(1) — constant — in payload size, rather than O(D), proportional to the data volume D.
Nor does it grow with the number of tool calls inside the script: three calls and thirty both return one print.
What it does not change is the number of model turns your agent takes overall. PTC does not make an agent free; it makes an agent’s context independent of how fat its tool results are. So it is the right lever whenever an agent chains several data-heavy lookups, and no lever at all when the results were small to begin with.
9. Computer use
Computer use is a tool whose action space is a screen: screenshot, click(x, y), type, key, scroll. The model looks at a picture of a desktop and issues mouse and keyboard actions against it.
The mechanical consequence that dominates the design: every turn ships an image. Images are incompressible in a way text isn’t — you can’t summarize a screenshot and keep it useful, because the next click depends on exact pixels. Cost per step is therefore high and roughly constant.
Vision tiers, and the formula for image tokens
How high depends on which vision tier your model is on, so quote the tier along with the number.
A vision tier is the resolution regime a model family belongs to. It fixes two limits, and both shrink your image before you are charged for it:
- A cap on the long edge in pixels. This is the one everybody quotes.
- A cap on how many tokens one image may cost. This is the one that actually bounds the bill.
Tokens scale with area:
image tokens = ceil(width / 28) × ceil(height / 28)
The API downscales anything that exceeds either cap before tokenizing it.
Where the formula comes from. The model does not see pixels. It sees an image cut into a fixed grid of small square patches, and each patch becomes one visual token in exactly the way a few characters of English become one text token.
The patch is 28 × 28 pixels, so one token covers 28 × 28 = 784 pixels. The ceilings in ⌈width / 28⌉ × ⌈height / 28⌉ are there because a partial patch along the right or bottom edge still costs a whole one.
That is the formula. The tier caps tell you what gets downscaled before it is applied.
Optional: a pocket shortcut, if you need a number without a calculator. Divide total area by 750.
It is the same idea with both refinements thrown away — it drops the ceilings and rounds 784 down to a friendlier divisor — so it errs a few percent high, which is the safe direction for a budget.
On 1080p it gives 1920 × 1080 / 750 = 2,764.8 against the exact 2,691, about 2.7% over.
It is also provider-specific: 750 is a restatement of one vendor’s 28-pixel patch, and a vendor tiling at 16 × 16 would have a completely different divisor. Size a budget with it if you like; count real tokens with a token-counting endpoint before you bill anyone.
The two tiers, with 1080p worked out
The table gives both caps for each tier and what a 1080p frame actually costs there. The second column is the pixel cap; the third is the token cap.
| Tier | Long-edge cap | Max tokens per image | 1080p (1920×1080) |
|---|---|---|---|
| Standard (pre-Opus 4.7, pre-Sonnet 5) | 1,568 px | 1,568 | downscaled to 1456×819 → 1,560 tok |
| Current (Opus 4.7 and later, incl. Opus 5, Sonnet 5) | 2,576 px | 4,784 | not downscaled → 2,691 tok |
Substituting for the current tier, step by step:
ceil(1920 / 28) = ceil(68.57) = 69ceil(1080 / 28) = ceil(38.57) = 3969 × 39 =2,691 tokens
A 1920-px long edge is under the 2,576-px cap, and 2,691 is under the 4,784-token cap, so nothing is thrown away and you are charged for the full frame.
The token cap is the one that actually bounds your bill, and it is the one people omit. A long-edge cap constrains one dimension only, so a square image at the cap and a tall thin one at the cap are both legal and cost wildly different amounts. “2,576 px” alone implies no ceiling on what an image can cost. The per-image token cap does: 4,784 on the current tier, 1,568 on the standard one. Ask for that number by name.
The two caps are not redundant, and the token one bites first. Work it through on the standard tier. Scale a 1080p frame to exactly its 1,568-px long edge and you get 1568×882:
ceil(1568 / 28) = 56ceil(882 / 28) = ceil(31.5) = 3256 × 32 = 1,792tokens
That is over the standard tier’s 1,568-token cap, so that size is not reachable there at all. It lands at 1456×819 instead. A resolution that satisfies the pixel limit can still violate the token limit, which is why a long-edge cap quoted on its own tells you nothing about cost.
The pairing you will see in older write-ups — “~1.5k at 1080p, up to ~4.8k at maximum” — mixes the two tiers. The low figure is the standard 1,568-px regime and the high one is the current 2,576-px regime; they were never both true of the same model.
Getting this wrong is not a rounding error. On the current tier a 1080p step costs 2691 / 1560 ≈ 1.7× what the same step cost on the standard one, and that multiplier lands on every turn of every run. Case study 01 works the full cost model against a single stated tier.
Which cost lever to reach for
The obvious lever is trimming old screenshots out of the conversation. It is the wrong first lever once caching is on.
A sliding window advances the point where this request’s prefix diverges from the last one’s, every single turn. That forces the surviving images to be re-processed at full rate instead of billing at the cached tenth.
Resolution is the lever that shrinks every image without touching the prefix — see case study 01.
Coordinates map 1:1 to pixels on current models, so there is no scale-factor math to get wrong. The full build is in case study 01.
If an API exists, use the API. Computer use is on the order of 10–50× the cost of an equivalent API call and far less reliable.
That range is an estimate rather than a measurement, and the mechanism behind it is the arithmetic just above. An API call sends a few hundred tokens of JSON once; the same task through a screen sends a multi-thousand-token image on every step and takes several steps. Multiply those two and you land in that band. The width of the band is mostly how many steps your task takes.
10. Agent Skills
Tools give an agent actions. Skills give it know-how, loaded only when a task calls for it.
A skill is a folder containing a SKILL.md file — instructions, plus optional scripts and reference files. Only the skill’s short description sits in the model’s context; the model reads the full file when the task calls for it.
“Reads the full file” is not magic — it is a tool call. The skill’s name and one-line description are injected into the prompt. The body is a file on disk that the agent opens with an ordinary file-read tool (or bash) when it decides the description matches the task at hand.
So the cost profile is exactly the defer_loading profile from Tool selection at scale: a few tokens of pointer on every turn, the full payload only on the turns that need it, and one extra round trip to fetch it.
That is progressive disclosure for instructions — the same mechanism as deferred tool schemas, for the same reason. Context is a scarce resource billed on every turn, so you load detail on demand instead of up front.
Skill or retrieval? Use skills for “how we do X here” knowledge that is too long for the system prompt and too procedural for retrieval.
- A deploy runbook is a skill: the model needs all of it, in order, exactly once.
- An API reference is a retrieval problem: the model needs three paragraphs out of thousands, chosen by similarity to the question.
11. OKF — Open Knowledge Format
One more emerging spec is worth a line on the map.
OKF (Open Knowledge Format) is an emerging specification for packaging domain knowledge — facts, ontologies (formal descriptions of the things in a domain and how they relate), and procedures — in a portable, model-agnostic file, rather than baking it into a prompt or a vendor-specific store.
It has the same motivation as MCP one layer up: MCP standardizes actions, OKF aims at knowledge.
It is early — know the one-liner, don’t over-claim.
Cheat sheet
Each row starts from something you would observe, names the mechanism underneath it, and gives the first thing to try.
| Symptom | Mechanism | First fix |
|---|---|---|
| Tool never fires | Description states what, not when | Add the trigger condition |
| Wrong tool chosen | Two descriptions overlap | State each tool’s boundary |
| Right tool, bad args | No structural constraint | Property descriptions → enum/format → strict |
| Invented tool name | Prompt promises a capability the tool set lacks | Return real names in is_error; audit the system prompt |
| Cache hit rate dropped after a release | Tool list changed — it’s at prefix position 0 | Sort tools deterministically; never build per-user |
| Context explodes | Tool returns raw payloads | Truncate at source; programmatic tool calling |
| Accuracy falls past ~20 tools | Schema volume in every turn | Tool search (defer_loading) or a router |
| MCP server update changed behavior | Its tools/list descriptions land in your prefix | Pin versions; diff descriptions on update |
| Computer-use cost estimate is ~1.7× off | Image figures quoted from the wrong vision tier | State the tier: 1080p is 2,691 tok on the current tier, 1,560 on the standard one |
Next: 04 — Memory & Context.