Skip to main content
A tool spends your context window in two directions, and they are not fixed by the same thing. Going out, there is the schema. It is not sent once: it is sent on every turn of the loop, before the model has read the task, and it is charged as input tokens every time. That is the first half of this page, and the lever is a projection. Coming back, there is whatever the API answered with. That is the second half, and the lever is a response handler. It is the half nobody chooses: a schema is something you wrote, and a response is whatever the API felt like sending. Most tools are small enough that this never comes up. A handful are not, and they are not the ones you would guess. In the Linear pack, comment_create costs 911 tokens of schema and search_issues costs 47,026. They look alike from the outside: both take one object, both read issues.

Measure before narrowing

schema_tokens() prices a tool. Run it across a pack and the distribution is usually lopsided:
survey.py
Seventeen tools land between 46,870 and 48,231. The eighteenth is project_update at 1,793, and the remaining 110 are smaller than that. A cliff like this is the signal. Sizes that cluster inside 3% of each other are not seventeen expensive tools. They are one expensive thing, reached by seventeen routes.

Why the fat tools are fat

Linear’s API is GraphQL, and its filter types are mutually referential. An IssueFilter has an and and an or that hold more IssueFilter, and a team that holds a TeamFilter that holds an IssueFilter. Fifty-six of the pack’s 77 generated definitions sit in a cycle. One filter argument reaches all of them, which is what makes the seventeen sizes nearly equal: each of those tools is paying for the same graph. The 111 tools that never name a filter are all 1,793 tokens or less. This is worth separating from merely large. gsheets.spreadsheets_batch_update generates 201 definitions and gdocs.documents_batch_update generates 51, and not one of either is in a cycle. Wide is a cost you can decide to pay. Recursive is a different problem, because above a certain size it stops being a cost at all and becomes a rejected request.

Deferring is not the same as shrinking

ToolSession defers schemas: the model gets a search tool, and a tool’s parameters arrive only once it has asked for that tool by name. That is the right default and it is not sufficient here. Progressive disclosure changes how many schemas are in the prompt. It does not change what one costs, and once the model searches for search_issues it has bought all 47,026 tokens, on every turn from then until the end of the run. Deferral and narrowing solve different halves. Use both.

Find the branch, not the tool

The tool is not expensive. One field in it is, and a list of field names does not say which. Tool.paths(by_cost=True) prices the level instead of naming it:
by_cost.py
filter is 99% of the tool. term, first and team_id are the rest. The figure is measured rather than estimated. Each path is pruned for real and the schema regenerated, so what by_cost reports is what drop will produce.

Narrow it

derived() removes the branch. Two shapes, and which one you want depends on what the tool’s cost is made of. drop when one branch is the whole cost:
drop.py
keep when the cost is a union and you want a few of its members:
keep.py
Both tools still work. search_issues keeps its other seven arguments, including the search term, the team and the ordering, which is what an agent triaging issues actually sends. documents_edit_text still inserts, replaces and deletes text.

Recursive schemas can fail outright

Above some size the problem stops being cost and becomes whether the tool is callable at all. Providers cap how deep a tool schema may resolve, and a cycle resolves deeper than anything a human writes. Sending linear.search_issues unnarrowed to Fireworks returns:
The whole request is rejected, not just that tool, so the turn produces no tool call at all. drop={"filter"} removes the cycle and the same request is accepted. Two things follow, and neither is specific to this library:
  • The limit is the provider’s, and they differ. There is no schema a pack can emit that satisfies every one of them. The cap is a deployment fact, which is the layer a projection belongs to.
  • It fails as a request error, not a bad answer. Nothing degrades gracefully. If a recursive tool is in the payload, the turn returns 400 and the agent makes no progress, so this shows up as an agent that does nothing rather than as a cost line.
A tool whose schema is a cycle needs a projection to be usable, not merely to be cheap.

Every tool carries its own definitions

