Chapter 1 of 7 · 16 min read
Projecting a tool result
On this page
A tool result is the message your code sends back after a model has asked
for a call. A model cannot open a socket, run a query or follow a link; the
only thing it can do is emit text, so a tool call is text saying "run
search_tickets with these arguments", and the tool result is the text your
handler produces in reply, appended to the conversation as one more block for
the model to read. Tool use is that loop — a description, a call, a result —
and this topic is about the half of it that carries data. Topic 5.11, on
writing tool descriptions, deals with the other half, which is where the model
learns that search_tickets exists and what to pass it.
In practice the result is whatever your handler returns and something
serializes: the dict a function-calling endpoint packs into a tool_result
block, or the body an MCP server sends back, MCP being the Model Context
Protocol that many agent runtimes now use to reach tools. It exists to solve an
ordinary integration problem, which is why it usually receives ordinary
integration treatment — point the tool at an endpoint that already exists and
return what that endpoint returns. Everything the model will ever know about
those records is in that text, read once, with nothing behind it to look up, so
the fields that are present decide which questions it can answer and the fields
that are absent are gaps it has no way to notice. The reshaping that fixes this
is a projection in the relational sense: a deliberate choice of which
columns cross the boundary, made for this consumer.
Where a UI envelope spends its tokens
Here is one element of the data array returned by a support system's
search_tickets endpoint, written to the JSON:API convention — a long-standing
specification in which every record is wrapped with its type, its id and its
links to related records. The customer's name is changed and the array is
truncated to a single entry:
{
"data": [
{
"type": "ticket",
"id": "tkt_9f3a71c4-2d18-4b6e-9c05-8ab3f2e17d90",
"attributes": {
"subject": "Card declined at checkout",
"status_code": 3,
"priority": 2,
"created_at": "2026-02-11T09:14:22.481Z",
"updated_at": "2026-02-14T16:02:07.113Z"
},
"relationships": {
"assignee": {
"data": { "type": "user", "id": "usr_41c2be09-77aa-4f31-8d2c-1e5b6c9042af" }
},
"organization": {
"data": { "type": "organization", "id": "org_5d8804f1-6b2a-4c77-9e10-3f7d51ac8802" }
}
},
"links": { "self": "https://api.example.com/v1/tickets/tkt_9f3a71c4-2d18-4b6e-9c05-8ab3f2e17d90" }
}
],
"links": { "first": "…", "next": "…", "last": "…" },
"meta": { "page": { "number": 1, "size": 25, "total": 12 } }
}Counted with the model's own tokenizer, that element is 218 tokens. Broken down by what each part of it is for, the distribution is lopsided in a way that is hard to see when you are reading the JSON as a developer, because the parts that are cheap to write are not the parts that are cheap to send.
| Part of the element | Tokens | What the model can do with it |
|---|---|---|
type, data, attributes, relationships keys | 74 | Nothing — the tool is called search_tickets, so it already knows what these are |
links.self | 31 | Nothing — it has no way to fetch a URL |
| Opaque assignee and organization ids | 46 | Only pass them back to another tool, one call per id |
status_code: 3, priority: 2 | 12 | Nothing without a legend it was never given |
Subject, created_at, updated_at | 55 | Answer with them directly |
Adding the middle column down returns the whole element: seventy-four for the structural keys, thirty-one for the self link, forty-six for the two opaque ids, twelve for the two integer enums, fifty-five for the subject and the two dates. Addition is the right operation here because every one of those parts is serialised on every record, so the sum is what one row costs and the column is what the row is spending it on. The split that matters runs across the rows rather than down them: four of the five describe the record's packaging, and one describes the record.
Fifty-five of 218 tokens carry facts the model can put in a sentence. The other
163 are either invisible to it, or actively expensive: the opaque ids are the
reason an agent holding a page of these tickets makes one follow-up call per
distinct id, and the two integer enums are the reason a status_code: 3 ticket
gets described as "status 3" rather than as open. The envelope is not merely
wasteful. It converts a single tool call into a fan of them, and it converts a
fact the database already knows into a hallucination risk, because a model that
has been handed priority: 2 and no legend will sometimes decide that 2 means
low and sometimes that it means high.
None of that is a habit a model could be trained out of, and the chain that
produces the extra calls is short enough to check link by link. The only thing a model emits
is text, so the only way it can turn usr_41c2be09-77aa-4f31-8d2c-1e5b6c9042af
into a person's name is to emit a call to a tool that accepts a user id.
get_user accepts one id per call, which fixes the number of calls at the
number of ids the model decides to resolve. And this agent loop dispatches one
call per turn, so each of those calls is a separate turn rather than a batch,
which is what converts a count of ids into a count of round trips. Each step is
forced by the one above it, which is why the number of follow-up calls is a
property of the payload rather than of the agent's diligence.
That fan is worth looking at as a shape rather than as a count, because the extra calls are all the same call with a different argument and they are serialised by the agent loop.
Each iteration is a full round trip through the model — a request carrying the whole conversation so far, a generated tool call, a result appended — so the elapsed time is mostly prefill, which is the model reading the entire conversation again before it can produce its first token, and network time rather than database time. The results alone come to 1,900 tokens, not counting the growing prompt they are appended to, and four of the twelve are exact duplicates because four tickets share an owner, which nothing in the loop knows until the results come back.
That 1,900 comes straight off the diagram and is worth reproducing, because it
is the figure most often left out of a comparison. A get_user result is 158
tokens, twelve of them arrive, and twelve times a hundred and fifty-eight is a
shade under nineteen hundred. What the multiplication leaves out is the prompt
those results are appended to. Every turn re-sends the whole conversation, so
the twelfth call is generated against a request carrying eleven earlier results
on top of the original page, and the input a provider bills is that running
total at each turn rather than the size of the last block added. Counting only
the results is therefore the optimistic version of the number, and it is still
larger than the payload that caused it.
The envelope was designed for a client that keeps a store
None of this is a criticism of JSON:API, which solved a real problem well.
Around 2013 the dominant client was a long-lived browser application — Ember
Data, Backbone, later a Redux store with a normalizing layer — that fetched many
overlapping resources over a session and needed them deduplicated. A type
discriminator lets a generic client route a record to the right model class
without knowing the endpoint. An opaque relationship id lets the client store
one copy of user usr_41c2 and point every ticket at it, so that renaming the
user updates twelve rows in the UI at once. Hypermedia links let the client page
forward without hardcoding URL structure, which is what makes the API evolvable.
Every one of those features pays for itself when the consumer is a program with
memory, a router and a normalized cache.
A model has none of those things at the moment the result lands. It has one flat body of text, read once, with no store to join against and no ability to dereference a link. Every affordance the envelope offers is an affordance it cannot use, and it pays full token price for all of them. The same structure that makes the payload efficient for a browser client fetching two hundred tickets across a session makes it inefficient for a consumer that will read twelve tickets exactly once and then answer a question.
That is the shift in framing worth carrying. The response is not a transport container that the model happens to open. It is the evidence the model will reason over, in the same sense that a case file is evidence — its value is measured by whether it contains the facts needed to reach a conclusion, and everything that is not such a fact is drag. A tool description tells the model how to make the call; the result is the other half of the same interface, and it deserves the same deliberate design rather than whatever the object-relational mapper happens to serialize.
The case file is a picture rather than a claim about the protocol, and it is worth saying where the picture stops being accurate. A case file is read by somebody who can go back for more: they can fetch the appendix, re-read page four, or ring the archive. A tool result is read once, in a single pass, competing for attention with everything else in the request, and there is no going back to a part that was skimmed. So the resemblance holds for what the result is for — supporting a conclusion — and breaks on what completeness costs. In a case file an irrelevant document is inert. In a tool result it is paid for on this turn and on every turn after it, and it displaces attention from the fields that answer the question.
Which identifiers earn their place
Stripping all the ids out is the overcorrection, and it breaks the agent in a
quieter way, because the model then has no handle to pass to add_comment or
close_ticket and will either ask the user for a ticket number it was never
shown or invent one. The distinction that holds up is whether an identifier is
an argument to a call the model can actually make. The ticket id is such an
argument — get_ticket, add_comment and assign_ticket all take it — so it
belongs in the result, and it can usually be shortened, since most systems
accept the short form tkt_9f3a71c4 and the remaining eighteen characters of
UUID are there for a uniqueness guarantee the model does not need to reproduce.
The assignee id is not such an argument in this agent, since no tool takes a
user id; it exists only as a join key for a store the model does not have, so
the server should resolve it to a name before the result is serialized. The
organization id is the same case.
The two integer enums invite a cheaper-looking fix than expansion, which is to put the legend in the system prompt once — three is open, two is high — and leave the payload alone. It is tempting for a good reason: the legend is then paid for once per session instead of once per row, and it is a one-line change to a file the tool author already owns. It fails on two counts, and both are worth being able to state. The legend in the prompt is a second copy of a mapping the database already holds, so the day someone inserts a status between two existing ones the copy is wrong and nothing fails; a wrong legend produces confident wrong words rather than an error. And a legend held in the prompt has to be applied by the model at every mention, so a ticket described three turns later is being described from a remembered substitution rather than from a fact in front of it. Expanding the value at serialisation time puts the answer where the evidence is, which is the same move as resolving the ids.
Applying that rule, plus expanding the two enums and dropping links and meta, gives this:
{
"tickets": [
{
"id": "tkt_9f3a71c4",
"subject": "Card declined at checkout",
"status": "open",
"priority": "high",
"assignee": null,
"org": "Northwind Foods",
"opened": "2026-02-11",
"last_update": "2026-02-14"
}
]
}That element is 41 tokens against the original 218, so a twelve-ticket result
drops from 3,180 tokens to roughly 520. The follow-up calls disappear, taking
1,900 tokens with them, and a question about who owns what becomes answerable
from the payload, because "assignee": null is a fact where
relationships.assignee.data.id pointing at a user record was a lookup. The
date fields lost their sub-second precision and their timezone suffix, which is
a real loss for a client that computes durations and no loss at all for a model
that will say "opened on the eleventh".
The two page figures in that sentence are documents rather than elements, and
keeping the distinction straight is what makes them reproducible. Twelve
elements at two hundred and eighteen tokens apiece is a little over two and a
half thousand, and the page also carries the top-level links and meta
objects that sit outside the data array, which is where the rest of the 3,180
goes. Twelve projected elements at forty-one apiece is a little under five
hundred, and the tickets wrapper accounts for the small remainder of the 520.
What survives that difference is the ratio, which is the part worth carrying:
the projected record is a shade under a fifth of the original, the page follows
it approximately, and the saving scales with the number of rows because the
thing that changed is the per-row cost rather than a fixed overhead.
The mapping that produces it is ordinary code, and it belongs beside the tool rather than inside the API layer, because the decisions in it are about this agent's tools and this agent's questions.
STATUS = {1: "new", 2: "pending", 3: "open", 4: "resolved", 5: "closed"}
PRIORITY = {1: "low", 2: "high", 3: "urgent"}
def project_ticket(el: dict, users: dict[str, str], orgs: dict[str, str]) -> dict:
a = el["attributes"]
assignee_id = (el["relationships"]["assignee"]["data"] or {}).get("id")
return {
"id": el["id"].split("-")[0], # tkt_9f3a71c4
"subject": a["subject"],
"status": STATUS[a["status_code"]],
"priority": PRIORITY[a["priority"]],
"assignee": users.get(assignee_id), # None stays None
"org": orgs[el["relationships"]["organization"]["data"]["id"]],
"opened": a["created_at"][:10],
"last_update": a["updated_at"][:10],
}The users and orgs maps are filled by one batched query over the ids in the
page, which is the same work an agent does across a dozen turns when the ids
arrive unresolved, performed once in the process that already holds a database
connection. A single select id, display_name from users where id = any($1)
against eight ids is under a millisecond.
Dropping a field trades a follow-up call for a wrong answer
Projection has a failure mode in the other direction, and it is harder to see
than the envelope, because an over-full result announces itself in the token
bill while a too-thin one announces nothing. The support agent's projection
above dropped sla_due_at, on the reasonable grounds that nobody had asked
about it in the traces the team read. Six weeks later the queue-management team
started asking which tickets were about to breach, and the agent answered — from
opened, the priority, and a general sense that high-priority tickets get a
shorter clock. It was right often enough that the wrongness took a month to
surface, because a ticket opened on the eleventh with a two-day clock and a
ticket opened on the eleventh with a five-day clock look identical in the
payload it was given.
The contrast with the envelope failure is what makes this worth a habit. A missing name cost twelve extra calls and eleven seconds, which is visible in every trace and every invoice. A missing due date cost nothing measurable and produced confident sentences with no evidence under them, and the model had no way to signal the gap, because a field that is absent from a result is indistinguishable from a field the record does not have. Two cheap practices keep this in view: score the projection against a set of the questions the agent is actually asked and rerun when the question mix changes, and count the turns that end in a follow-up lookup or a hedge, both of which are the payload telling you which field it is short of.
Absence has no representation, and that is the whole of the difficulty. An
object with no sla_due_at key reads identically whether the projection
dropped the field, the record genuinely has no due date, or the column was
added to the schema after this row was written. Those are three different
situations and the payload encodes one thing for all of them, so no consumer
can separate them, and no wording of a system prompt recovers a distinction the
encoding never carried. What a reader does with a gap is fill it from what is
available, and what is available here is opened, a priority, and a general
sense of how service levels tend to work. The answer that comes out has the
shape of something derived and no basis under it, which is exactly the failure
that takes a month to find.
A projection is not a minification
The reshape is not a compression pass, and treating it as one leads people to
the wrong moves — shortening keys to s and p, stripping whitespace,
base64-ing things. Those save a few percent and cost the model clarity, which is
a bad trade in both directions. Three of the changes above actually spend more
tokens than the original: "status": "open" is longer than "status_code": 3,
"priority": "high" is longer than 2, and "org": "Northwind Foods" is
longer than nothing at all, since the web client would have looked that name up
from its store. Each is worth paying for because it converts something the model
would have to resolve into something it can read. The governing question at
every field is what the model can do with this, not how many bytes it is.
It is also not a redesign of your public API. The web app keeps its JSON:API document; the tool gets a second projection over the same query, typically thirty lines of mapping code. Teams sometimes resist that as duplication and try to make one shape serve both consumers, which usually means degrading a well-understood client contract to suit a consumer with entirely different needs. Two consumers with different reading strategies is the ordinary case for a serialization layer, and the model is simply the newest one.
A tool result designed as evidence is one where every field is there because the model will either answer with it or pass it to another call, and where anything that would have required a second lookup has already been resolved. That is what takes a ticket from 218 tokens to 41 while making it more answerable rather than less, and it is a decision about this agent's questions rather than a general tidying of the API.
What it does not touch is volume. A well-shaped ticket is 41 tokens, which means four thousand well-shaped tickets are 164,000 tokens, and no amount of field selection changes that a query can match more rows than any window can hold. Shaping is what makes each row cheap; it says nothing about how many rows you should send. Before any of that, there is a smaller problem hiding inside this one: every number above was presented as a fact, and the way most teams would arrive at those numbers is by taking the byte size of the payload and dividing by four. On a body made almost entirely of UUIDs, ISO timestamps and punctuation, that estimate is not merely imprecise. It is wrong in one direction, and it is the direction that lets a page look affordable when it is not.
Tool design — results — read the current version at https://daviddominguez.dev/learn/en/tool-design-results/projecting-a-tool-result