Skip to content
Chapter 1 of 7 · Tool design — resultsProjecting a tool result

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:

json
{
  "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 elementTokensWhat the model can do with it
type, data, attributes, relationships keys74Nothing — the tool is called search_tickets, so it already knows what these are
links.self31Nothing — it has no way to fetch a URL
Opaque assignee and organization ids46Only pass them back to another tool, one call per id
status_code: 3, priority: 212Nothing without a legend it was never given
Subject, created_at, updated_at55Answer 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.

Sequence: Model → Tools (search_tickets(customer)); Tools → Model (12 tickets, 3,180 tokens, assignees as UUIDs); Model → Tools (get_user(usr_…)); Tools → Model (name, 158 tokens); Model → Model (8 distinct people, 4 duplicates).

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:

json
{
  "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.

python
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

Back to top