One more reason the fat tools compound. $defs is scoped to a single schema document, and in every tool-calling format a document is one tool. There is no cross-tool definition space in an OpenAI tools array, in an MCP tools/list response, or in an Anthropic input_schema, so a type two tools share is sent twice. Three Linear list tools, measured through the adapters this library ships:
231 definition entries for 79 distinct types. Narrowing one recursive tool therefore does not save its cost once. It saves it once per tool that reaches the same type graph. Which is also why this is not an artefact of how a pack was authored. The Linear pack is generated from GraphQL, not from an OpenAPI document, and the duplication is identical either way. It is a property of the protocol. There is no dead weight in it to remove. Every definition in every tool measured here is reachable from that tool’s own properties: linear.teams_list 77 of 77, gsheets.spreadsheets_batch_update 201 of 201, gdocs.documents_batch_update 51 of 51. Nothing is emitted that the tool does not use, so the copies cannot be trimmed, only made small. Making them small is the whole of it, and it compounds the same way the cost does. The same three tools with drop={"filter"} applied:
136x, because the saving lands once per tool rather than once. Progressive disclosure is the other half of the same lever: it decides how many copies are in the payload, while a projection decides what each copy weighs.

Clients that flatten make it worse

Support for $ref in tool schemas is uneven, so some clients resolve references before sending. Resolving is multiplicative, and on a cyclic schema there is no size it converges to. langchain_core 1.6.1 does this in convert_to_openai_function, which calls dereference_refs and then discards $defs. Its cycle guard is per branch, so a definition reached along ten paths is expanded ten times. On small Linear tools the result is about twice the input. On linear.teams_list, 187,579 bytes and 77 definitions, it had not returned after 45 seconds and had allocated 1.92 GB. That is the shape of the problem rather than one library’s bug. Expanding this pack’s 188 KB Linear schema with a depth cap of 12 produces 115 MB, and no cap that is generous enough to keep an ordinary REST schema whole is small enough to bound a cyclic one. A projection fixes this at the source, because it removes the cycle rather than bounding the expansion of one.

The same edit is a permission

A projection can only remove. That is what lets the saving and the restriction be the same line of code: drop={"filter"} is a smaller prompt and it is also an agent that cannot construct an arbitrary query against your issue tracker. Both halves print in egress_map(), so what was narrowed is reviewable rather than implied:
egress.txt
Cost is the reason you go looking. It is rarely the best reason to keep the change.

The other direction: responses coming back

Everything above is about the schema, which is the half you can see in your own code. The other half arrives from someone else. Two fields separate them, and the gap between the two is the whole subject: A response handler runs after a successful call and returns whatever the model should see. Gmail’s threads_get is the ordinary case: the API answers with a MIME tree — nested parts, multipart/alternative duplicates of the same text, base64 attachment bytes inline — and the pack’s handler returns an id, the headers worth keeping, the decoded body text, and attachment metadata rather than attachment bodies. The attachment ids survive because messages.attachments.get is the call the model makes next; dropping them would save bytes and cost a turn. Across the shipped packs, 286 tool definitions in 14 packs name a handler. None of that is configuration you write.

What it is worth

From the two campaigns on the measured results page, per run:
The first column is the point. Both arms pulled the same volume off the wire, to within 3% — same scenarios, same APIs, same credentials — and differed only in how much of it reached the model. Nothing was fetched less; it was forwarded less. Three things that number is not, all of which ToolCall is deliberate about:
  • It is not bandwidth. payload_bytes is read after decompression. The currency here is the model’s context window, not egress.
  • It is not tokens. Tokens need a tokeniser this library does not ship, and a bytes-per-token constant is a guess with a decimal point in it.
  • It is not all trimming. payload_bytes is the response as the server formatted it and context_bytes is compact JSON, so against an API that pretty-prints, some of the difference is that API’s whitespace.

Find your own

The same survey shape as the schema half, one axis over. Attach a sink, run the thing you actually run, and sort by what the handler failed to remove:
survey_responses.py
A tool near the top with saved_ratio near zero is one whose handler is missing or is not removing what the endpoint actually sends. saved_bytes going negative is not hidden either — a handler that adds more than it removes is worth seeing.

Two things to keep

Trimming a list endpoint has exactly two ways to go wrong, and both cost a turn rather than raising:
  • Keep what a Pagination declaration reads — the object id behind a derived cursor, and has_more even when it is False.
  • Keep what the model needs for the next call, which is usually an id it would otherwise have to guess.