toolgz
v0.6.2
Published
Compress LLM tool definitions to reclaim context window. Measured across Anthropic, OpenAI, Google and xAI.
Maintainers
Readme
npm install toolgz🤖 If a coding agent is reading this
Point it at
llms.txt— prescriptive integration instructions written for an agent, shipped inside the package atnode_modules/toolgz/llms.txt. It covers the three-step contract, the rules that are not optional, per-model behaviour, and aDo notlist of the mistakes we have actually fielded.Read node_modules/toolgz/llms.txt before integrating toolgz.
The problem
You connect a few MCP servers. Each ships 20–50 tools. Every tool is a JSON Schema with a sentence of prose per parameter. That block renders at the front of every single request.
A realistic tool definition is ~420 tokens, and roughly 400 of them are prose the model doesn't need in order to pick correctly. Fifty tools is 20k tokens. A hundred is 40k.
Prompt caching makes those tokens cheap. It does not make them take up less room. Reclaiming the room is what this does.
The fix, in three lines
import { compress, recommendLevel, forAnthropic } from "toolgz";
const { level } = recommendLevel(myTools); // advice: 0, 1 or 3 — see below
const c = compress(myTools, { level }); // your existing MCP/SDK tool array
const { tools, system } = forAnthropic(c); // send these insteadcompress(myTools) with no level gives you level 1 — safe, native tool calling,
provider schema enforcement intact, and 39% smaller in tokens on the real 149-tool
corpus (count_tokens), or 13–32% on the synthetic benchmark. The 71–85% figures below
are level 3, which is what recommendLevel returns once a tool set is big enough to
amortise the dispatcher.
Two units are in play throughout this document, and they are labelled wherever they
appear: savedPct counts characters (45.1% at level 1 here), while every published
headline figure is tokens from the provider's own counter (39.2%). Characters run a
few points optimistic.
Level 1 earns that by stripping prose, so it needs prose to strip: if your descriptions
are already one short sentence, it adds ~15% instead and recommendLevel returns
0 — leave the tools alone. Real MCP catalogues are verbose, which is why they land at
the top of that range.
Note the shape of those three lines: recommendLevel only advises, and you pass its
answer back in. Nothing upgrades itself — compress(myTools) is level 1 whether you
have 2 tools or 500. See Which level to use for why.
Then translate the model's call back before you dispatch:
const r = c.resolve(block.name, block.input);
if (r.kind === "call") await myDispatch(r.name, r.args); // real name, real argsmyDispatch gets exactly what it got before. Nothing downstream changes.
Measured results
Four frontier models, seven strategies, five tool-selection tasks, 3 reps —
420 runs on the current sweep, 1,200+ across all rounds. Every raw per-run record is
committed in bench/results/; recompute any figure with
npx tsx bench/analyze-multi.ts --sweep=<timestamp>.
The table below uses a synthetic catalogue — 100 realistic-but-invented MCP-style
tools across 9 namespaces — because it lets us build deliberately confusable clusters
that a real catalogue may not contain. Four of the five scenarios draw a 30-tool
confusable subset from it; the fifth (acc-haystack) uses all 100.
Two things follow from that, and the first is uncomfortable:
- Synthetic naming can flatter a compression style. One map style measured −21% on
this fixture because every tool name carried a
namespace_opprefix to factor out. On real MCP tools, which mostly do not, the same style was worth −1%. - Real catalogues are bigger, so these numbers understate the problem. A corpus of
149 tools harvested from 14 live MCP servers measures ~68,500 prompt tokens
uncompressed on
claude-opus-5— more than twice the synthetic fixture, and about a third of a 200k context window before the user types anything. (Two live measurements exist, 42 tokens apart: 68,494 in the Round 6 sweep and 68,536 in the scaling run below. Prose rounds; each table carries the exact figure for its own run.)
The real corpus is committed at bench/fixtures/real-mcp-tools.json
with its own scenario suite (--suite=real), and it is the corpus of record for any
claim about real deployments.
All figures below are level 3 (minified-plus, the shipped default map style).
Level 1 on the same sweep saves 13–32%; on the real 149-tool corpus it saves 39.2% in
tokens; level 2 is dominated by level 3 on accuracy, though not on size past ~300 tools.
| Provider | Model | Tool block | Prompt tokens | Latency | Tasks |
|---|---|---:|---:|---:|:-:|
| Anthropic | claude-opus-5 | 9,242 → 1,284 | 30,817 → 4,628 (−85%) | 15.0s → 12.1s | 15/15 |
| xAI | grok-4.5 | 6,421 → 775 | 17,522 → 2,663 (−85%) | 6.1s → 4.6s | 15/15 |
| Google | gemini-3.1-pro-preview | 5,264 → 732 | 10,948 → 2,302 (−79%) | 5.6s → 5.5s | 15/15 |
| OpenAI | gpt-5.6-sol | 2,752 → 573 | 7,694 → 2,196 (−71%) | 6.8s → 5.6s | 15/15 |
Reasoning is enabled on all four at high effort, so this is a like-for-like frontier comparison. 60/60 tasks completed, zero hallucinated tool names, zero malformed arguments — and it is faster than uncompressed on every provider.
Recompute any figure with
npx tsx bench/analyze-multi.ts --sweep=2026-07-25T19-19 against the raw per-run
records in bench/results/.
Level 3 against level 4, measured in one sweep
| provider | L3 block | L4 block | L3 turns | L4 turns | L3 lookups | L4 lookups |
|---|--:|--:|--:|--:|--:|--:|
| claude-opus-5 | 2,980 | 12,560 | 4.1 | 3.3 | 1.5 | 0.5 |
| gemini-3.1-pro-preview | 1,748 | 9,037 | 2.3 | 2.0 | 0.3 | 0.0 |
| gpt-5.6-sol | 1,477 | 7,128 | 2.3 | 2.0 | 0.1 | 0.0 |
| grok-4.5 | 1,780 | 8,926 | 2.7 | 2.7 | 0.7 | 0.2 |
96/96 tasks on both arms, zero hallucinated names, one malformed argument. Level 3 is smaller everywhere; level 4 buys some of it back in turns and lookups — on Anthropic, 0.8 fewer turns and a third of the lookups. At ~3,300 prompt tokens per turn that recovers about a quarter of the gap, not all of it.
Level 3 remains the default. Reach for level 4 when stats.ambiguousMapLines says its
map carries nothing but names. Round 10 in docs/RESULTS.md.
How it scales, measured on real tools
The table above is a synthetic 100-tool fixture. This is the real one: 149 tools
harvested from 14 live MCP servers, scaled by replicating the corpus, measured with
Anthropic's count_tokens and confirmed against live API calls.
| Tools | Uncompressed | Level 3 | Reclaimed | |---:|---:|---:|---:| | 149 (the real corpus) | 68,536 | 3,022 | 95.6% | | 435 | 199,822 | 8,690 | 95.7% | | 800 | 368,826 | 16,006 | 95.7% | | 1,200 | 552,795 | 23,880 | 95.7% |
The ratio does not decay with scale. Both the uncompressed block and the map grow linearly, so 95.7% holds from 149 tools to 1,200. Real MCP tools measure ~460 tokens each — 17% heavier than the ~393 in the published academic benchmark, so a real catalogue hits limits sooner than synthetic ones suggest.
Which gives a practical ceiling per context window:
| Context window | Tools that fit uncompressed | With level 3 | |---|---:|---:| | 8K (small/local models) | 17 | ~409 | | 32K | 71 | ~1,638 | | 200K (typical frontier cap) | 434 | ~10,000 |
That 200K row is the one to note: most deployments cap at 200k, making ~434 real tools a hard ceiling. An independent study (Sakizli 2026) measured the same threshold at ~494 tools using tools 17% lighter than ours — two separate measurements agreeing within about 15%.
What we could not test
We have not demonstrated that compression improves accuracy, and we do not claim it.
The published study above finds a binary enablement effect: at 8K with 28 tools, uncompressed schemas overflow the window and exact-match accuracy collapses to 2.6%, while compression restores it (+20.5pp average). At 32K, where both fit, four of five models show ≤1pp difference — the effect is budget-driven, not intrinsic.
We tried to reproduce it and could not, for an honest reason: every provider we test
against has a window far larger than our corpus needs. Uncompressed requests ran
successfully at 149, 435 and 800 tools (368,826 tokens) on claude-opus-5, picking
the correct tool each time. Reaching overflow on a 1M window would take ~2,173 tools.
So the enablement regime — where this stops being an optimisation and becomes a prerequisite — lives on small-context models we do not currently test. If you run local models at 8K–32K, that study is more relevant to you than our benchmarks are.
What about cost?
Cost is not the claim, and we deliberately do not lead with it. Prompt caching already makes tool tokens cheap. What caching does not do is give you the room back, and the room is what you run out of.
Cost does usually fall as a side effect, by an amount that depends on your provider
and reasoning settings — and on one of four providers we measured, it does not fall at
all. On gpt-5.6-sol the uncompressed cost distribution is heavily right-skewed (mean
$0.0172, median $0.0052), so a few expensive runs make compression look break-even
while the typical run gets about 2.5× dearer. We used to publish a "−7% on OpenAI"
figure. It was a mean over a skewed distribution and we withdrew it.
If you do want to optimise the bill, it is one option — and it is a trade, not a freebie:
compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });| Model | Median cost vs default |
|---|---:|
| gpt-5.6-sol | −20.7% |
| gemini-3.1-pro-preview | −15.4% |
| claude-opus-5 | −9.0% |
| grok-4.5 | +13.2% — so it is not enabled there |
From 432 runs, 36 per style per provider, on the real 149-tool corpus. What you give
up: a slightly larger cached map (+275 characters), and on grok-4.5 a worse bill —
which is why that row keeps the default rather than the cost-optimised style. Omit
objective and you get the conservative default everywhere, unchanged.
It does not make the model worse
That was the thing to disprove, and we tried hard to. The task suite is built from
deliberately confusable tool clusters — search_issues vs list_issues, comment-vs-update,
approve-vs-merge, the same three products side by side — where the correct choice turns on
the tool name that compression takes away.
The model doesn't lose the ability to choose — it converts a recall problem into a retrieval
problem and looks up what it needs. The default map style exists because of the red cell:
bare tool names failed on grok-4.5 deterministically, 3 of 3 attempts on one scenario,
answering with zero tool calls and no error raised. Naming the required arguments fixed it.
Two of the four rows are marked (removed): those styles were deleted in 0.2.0 and you cannot select them. They are charted anyway because they are the evidence for the default — deleting the losing arms would delete the reason.
How we found the cost story, and got it wrong twice
The first cross-provider sweep found cost going up 15% on OpenAI even while context fell 69%. The dispatcher was spending extra turns, and on a reasoning model every turn pays for a fresh round of thinking.
So we captured the calls being rejected instead of guessing, and found three bugs in
this library: models pass query to a parameter named q (14 of 18 rejections), they
sometimes call the map code as the tool name, and they sometimes pass arguments flat
instead of nested. Fixing all three drove malformed arguments to zero on every
provider. Later rounds found three more of the same kind — a namespace joined with a
dot, the lookup tool routed through the dispatcher — all shipped in 0.1.2.
That is the honest shape of this work: most of the wins came from accepting what models actually send, not from making the map smaller. One extra turn is worth ~3,300 prompt tokens; the best encoding change available was worth ~550. Six map styles were tried and removed in 0.2.0 because they were smaller and still worse.
Which level to use
The whole idea, in plain English
Your tool definitions are a menu handed to the model at the start of every single conversation. It is long, and most of it is flowery prose about each dish.
- Level 1 — the same menu with the prose cut. It is still a real menu: the model points at a dish by name, and the kitchen checks the order makes sense before cooking. This is the default, and it gives up nothing.
- Level 3 — throw the menu away. Hand the model a numbered list and one waiter. It says "number 12, no onions," and the waiter knows what that means. The list is tiny. But the kitchen no longer checks the order — toolgz checks it instead, against your original schema, and hands back a readable error if it's wrong.
If the model needs to know what number 12 comes with, it asks. That's the q() lookup,
and it costs about half a turn.
So: level 1 is smaller. Level 3 is much smaller and you take over order-checking.
Two things that surprise people
1. Nothing changes level on its own. You are always the one who picks.
compress(myTools) // level 1. ALWAYS — 2 tools or 500.
compress(myTools, { level: 3 }) // level 3, because you asked for it
const { level } = recommendLevel(myTools); // just advice: returns 0, 1 or 3
compress(myTools, { level }); // now it's 3, because you passed it inrecommendLevel() advises; it does not act. On our 149-tool corpus,
compress(myTools) saves 45.1% and compress(myTools, { level: 3 }) saves 96.5% — so
forgetting to pass the level back in quietly leaves half the win on the table.
This is deliberate. Level 3 gives up provider-side schema enforcement, and silently changing a caller's correctness guarantees because their tool array grew would be a worse bug than the tokens are worth.
2. It switches on how big the block is, not how many tools you have.
A tool can be 20 tokens or 460, so counting them tells you very little:
| Tool set | Level 1 block | Recommends | |---|---:|:-:| | 72 tools, one parameter each | ~5,000 tokens | 1 | | 200 tools, one parameter each | ~14,100 tokens | 3 | | 40 real MCP tools | ~10,600 tokens | 3 | | 149 real MCP tools | ~42,700 tokens | 3 |
Forty chatty tools cross the line while 72 terse ones don't. The threshold is 10,000 tokens — about 5% of a 200k window. Below that, reclaiming the block doesn't change what fits, so keeping the provider's own argument validation is worth more than the saving.
All five levels
Ask the library and it explains itself — though it only ever returns 0, 1 or 3, for reasons under each heading:
import { recommendLevel } from "toolgz";
const { level, reason } = recommendLevel(myTools);| Level | What goes on the wire | Names the model sees | Provider enforces your schema | Needs setup | |:-:|---|:-:|:-:|:-:| | 0 | your tools, unchanged | real | yes | — | | 1 | one native tool each, flattened schemas | real | yes | — | | 2 | one compound tool per namespace | real | no | — | | 3 | one dispatcher + one lookup | codes | no | — | | 4 | one dispatcher + one lookup | real | no | a compiled map |
Level 0 — passthrough
Your tools, normalised to Anthropic shape and nothing else. It exists so you can A/B
against an uncompressed baseline inside your own app without changing any other code, and
so resolve() is on the call path from day one — which makes every other level a one-line
change later.
It reports −0.6% if you pass MCP-style inputSchema, because input_schema is one
character longer. Structural, not waste.
Level 1 — signature lines (the default)
One native tool per input tool, real names kept. The JSON Schema keeps everything that
constrains sampling — types, enum, required, array item types — and loses the prose.
Each description becomes name(a,b?) — first sentence.
Nothing is given up: the provider still enforces your schema, so a malformed argument is rejected by the sampler before it reaches you. Measured: fewer tokens, zero malformed arguments, zero extra turns, latency no worse.
It earns its saving by stripping prose, so it needs prose to strip. On a real MCP
catalogue it nets 39% in tokens; on tools whose descriptions are already one short
sentence it adds ~15%, and recommendLevel returns 0 rather than recommend it.
If you have a compiled map, you can pass it here too. compress(tools, { level: 1,
compiled }) swaps each tool's own first sentence for the compiled docstring — a better
purpose statement, at roughly the same size (+2.2% on the real corpus). Provider-side
enforcement is untouched, because these are ordinary native tools carrying real schemas. A
compiled line that no longer matches its schema falls back to the tool's own prose.
This shipped in 0.5.0 claiming a 16% saving, and that claim was wrong. The saving came from dropping the signature prefix along with the prose. But level 1 strips every per-property
description, so the signature line is the only place the parameter inventory appears — and an external team measured what removing it costs on a 60-tool registry: selection accuracy fell 68.9% → 60.0% on Opus and 57.8% → 44.4% on Kimi, and the arm that had been the only one in their whole experiment with zero malformed arguments began inventing parameters that exist on no tool.The 16% was the inventory. No setting keeps both — restoring the prefix costs 2.2% here and 3.9% on their registry. The prefix is therefore back on by default;
signaturePrefix: falsestill buys the 16%, with that risk now stated rather than discovered.
The dangerous middle, an external reviewer's phrase and a better one than we had. Level 1 keeps the schema, so it looks authoritative and the model has no reason to read further — measured at zero
q()lookups per task. Level 3, forced into lookups, incidentally recovers policy that lived in the prose. On their registry level 1 was therefore the worse arm for confirmation-gated operations: 5 of 9 destructive calls issued with no confirmation token, against 3 of 9 at level 3. If your descriptions carry behavioural policy, move it to the system prompt before compressing at any level.
Level 2 — namespace collapse
Operations fold into one compound tool per namespace: github(op, args) where op is an
enum of create_issue | search_issues | …. The op names stay real and visible on the
wire, which is the entire reason to choose it — a log, an audit trail or a policy engine
reading the raw request still sees create_issue, where level 3 would show a0.
The cost is that arguments move into a generic object, so provider-side enforcement is gone from here on.
It is dominated by level 3 on accuracy, and recommendLevel never returns it. On the
420-run sweep, 60 runs each: level 2 averaged 4,408 prompt tokens against level 3's
2,947, and produced 16 malformed arguments against level 3's zero.
It is not dominated on size at scale, which is worth knowing before you rule it out. Level 3's map grows one line per tool, so it is not flat either — level 2's per-namespace payload overtakes it somewhere between 100 and 300 tools and is roughly a third smaller by 600. See the scale table in docs/BEFORE-AFTER.md. Size is not the reason to avoid level 2; argument quality is. It is not a stepping stone between 1 and 3 — it is a niche escape hatch for callers who need real operation names on the wire and will accept a worse trade to get them.
Level 3 — dispatcher and a cached map
Two tools total however many you start with: t to dispatch, q to look up. The map goes
in your system prompt behind a cache breakpoint. This is the level the headline numbers
come from — 71–85% of the prompt, 95.6% of the tool block on the real corpus.
The model calls t(f="a0", a={…}) and toolgz translates the code back. The cost is roughly
0.3–1.7 lookups per task, about half an extra turn, plus provider-side enforcement.
Its weakness is that the map is only as good as your tool names. If many of your tools
render to the same line — manage_table operation, manage_rows operation — the name is
the only signal left and the model picks by keyword. Check stats.ambiguousMapLines; if it
is high, either mapStyle: "signature" or level 4.
If your tools take objects or arrays, do not use the default map style here.
name+requiredshows parameter names and not their shapes, so the model has no way to know a parameter wants[{…}]rather than a string. On a suite built to test exactly this, all three malformed arguments in 144 runs landed on that arm, across three providers —Parameter "origin" on compute_route must be an object. It also spent 0.77 lookups per run against 0.08.mapStyle: "signature"and level 4 both rendershipments:[]andtruckSpecs:{}from your schema and produced zero. Round 12 in docs/RESULTS.md.
Level 4 — a map a model compiled for you
Level 3's dispatcher with the mechanical map replaced by minified Python a model wrote from your corpus ahead of time, so each line says what the tool is for:
def article_append(article_id,content):"add to end, keeping existing text; use over article_update to avoid overwriting"Deliberately larger than level 3 — it buys back the semantics level 3 deletes. On the 149-tool corpus: 12,441 tokens against level 3's 2,987, and ambiguous map lines fall from 101/149 to zero. Measured over 180 live runs: every task completed, zero hallucinated tool names.
It needs a compiled artifact, which is why recommendLevel never returns it — a
recommendation you cannot act on is not a recommendation. Generate one with
npx toolgz compile, or compileTools(tools, { complete }) with your own model client.
Pick it when level 3's map would be mostly lookalikes, and not otherwise. On a catalogue whose names already discriminate, it is 4× the tokens for nothing.
How to choose, in one paragraph
Start at 1 — it is free and gives up nothing. Move to 3 when the block is large
enough that reclaiming it changes what fits, which recommendLevel measures for you at
~10,000 tokens. If stats.ambiguousMapLines says level 3's map carries nothing but names,
go to 4. Use 2 only if something downstream must read real operation names off the
raw request, and 0 to measure what any of it bought you.
What level 3 actually looks like
Two tools on the wire regardless of how many you start with, and a map in the system prompt behind a cache breakpoint:
<toolmap>
a0 github_create_issue owner,repo,title
a1 github_search_issues q
b0 slack_post_message channel,text
</toolmap>The model calls t(f="a0", a={…}), and q(c="a0") expands a code to its full signature when
it needs the optional parameters.
If you want to remove those lookups entirely, put the whole signature in the map:
compress(myTools, { level: 3, mapStyle: "signature" });
// a0 github_create_issue(owner,repo,title,body?,labels?)Measured: lookups drop to zero and it was the fastest and cheapest arm on OpenAI (4.0s, −17% cost). It is slightly larger, and on xAI it was worse than the default, so it is an option rather than the default.
How much enforcement can a dispatcher have? None, and this was measured rather than assumed. Argument enforcement would need a discriminated union on
f, and the Anthropic API rejectsoneOf/allOf/anyOfat the top level of aninput_schema— we measured the union at +112,488 characters before finding that out. Constrainingfto anenumof real tool names is legal, and was built, measured and removed: it cost +47% to +70% of the level-3 map, prevented zero hallucinated names across 192 runs, and caused the only two failures in that sweep — grok-4.5 answering with no tool call and no error, the same silent-failure mode that got a map style deleted in 0.2.0. If you need real enforcement, use level 1 with a compiled map.
The trade: at levels 2, 3 and 4 the model fills a generic argument object, so the provider's
sampler no longer enforces your schema. toolgz validates against your original schema and
returns a model-readable error instead. That is why validate defaults to on — leave it on.
Every artifact above is generated by running the library — see docs/BEFORE-AFTER.md for the full tools array and system prompt, before and after, at every level, with real token counts and a live encode → decode round trip. A test asserts that file matches the code, so it cannot drift.
signature: the level-3 style with no lookups
Worth calling out because it trades differently from the others. signature puts the full
parameter list in the map, so the model never needs a q() lookup — measured 0.0 lookups
on all four providers, against 0.1–0.5 for the default.
That makes it the fastest and cheapest option on OpenAI (4.0s vs 5.6s; median cost $0.0106 vs $0.0129) at the price of a larger cached map. But it is not universally better: on grok-4.5 it was slower (7.4s vs 4.6s), dearer, and produced the one malformed argument in that arm. Reach for it if lookups are your bottleneck and you are not on xAI.
Optional: let the library pick the map style for your model
Level 3 has several map styles. Which one is cheapest turns out to depend on the
model, so you can hand compress() a model id and let it use what was actually
measured:
This is the one thing the library does choose for you, and only if you pass
model. Note the difference from levels: a map style is a pure encoding choice, so picking a better one cannot change your results. The level can — level 3 hands argument validation from the provider to toolgz — so that stays your explicit call.
compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });| Model | Style chosen for cost | Measured against the default |
|---|---|---:|
| gpt-5.6-sol | explicit | −20.7% |
| gemini-3.1-pro-preview | explicit | −15.4% |
| claude-opus-5 | explicit | −9.0% |
| grok-4.5 | default | explicit measured +13.2% there |
From a 432-run sweep, 36 runs per style per provider, on the real 149-tool corpus.
explicit completed 144/144 tasks and cut lookups on all four providers; only the
cost consequence differs by model. The table lives in
src/policy.generated.ts, is generated from the committed
results, and a test fails if it drifts from them.
Four things worth knowing:
- Omitting
modelchanges nothing. Existing behaviour is byte-identical; there is a test asserting that. objectivedefaults tooccupancy, which has no table. Every style we measured landed within ±3.1% of the default on context occupancy — under our 5% effect-size floor — so there is nothing to select. Onlycosthas entries.- An absent model gets the default. That is an absence of evidence, not a
prediction.
gpt-5.6-solbehaving one way says nothing certain aboutgpt-5.7. statsalways tells you what was actually used, so nothing is substituted silently:
const c = compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
c.stats.mapStyle; // "explicit" — what was used
c.stats.requestedMapStyle; // undefined — you did not ask for a specific style
c.stats.fallbackReason; // undefined — nothing was substitutedThere is also a safety valve: if a future sweep finds a (model, style) pair that
fails, it is refused and stats.fallbackReason says why. That table is currently
empty — the one pair ever measured unsafe was nocode on grok-4.5 (19% of runs
answered with no tool call at all), and rather than document a footgun we deleted the
style in 0.2.0.
See it run
A demo you can put on a screen. It prints every step — the tool definitions going in,
what compress() turns them into, the request, the model's actual response, the
resolve() translation, and your dispatcher running unchanged.
npm run demo -- --level=3 # one level, every step
npm run demo -- --compare # levels 0, 1 and 3 back to back
npm run demo -- --level=3 --offline # no API key: scripted model, real library--compare ends with the side-by-side:
level wire tools definition chars reclaimed turns argument checking
0 6 → 6 3,283 → 3,289 -0.2% 2 provider
1 6 → 6 3,283 → 1,999 39.1% 2 provider
3 6 → 2 3,283 → 807 75.4% 2 toolgzIt really runs the loop against a real model unless you pass --offline, and it says
which it did. Level 3 also demonstrates the two outcomes a happy path never shows —
a bad code coming back kind: "error" and recoverable, and a q() lookup coming back
kind: "meta" with nothing dispatched.
Not shipped in the npm package: it imports a provider SDK, which is a devDependency, and the library's zero-runtime-dependency guarantee is not negotiable.
Runnable examples
Six files in examples/, all offline — no API key, no cost. Every one is
executed by the test suite, so an example that stops working is a failing test rather
than a bug report from you.
npx tsx examples/01-minimal.tsexamples/README.md walks through all five with their real
output and what each one is trying to teach — start there if you would rather read than
run.
| File | Shows |
|---|---|
| 01-minimal.ts | the smallest useful thing: recommendLevel → compress → resolve |
| 02-agent-loop.ts | the full loop against a scripted model, covering all three resolve() outcomes including a recovery |
| 03-mcp-servers.ts | 149 real tools from 14 MCP servers, all four levels, and the name-collision hazard |
| 04-providers.ts | the four provider envelopes side by side, plus the Gemini schema repairs |
| 05-per-model.ts | model/objective selection and reading stats to see what was actually used |
| 06-level4.ts | a compiled Python map: what it fixes, and the three guarantees that make it safe |
Two things 04-providers.ts demonstrates rather than describes, because both have bitten
people: /v1/responses needs the flat tool shape when you set reasoning effort, and
Gemini returns one wrapper object containing all declarations, so you count
tools[0].functionDeclarations.length.
Using it: the full guide
Everything below was a separate docs/GUIDE.md. It is inline now, deliberately: the
same claims lived in both files and drifted apart — the README advertised level 3's
savings while its own quick-start example used level 1. One document cannot contradict
itself.
Reference material that is generated or historical stays separate, because it is not hand-maintained prose:
- docs/RESULTS.md — every benchmark round, the raw evidence log
- docs/BEFORE-AFTER.md — generated by executing the library
- docs/RELEASING.md — release process
1. The agent loop, in full
A complete, working Anthropic loop. The only additions to a normal loop are the
compress() call at the top and the resolve() call before dispatch.
import Anthropic from "@anthropic-ai/sdk";
import { compress, forAnthropic } from "toolgz";
const client = new Anthropic();
const SYSTEM = "You are a helpful operations agent. Use the tools available.";
// 1. Compress once, outside the loop. It is pure and deterministic.
const c = compress(myTools, { level: 3 });
const { tools, system } = forAnthropic(c);
export async function run(userMessage: string) {
const messages: any[] = [{ role: "user", content: userMessage }];
for (let turn = 0; turn < 12; turn++) {
const res = await client.messages.create({
model: "claude-opus-5",
max_tokens: 8000,
system: [{ type: "text", text: SYSTEM }, ...(system ?? [])],
tools: tools as any,
messages,
});
const calls = res.content.filter((b: any) => b.type === "tool_use");
if (!calls.length) return res; // model is done
// 2. Echo the assistant turn back verbatim. On Anthropic this matters:
// thinking blocks must be returned unchanged or the model re-reasons.
messages.push({ role: "assistant", content: res.content });
const results: any[] = [];
for (const call of calls) {
const r = c.resolve(call.name, (call as any).input);
if (r.kind === "call") {
const output = await myDispatch(r.name, r.args); // your real handler
results.push({
type: "tool_result",
tool_use_id: call.id,
content: JSON.stringify(output),
});
} else if (r.kind === "meta") {
// The model asked toolgz a lookup question. Answer it; don't dispatch.
results.push({
type: "tool_result",
tool_use_id: call.id,
content: r.result,
});
} else {
// Bad arguments or an unknown code. The message is written for the
// model to read — hand it straight back and let it retry.
results.push({
type: "tool_result",
tool_use_id: call.id,
content: `Error: ${r.message}`,
is_error: true,
});
}
}
messages.push({ role: "user", content: results });
}
}Three rules that matter:
- Call
compress()once, outside the loop. Calling it per turn is wasteful and, if you vary the options, breaks prompt caching. - Never dispatch on
metaorerror. Onlykind === "call"is a real tool invocation. - Feed errors back rather than throwing. Recovery is the design; the error strings are written for the model.
2. Handling the three outcomes
resolve() returns a discriminated union. Handle all three.
const r = c.resolve(rawName, rawArgs);
switch (r.kind) {
case "call":
// r.name → the original tool name, e.g. "github_create_issue"
// r.args → validated against your original schema
await myDispatch(r.name, r.args);
break;
case "meta":
// The model used a toolgz lookup tool (q / describe_op).
// r.result is text to return as the tool result. Do not dispatch.
reply(r.result);
break;
case "error":
// r.message → model-readable: names the tool, the parameter, the fix
// r.recoverable → true when retrying can plausibly succeed
reply(r.message, { isError: true });
break;
}What produces each:
| Outcome | Cause | You should |
|---|---|---|
| call | valid invocation | dispatch it |
| meta | model asked for a definition or searched the map | return r.result as the tool result |
| error | missing/unknown/wrong-typed argument, or an unknown code | return r.message with an error flag |
Argument validation runs against your original schema, not the compressed
one, so an error here means the call genuinely would not have worked.
3. Provider setup
The core is provider-neutral. Adapters handle wire shape and cache placement. They are pure functions and never mutate what you pass them.
Anthropic
import { compress, forAnthropic } from "toolgz";
const c = compress(myTools, { level: 3 });
const { tools, system } = forAnthropic(c); // or { ttl: "1h" }
await client.messages.create({
model: "claude-opus-5",
max_tokens: 8000,
system: [{ type: "text", text: SYSTEM }, ...(system ?? [])],
tools,
messages,
});forAnthropic places exactly one cache_control breakpoint on the last
eligible tool, and skips any tool carrying defer_loading because the API
rejects that combination.
OpenAI
OpenAI has two endpoints with two different tool shapes, and toolgz has an adapter for each. Pick by whether you want reasoning.
If you want tools and reasoning — use /v1/responses. On the GPT-5.x
line, /v1/chat/completions refuses the combination outright:
Function tools with reasoning_effort are not supported for gpt-5.6-sol in
/v1/chat/completions. To use function tools, use /v1/responses or set
reasoning_effort to 'none'.import { compress, forOpenAIResponses } from "toolgz";
const c = compress(myTools, { level: 3 });
const { tools, systemPreamble } = forOpenAIResponses(c); // flat tool shape
const res = await client.responses.create({
model: "gpt-5.6-sol",
reasoning: { effort: "high" },
max_output_tokens: 8000,
tools,
input: [
{ type: "message", role: "developer",
content: SYSTEM + (systemPreamble ? "\n\n" + systemPreamble : "") },
{ type: "message", role: "user", content: userMessage },
],
});Two things about /v1/responses that will bite you otherwise:
- History is a flat
input[]list, notmessages. A tool call round-trip is{type:"function_call", call_id, name, arguments}followed by{type:"function_call_output", call_id, output}. - Reasoning items must be echoed back alongside tool outputs on the next
turn, or the model re-reasons from scratch every turn. Push the whole
response.outputarray back verbatim.
If you don't need reasoning, chat completions is fine and the tool shape is the nested one:
import { compress, forOpenAI } from "toolgz";
const { tools, systemPreamble } = forOpenAI(compress(myTools, { level: 3 }));
await client.chat.completions.create({
model: "gpt-5.6-sol",
messages: [
{ role: "system", content: SYSTEM + (systemPreamble ? "\n\n" + systemPreamble : "") },
...messages,
],
tools,
});Either way, OpenAI's prefix caching is automatic with a ~1024-token floor, so there is no breakpoint to place — just keep your prefix stable. Cached input bills at roughly a tenth of the normal rate.
forOpenAI emits {type:"function", function:{…}}; forOpenAIResponses emits
the flat {type:"function", name, …}. Sending one shape to the other endpoint
is a validation error, so match them.
Google Gemini
import { compress, forGemini } from "toolgz";
const c = compress(myTools, { level: 3 });
const { tools, systemPreamble } = forGemini(c);
await ai.models.generateContent({
model: "gemini-3.1-pro-preview",
contents,
config: {
systemInstruction: SYSTEM + (systemPreamble ? "\n\n" + systemPreamble : ""),
tools,
},
});forGemini strips the JSON Schema keywords Gemini rejects
(additionalProperties, $schema, default, examples) recursively.
xAI
xAI's API is OpenAI-compatible, so use forOpenAI:
import OpenAI from "openai";
import { compress, forOpenAI } from "toolgz";
const client = new OpenAI({
apiKey: process.env.XAI_API_KEY,
baseURL: "https://api.x.ai/v1",
});
const { tools, systemPreamble } = forOpenAI(compress(myTools, { level: 3 }));Note: xAI excludes reasoning tokens from
completion_tokens. If you're tracking spend, addusage.completion_tokens_details.reasoning_tokens.
4. Prompt caching
Read this even if you skip everything else. Prompt caching is what makes the per-turn cost of your tool block collapse, and it is easy to break by accident.
Caching is a prefix match. Any byte that changes invalidates everything after it. Tool definitions render first, so a single reordered tool re-bills your whole prompt.
compress() is built for this: it sorts tools by name and emits byte-identical
output for identical input. There is a test asserting it, and that test does not
get deleted.
What breaks caching — check your own code for these:
| Mistake | Why it breaks |
|---|---|
| new Date().toISOString() in the system prompt | prefix differs every request |
| a request id or UUID near the top | same |
| building tools from an unsorted Set/Map | serialization order drifts |
| calling compress() with different options per turn | different payload |
| adding or removing a tool mid-conversation | tool block is position 0 |
Verify it's working — don't assume:
const res = await client.messages.create({ /* … */ });
console.log(res.usage.cache_read_input_tokens); // should be > 0 after turn 1If that stays 0 across turns with an identical prefix, something upstream is
varying. Diff two rendered request bodies to find it.
5. Composing with Anthropic's native tool search
Anthropic ships server-side tool search (tool_search_tool_regex_20251119 plus
defer_loading: true). It defers whole schemas; toolgz shrinks each schema.
They're orthogonal and compose:
const c = compress(myTools, { level: 1 });
const tools = [
{ type: "tool_search_tool_regex_20251119", name: "tool_search_tool_regex" },
...(c.tools as any[]).map((t, i) => (i < 5 ? t : { ...t, defer_loading: true })),
];Two API constraints: at least one tool must stay non-deferred, and
cache_control cannot go on a deferred tool (forAnthropic handles the second).
One measured caveat. defer_loading hides tools until the model elects to
search. On Claude Opus 5 it elects reliably — 20/20 tasks. On Haiku 4.5 it
often does not: 6 of 30 tasks, with four of five scenarios answered in a
single turn and zero tool calls. No error is raised; the request succeeds and the
answer is simply unaided.
A dispatcher doesn't share that failure mode, for a structural reason: t and
q are ordinary always-visible tools, so the model cannot forget to search —
searching is the only thing on offer. Deferred loading makes discovery
optional; a dispatcher makes it the entry point.
Rule of thumb: compose with native search on frontier models; prefer level 3 alone below that tier.
6. MCP servers
MCP tool definitions already match the input shape, so there's nothing to convert:
const { tools: mcpTools } = await mcpClient.listTools();
const c = compress(mcpTools, { level: 3 });Aggregating several servers is where the payoff is largest. Namespace the names so the grouping is meaningful:
const all = [];
for (const [serverName, client] of servers) {
const { tools } = await client.listTools();
all.push(...tools.map((t) => ({ ...t, name: `${serverName}_${t.name}` })));
}
const c = compress(all, { level: 3 });Default namespacing splits on the first _ or .. Override it if your names
don't follow that convention:
compress(all, {
level: 3,
namespaceOf: (name) => {
const [ns, ...rest] = name.split("::");
return { ns, op: rest.join("::") };
},
});7. Troubleshooting
The model calls t or q and my dispatcher explodes.
You're dispatching on every tool call. Only kind === "call" is real; meta
and error are toolgz's own tools and must be answered, not dispatched. See
§2.
resolve() returns error: unknown parameter "x".
The model invented a parameter. Feed r.message back as an error tool result —
it names the accepted parameters and the model will retry. This is expected on
levels 2, 3 and 4 and is exactly what validation is for.
Lots of malformed arguments.
First check you have not overridden mapStyle away from the default
"name+required" — on the current sweep that default produced zero
malformed arguments on all four providers, while the bare-name map still
produced them. If you are on the default and still seeing errors, try
mapStyle: "signature" (the model then has the optional parameters too), or
drop to level 1, which keeps provider-side schema enforcement.
The error messages are written to be actionable: a near-miss parameter name is named explicitly ("You passed "query" — did you mean "q"? Rename it."), and a wrong-case enum value shows the exact accepted spelling. Feed them straight back and the retry usually lands first time.
cache_read_input_tokens is always 0.
Something in your prefix varies per request. See §4.
Gemini rejects my schema.
Use forGemini, which strips the keywords Gemini won't accept. If a new one
appears, it's a one-line addition to the adapter.
Savings look small.
First check you actually passed a level: compress(myTools) is level 1, and it
stays level 1 no matter how many tools you hand it. recommendLevel advises, it does
not act — you have to pass its answer in as { level }.
If you did, ask recommendLevel(myTools) and read the reason. It reports the size of
your level-1 block, and under ~10,000 tokens there is little worth reclaiming. Also
check c.stats:
console.log(c.stats);
// { level: 3, toolCount: 100, wireToolCount: 2,
// originalChars: 61461, compressedChars: 2211, savedPct: 96.4 }savedPct is a character saving, a few points optimistic against tokens. originalChars and compressedChars give the raw character counts.
I need the real token numbers.
Count them with the provider's own endpoint. Never use tiktoken for Claude —
it's OpenAI's tokenizer and is wrong for Claude by 15–20%+.
const { input_tokens } = await client.messages.countTokens({
model: "claude-opus-5",
tools: tools as any,
system: SYSTEM,
messages: [{ role: "user", content: "x" }],
});8. Benchmarking your own tools
Don't take our numbers for your workload — especially at level 3. The repo ships the harness:
git clone https://github.com/dperussina/toolgz
cd toolgz && npm install
cp .env.example .env # add your keys
npm test # 107 offline tests, no network, no cost
# one provider, one scenario, cheap
npx tsx bench/harness/run-multi.ts --provider=openai \
--scenario=acc-search-vs-list --reps=1
# the full comparison (costs money)
npx tsx bench/harness/run-multi.ts --provider=all --reps=3 --variants
npx tsx bench/analyze-multi.ts # per-arm table + ranking stabilityTo use your own tools and tasks, edit bench/fixtures/tools.ts and
bench/scenarios-accuracy.ts. A scenario declares its tools, the prompt, and
the expected call, so grading is mechanical rather than model-judged.
Raw per-run JSONL lands in bench/results/ and is committed, so every published
number can be recomputed rather than trusted.
9. API reference
compress(tools, options?) → CompressResult
| Option | Type | Default | Notes |
|---|---|---|---|
| level | 0 \| 1 \| 2 \| 3 \| 4 | 1 | see Which level to use |
| mapStyle | "name+required" \| "explicit" \| "signature" | "name+required" | level 3 only |
| namespaceOf | (name) => {ns, op} | split on first _/. | levels 2–4 grouping |
| aliasOf | (ns) => string | identity | level 2 tool naming |
| signaturePrefix | boolean | true | level 1 only; see below |
| compiled | Record<string, string> | — | required at level 4, optional at level 1; see below |
| requireCompiled | boolean | false | levels 1 and 4; throw instead of falling back |
| searchLimit | number | 8 | max results from a q search |
| validate | boolean | true | leave this on |
| model | string | — | exact model id; picks the measured style. Omit and nothing changes |
| objective | "occupancy" \| "cost" | "occupancy" | what the pick optimises. Only cost has entries |
signaturePrefix — measured, and not the default for a reason
Level 1 prepends name(a,b?) — to each description while keeping the full
input_schema. That prefix restates the tool name, property names, required list, enums
and item types that the schema already carries, and it is 18.5% of the level-1
payload on the real corpus. Setting it false makes level 1 strictly smaller — 45.1%
→ 55.3% on 149 real tools — and removes the case where level 1 inflates a terse
catalogue at all (−14.4% → −0.6%, which is level 0's own floor).
It stays true by default because size was not the question. Over 64 live runs, removing
it was a clear win on three providers and not on the fourth:
| Provider | Block | Median cost | Turns |
|---|---:|---:|---|
| gemini-3.1-pro-preview | −18.5% | −15.3% | 2.00 → 2.00 |
| gpt-5.6-sol | −26.0% | −13.8% | 2.38 → 2.13 |
| grok-4.5 | −18.2% | −17.2% | 3.00 → 3.00 |
| claude-opus-5 | −18.3% | +3.8% | 3.88 → 4.63 |
On claude-opus-5 the smaller block is spent on extra turns, and one turn is worth
~3,300 prompt tokens here. Zero malformed arguments and zero hallucinated names on both
arms, 64/64 tasks. So it is safe either way; it is just not universally cheaper. Try it
if you are not on Anthropic, and measure. Full write-up in
docs/RESULTS.md Round 7.
Returns:
| Field | Type | Notes |
|---|---|---|
| tools | unknown[] | send these; Anthropic shape |
| systemPreamble | string | append to your system prompt; "" at levels 0–1 |
| cachePreamble | boolean | whether the preamble should sit behind a breakpoint |
| resolve(name, args) | → Resolution | translate a model call back |
| codeFor(name) | → string | real name → level-3 code; throws below level 3 |
| stats | CompressStats | level, mapStyle, requestedMapStyle, fallbackReason, toolCount, wireToolCount, originalChars, compressedChars, savedPct, ambiguousMapLines, largestLookalikeGroup, uncompiledTools, staleCompiledTools, orphanedCompiledEntries |
savedPctis a character saving, and runs a few points optimistic against tokens. On the real 149-tool corpus it reports 45.1% at level 1 wherecount_tokensmeasures 39.2%, and 96.5% at level 3 against 95.6%.Those character figures are measured against the tool array as an MCP client hands it to you —
{name, description, inputSchema}. Our own benchmark fixture addsns/opfields for grouping, and counting those inflated the baseline by 4,993 characters, which is why this note used to say 46.8%.We tried making it a token estimate in 0.2.7 and reverted it in 0.2.8. Providers charge a fixed framing cost per tool definition that character counting cannot see: at 149 tools it amortises away, at 2 tools it dominates, and the calibrated estimate was off by 44% on a small level-1 block while being within 1% at scale. The plain character ratio is the smaller and more predictable error.
Use it as a local signal. For anything you publish, measure with your provider's token counter.
originalCharsandcompressedCharsare onstatsfor the raw counts.A negative value is possible and correct. Level 1 pays for a signature line and gets paid by the prose it strips, so on tools whose descriptions are already terse it comes out ~15% larger —
recommendLevelreturns 0 in that case rather than recommending it. Level 0 reports −0.6% if you pass MCP-styleinputSchema, becausec.toolsis Anthropic-shaped andinput_schemais one character longer; hand itinput_schemaalready and it reports a true 0%. Structural either way, not waste. |encodeCallForTest(name, args)|→ {name, args}| build the raw call a model would emit; test aid |
Check ambiguousMapLines before you ship level 3
At level 3 the map is the only thing the model has to choose from. If many of your tools
render to the same line, the tool name becomes the sole signal — and a name is a weak
signal when fifteen of them start with manage_.
const c = compress(myTools, { level: 3 });
console.log(c.stats.ambiguousMapLines, c.stats.largestLookalikeGroup);A team ran this against a live 60-tool registry where 19 tools are named manage_* and 15
take a single required operation:
| | ambiguous lines | largest group | tokens |
|---|---:|---:|---:|
| name+required | 44/60 | 24 | 1,279 |
| signature | 2/60 | 2 | 5,521 |
Asked to attach a note to "table tbl_ord", the model keyword-matched table to
manage_table — 3 times out of 3 — because the map had deleted every other distinction.
Switching to signature fixed it: 0 wrong picks, 0 malformed arguments, and lookups
per task fell from 1.00 to 0.33. The map costs 4,242 more tokens and is still 91% under
their uncompressed baseline and ~4× smaller than level 1.
So: a high number is a prompt to reach for signature, not a defect. Zero means every
line is distinct and name+required is doing its job. Absent below level 3.
Two things to know before you rely on level 3
Nothing schema-encoded reaches the model. The wire carries t(f, a) and q(c, s) with
generic argument objects — no parameter name from your registry appears anywhere in them.
Your schema is used by toolgz for validation, and the model only ever sees it through a
q() expansion. So a contract expressed in required[], in a conditional, or in a
parameter description is invisible at level 3. Behavioural policy belongs in your
system prompt, which is the one channel that survives every level.
Your audit trail must read resolve() output, not the raw request. Real tool names
never hit the wire at level 3. A log that records what you sent the provider shows t and
q and opaque codes; resolve() hands back the real name and byte-identical arguments.
Log from there.
Level 4 — tools compiled to Python
Measured over 180 live runs on four frontier models: every task completed, zero hallucinated tool names, one recovered malformed argument. Round 8 has the numbers.
Levels 0–3 derive the map from your schema, so they can only rearrange information that is already there. Level 4 has a model rewrite the corpus first, so the map carries what each tool is for:
def append_to_article(article_id,section_title,content,append_reason,confidence):"add new end section to article, keeping existing text; prefer over update_article for additions"Bring your own model. compileTools() takes a complete function; the library never
imports an SDK and never sees a key.
const { compiled } = await compileTools(myTools, {
complete: async ({ system, user }) => yourClient.run(system, user),
});
const c = compress(myTools, { level: 4, compiled });Or from the command line — it uses fetch, so it installs nothing either:
npx toolgz compile --tools ./tools.json --out ./toolmap.jsonEvery line is verified against your real schema before it is accepted: no renamed tool, no
invented parameter, no dropped required parameter. Failures are retried once and then
discarded, falling back to a bare signature line counted in stats.uncompiledTools.
Staleness is caught without any bookkeeping, because the schema is the fingerprint:
every compiled line is re-verified against the live tool at compress() time. A map
compiled before a tool gained a parameter would otherwise show the model a signature that
no longer exists — instead the line is dropped, listed in stats.staleCompiledTools, and
replaced with a correct one. stats.orphanedCompiledEntries counts entries for tools that
are gone. Set requireCompiled: true in CI or at startup to make a partial map throw
rather than degrade.
compileTools also returns danglingReferences — docstrings that point the model at a
tool not in your corpus. Compiling a deliberately-wrong corpus produced "call
compress_output first if large" for a tool that does not exist; this is what catches
that. Advisory, not fatal.
Measured on the 149-tool corpus: 12,441 tokens, 81.8% under uncompressed, zero ambiguous map lines. Full write-up and the honest risks in docs/EXPERIMENT-tools-as-code.md.
recommendLevel(tools, namespaceOf?) → Recommendation
{ level, reason, toolCount, namespaceCount, opsPerNamespace }. Returns 0, 1 or 3 — never
2, and never 4.
It does not return 4 because level 4 needs a compiled artifact you may not have, and a
recommendation you cannot act on is not a recommendation. Instead, when it recommends 3
and that map would be mostly lookalike lines, the reason says how many and points at
npx toolgz compile.
level: 0 means this library has nothing to offer your tool set. Level 1 would
inflate it — terse descriptions leave no prose to strip, so the signature line is pure
addition — and the block is too small for level 3's trade to be worth it. Telling you that
is more useful than recommending a transform that makes the payload bigger.
It advises; it does not act. Pass the answer back in yourself —
compress(tools, { level }). Calling compress(tools) alone is level 1 regardless of
how large tools is.
The decision is on the size of the level-1 block (threshold 10,000 tokens ≈ 5% of a
200k window), not on toolCount. The three shape fields are reported for your own
logging; only block size drives the level.
Provider adapters
| Adapter | Endpoint | Tool shape |
|---|---|---|
| forAnthropic(c, { cache?, ttl? }) → { tools, system } | Messages API | Anthropic native; places one cache_control breakpoint |
| forOpenAI(c) → { tools, systemPreamble } | /v1/chat/completions | nested {type, function:{…}} |
| forOpenAIResponses(c) → { tools, systemPreamble } | /v1/responses | flat {type, name, …} — required for tools + reasoning |
| forGemini(c) → { tools, systemPreamble } | generateContent | one functionDeclarations array |
Also exported, all experimental and branch-only: compileTools(tools, { complete }) →
{ compiled, rejected, stats }, verifyCompiledLine(line, tool), describeForCompile(tool)
and COMPILE_SYSTEM_PROMPT(maxDocChars).
Also exported: recommendLevel(tools) → { level, reason }; selectMapStyle(options)
→ { mapStyle, requestedMapStyle?, fallbackReason? } (pure, so you can see a pick
without compressing); and POLICY, BROKEN, CONSERVATIVE_DEFAULT — the measured
table, so a style choice is never a black box.
Renderers (exported for tooling)
signatureLine(tool, nameOverride?)→"name(a,b?:x|y)"flattenSchema(schema)→ schema with prose and boilerplate removedcountSchemaTokensApprox(value)→ character-count proxy, not a token count
Measured results and their limits: RESULTS.md. Methodology and repo conventions: ../AGENTS.md.
Providers
Why forGemini returns one tool where the others return two
It isn't sending less. Gemini's API nests all function declarations inside a single
tool object — [{ functionDeclarations: [...] }] — where Anthropic and OpenAI take a
flat array of tools. At level 3 the two dispatcher tools (t and q) are both present;
they are just both inside that one wrapper. Count tools[0].functionDeclarations.length,
not tools.length.
forGemini repairs three schema forms Gemini rejects
Gemini rejects the whole request if any single declaration is invalid, so one non-conforming tool anywhere in your catalogue breaks every call. Three forms occur in real MCP servers, and the adapter repairs all three:
| Form | Found in the real corpus | Repair |
|---|---|---|
| array with no items | 7 of 149 tools | adds items: {} |
| enum on a non-string type | {type:"number", enum:[1,2,3]} | drops the enum, keeps the type |
| union type ["string","array"] | 1 tool | takes the first type |
The dropped enum is not a lost constraint. validateArgs checks arguments against
your original schema before dispatch at every level, so an out-of-range value is still
caught — the check moves from provider-side to library-side. We deliberately do not coerce
a numeric enum into a string enum: Gemini would accept it, and the model would then send
"1" where your API wants 1, turning a caught error into silent bad data.
All 149 tools in the committed corpus pass Gemini at levels 0–3.
import { forAnthropic, forOpenAI, forOpenAIResponses, forGemini } from "toolgz";Pure functions; they never mutate what you pass them.
| Adapter | Endpoint | Handles |
|---|---|---|
| forAnthropic | Messages API | Places one cache_control breakpoint; skips deferred tools, which the API rejects |
| forOpenAIResponses | /v1/responses | Flat tool shape. Required if you want tools and reasoning |
| forOpenAI | /v1/chat/completions | Nested tool shape |
| forGemini | generateContent | One functionDeclarations array |
xAI is OpenAI-compatible — use forOpenAI with baseURL: "https://api.x.ai/v1".
What this does not do
- The size of the cost saving is not the size of the token saving. Measured 62–78% cheaper on three providers but only 7% on OpenAI, where reasoning output dominates the bill. The claim is context-window occupancy; cost follows, by a variable amount.
- It does not beat Anthropic's native tool search on tool-block size. It composes with
it, works where there is no equivalent, and is more reliable below the frontier tier —
defer_loadingcompleted only 6/30 tasks on Haiku 4.5, silently, because it lets the model choose whether to discover tools. A dispatcher makes discovery the entry point. - It has not been measured on a non-frontier model at level 3. On Haiku 4.5, argument errors rose sharply (17 of 30 runs) — all caught and retried, no task lost, but that is the known edge.
- It is not magic on a small tool block. Under ~10,000 tokens at level 1 there is
little worth reclaiming, and
recommendLevel()will say so and keep you on level 1. That is usually a small number of tools, but not always — it depends on how verbose your schemas are, not on the count. - A compiled level-4 map cannot be checked for truthfulness. Verification covers the contract — no renamed tool, no invented parameter, no dropped required parameter — and `dangl
