# TOON format adoption ## Problem MCP tool results land in the LLM context window. Most tools wire JSON-encoded payloads (Actor runs, dataset listings, KV-store keys, dataset items). JSON repeats every object key on every row of an array. Context costs tokens and dilutes model attention, and once a tool result lands it stays in the conversation for every subsequent turn — the savings compound per request. ## TL;DR Switching the text content of certain MCP tool responses from JSON to [TOON](https://toonformat.dev/) (a more compact LLM-oriented format, introduced below) measurably reduces context bytes — and stacks massively with field projection on realistic agentic workloads. | Tool | Δ vs current JSON | Strategy that wins most often | |-----------------------------------|------------------:|--------------------------------| | `get-actor-run-list` | −44.0% | `toon-flatten` | | `get-dataset-list` | −31.6% | `toon-flatten` (mixed-schema: `json`) | | `get-key-value-store-list` | −31.3% | `toon-flatten` (mixed-schema: `json`) | | `get-key-value-store-keys` | −19.0% | `toon-flatten` | | `dataset-items` | −1.2% | depends on the Actor | | **Combined across 18 fixtures** | **−6.2%** | `toon-flatten` 10/18, `json` 8/18 | **Combined with field projection** (`fields=name,url,rating` on a 100-place Google-Maps result — see [Field projection benchmark](#field-projection-benchmark) below): **1.01 MB → 9.5 KB**, ~110× smaller payload entering the LLM context. Two guarantees make adoption safe: - **Never worse than today.** The mechanism is an *adaptive picker* that encodes both JSON and TOON per call and ships whichever is smaller. JSON is always a candidate. - **`structuredContent` is unchanged.** Only `TextContent.text` shifts. `outputSchema` validation, MCP widgets, and programmatic consumers continue to see the original JSON view. The rest of this document covers what TOON is, the measurement methodology, the picker's implementation and edge cases, and trade-offs. ## What TOON is [TOON](https://toonformat.dev/) (Token-Oriented Object Notation) encodes the JSON data model with one trick JSON lacks: **tabular form** for arrays of similarly-shaped objects. Keys go in a header line; each row is one comma-separated record: ``` items[5]{id,status,startedAt,usageTotalUsd}: toaYdmG…,SUCCEEDED,"2026-05-12T09:18:27.527Z",0.00008311 CkbooBH…,SUCCEEDED,"2026-05-12T09:17:57.206Z",0.00392022 … ``` JSON re-emits those four keys on every row. Dropping that repetition (plus the per-row `{}` and `:` punctuation) is where TOON's savings come from. Tabular form requires every array element to be a scalar-only object with the same keys. Nested objects, missing optionals, or inline arrays force a list-of-objects fallback that costs **more** bytes than JSON (indent overhead exceeds brace+quote overhead). Payload shape, not tool identity, determines whether TOON helps. ## What we evaluated 18 live Apify API fixtures across five tool groups: - `get-actor-run-list` (2) - `get-dataset-list` (2 real + 1 synthetic) - `get-key-value-store-list` (2 real + 1 synthetic) - `get-key-value-store-keys` (2) - `get-dataset-items` from 4 Actors: `apify/rag-web-browser`, `compass/crawler-google-places`, `apify/instagram-scraper`, `apify/instagram-post-scraper` (8 total) Sizes are measured in **UTF-8 bytes**, not LLM tokens. Tokens are tokenizer-specific and not reproducible across models; bytes are. We expect token savings to track byte savings on the kind of payloads in scope here, but the proposal's headline numbers are bytes only. Every encoding round-trips to a JSON-equal value; mismatches fail the run. > **Synthetic mixed-schema fixtures.** Dataset and KV-store list > responses include an optional `schema` field (user-defined nested > JSON Schema), present only on storages that declare one. The test > account had zero such storages — auto-generated storages don't carry > a schema — so real fixtures couldn't exercise heterogeneous-schema > rows. We generated additional fixtures with `schema` populated on > half the rows using realistic dataset/KV-store schema shapes to > verify the picker falls back to `json` correctly. --- ## Field projection benchmark The picker's wins compound with a `fields=` projection parameter on the tools that return rich items. Realistic agentic scenario: the agent asks a Google-Maps-style scraper for 100 places but only needs `name`, `url`, `rating` to summarise. | Encoding (100 places, ~60 fields each) | Bytes | vs current | |---|---:|---:| | Full JSON (current behaviour) | 1.01 MB | baseline | | Full TOON-flatten | 893 KB | −14.0% | | Projected JSON (`fields=name,url,rating`) | 12.3 KB | −98.8% | | **Projected + picker (`fields=` + TOON)** | **9.5 KB** | **−99.1%** | Projection alone is the dominant lever (−98.8%); the picker adds another −23% relative to the projected JSON baseline because the projected payload is exactly TOON's sweet spot (uniform 3-scalar-field rows). Combined: **~110× smaller payload entering the LLM context**. `fields=` is out of scope for this document and tracked separately. ## Scope In scope: tools whose text content today is a JSON encoding of structured data — `get-actor-run-list`, `get-dataset-list`, `get-key-value-store-list`, `get-key-value-store-keys`, `get-dataset-items`, `get-actor-output`, `get-dataset`, `get-key-value-store`, etc. Out of scope: - Tools that already ship non-JSON text content (Markdown cards, plain log output, etc.) — adopting TOON there would change the rendered shape, not just the encoding. Separate decision. ## The picker At every in-scope tool-call site, the picker encodes both candidates and ships the smaller. JSON is always present; any other candidate is wrapped in `try`/`catch` and dropped on failure — JSON is the guaranteed fallback, so no tool call ever fails because of the picker. | Candidate | What it does | |-----------------|-----------------------------------------------------------------------------------------| | `json` | Current behaviour — `JSON.stringify` of the structured payload | | `toon-flatten` | `encode(dotFlatten(payload))` — unlocks TOON tabular form for uniform-key nested objects | ```ts const json = JSON.stringify(payload); const candidates: { name: string; text: string }[] = [{ name: 'json', text: json }]; try { candidates.push({ name: 'toon-flatten', text: encodeToon(dotFlatten(payload)) }); } catch { // Any failure in dotFlatten or encodeToon — silently drop the candidate. // The picker still ships JSON; no tool call ever fails because of this. } const best = candidates.reduce((a, b) => Buffer.byteLength(b.text, 'utf8') < Buffer.byteLength(a.text, 'utf8') ? b : a); return { content: [{ type: 'text', text: best.text }], structuredContent: payload, // unchanged _meta: { 'com.apify/encodingStrategy': best.name }, // per-tool win-rate telemetry }; ``` ## The `flatten` transform Nested objects defeat TOON's tabular form. Dot-flatten lifts the nested keys into the top-level row, restoring the tabular precondition. ### Semantics ``` MAX_DEPTH = 20 flatten(value, depth = 0): if depth > MAX_DEPTH: throw new RangeError('flatten: max depth exceeded') if value is an array: return value.map(v => flatten(v, depth + 1)) if value is a non-null object: return flattenObject(value, '', depth) otherwise (scalar / null): return value // unchanged flattenObject(obj, prefix, depth): out = {} for (k, v) in obj: safeKey = k.replace('.', '_') // see Edge cases key = prefix ? prefix + '.' + safeKey : safeKey if v is a non-null, non-array object: merge flattenObject(v, key, depth + 1) into out // recurse, dot-prefix else: if key already in out: throw RangeError // collision guard out[key] = flatten(v, depth + 1) // scalars unchanged; arrays recursed into return out ``` Key behaviours: - Arrays at any level are recursed into; each array stays an array, its object elements are flattened individually. This is what lets TOON emit a tabular header for a list response. - Inline arrays of scalars (`tags: ['a','b']`) are kept as-is on the flattened key; TOON encodes them inline. - Arrays of objects nested under an object key (`pricingInfos: […]`) are kept under that key; their elements are flattened but the array is preserved — TOON falls back to indented form for that sub-array. - Scalars (string / number / boolean / null) are returned unchanged. ### Edge cases and protection - **Bounded recursion.** The `MAX_DEPTH` guard prevents runaway recursion on pathological inputs — throws `RangeError`, the picker's `try`/`catch` drops the `toon-flatten` candidate, and `json` ships. `20` is comfortable: the deepest real fixture measured is depth 9 (a user-declared dataset schema), giving a margin of 11. - **Keys containing literal `.`** — source keys with literal dots would collide with the nesting separator. Resolved by normalising `.` → `_` in source keys before flatten. The mapping is one-way LLM-readable; `structuredContent` (untouched) preserves the original keys for programmatic consumers. - **Normalisation collisions** — if normalisation would produce a collision (e.g. a payload with both `'a.b'` and `'a_b'` as keys at the same level), flatten throws `RangeError`, the picker drops the `toon-flatten` candidate, and `json` ships. No silent data loss is possible. Apify API responses don't use such keys today, but the guard makes flatten safe if that ever changes. ### Example Input rows with a uniform nested shape: ```js [ { id: 1, stats: { runs: 10, reads: 5 } }, { id: 2, stats: { runs: 20, reads: 8 } }, { id: 3, stats: { runs: 30, reads: 12 } }, ] ``` Dot-flatten produces two dotted columns; TOON declares them once in the tabular header: ``` items[3]{id,stats.runs,stats.reads}: 1,10,5 2,20,8 3,30,12 ``` ### When it wins, when it doesn't Flatten wins when rows share the same nested key set (Actor runs, storage list items, uniform-schema dataset items). It does not help in two distinct cases: - **Nested shapes vary per row** — e.g. `schema`-bearing dataset/KV-store mixes. Flatten explodes the column count, the JSON candidate beats it, and the picker falls back to `json`. - **Prose-dominated payloads** — e.g. `apify/rag-web-browser`, Instagram captions. Flatten works fine but the savings are negligible: key overhead is a tiny fraction of total bytes when the rows are mostly prose. Lossless: the consumer recovers the original structure by un-flattening dotted keys. Round-trip verified on all 18 fixtures. > **An alternative transform we tested and dropped.** We also evaluated > a `stringify` transform that collapses each row's non-scalar fields > to a JSON string — the dual of flatten. It never won the picker on > any in-scope fixture, so a third candidate would have grown the > implementation without affecting the output. ## Why adaptive over a static per-tool choice `dataset-items` returns whatever shape the chosen Actor produced — the MCP server has no idea ahead of time whether the rows will be uniform structured data or unstructured prose. Four sampled Actors show the picker splitting between `toon-flatten` and `json` based purely on payload shape: | Actor | Adaptive picks | Why | |------------------------------------|------------------|------------------------------------------------------------| | `compass/crawler-google-places` | `toon-flatten` | Uniform-row places with scalar fields → −14% | | `apify/rag-web-browser` | `json` | Prose-dominated; format-neutral → ±0% | | `apify/instagram-scraper` | `json` | Heterogeneous nested fields + captions → +3.5% if forced | | `apify/instagram-post-scraper` | `json` | Same — caption-heavy → +2.3% if forced | Any static rule (per tool, per Actor) would have to guess the shape of the next call. The adaptive picker doesn't guess — it encodes both and picks the smaller — at the cost of one extra `encode()` per response. ## Use in Code Mode ([PR #794](https://github.com/apify/apify-mcp-server/pull/794)) [Code Mode](https://github.com/apify/apify-mcp-server/pull/794) introduces a `code` MCP tool category where the agent submits a TypeScript program that orchestrates Apify resources through typed bindings inside a sandboxed `workerd` isolate. Most intermediate results never enter the LLM context — only what the submitted code prints (via `console.log`) shows up in `content[0].text`. TOON shrinks results that flow through; Code Mode skips most of them entirely. The two compose well by exposing the picker's encoder as a binding helper: ```ts apify.stringifyCompact(value: unknown): string // Returns min(json, toon-flatten) of `value` as a string. // Mirrors JSON.stringify, but ships whichever encoding is smaller. // Use before console.log when the agent needs to surface structured // data to the caller LLM. ``` Typical usage in agent-submitted code: ```ts const places = await apify.dataset.listItems({ datasetId, fields: ['title', 'url', 'totalScore'], limit: 100, }); console.log(apify.stringifyCompact(places)); ``` Any implementation of the picker should expose its encoder via a reusable helper so Code Mode (and any future caller) can adopt it without duplication. ## Trade-offs ### Pros - **−6.2%** bytes combined across the 18 fixtures; the four list endpoints save **19–44%**. - Single universal recipe. No per-tool field allowlists, no shape-specific helpers. - Bounded by construction — `json` is always a candidate; picker never ships larger than today. Defensive `try`/`catch` means a buggy encoder downgrade never fails a tool call. - `structuredContent` and `outputSchema` are untouched; only `TextContent.text` shifts. - Self-tuning — no static config drifts as Actor outputs evolve. - `_meta.com.apify/encodingStrategy` enables per-tool win-rate telemetry. ### Cons - Extra encode cost — one `encode()` + one `JSON.stringify` per response, plus a byte comparison. Measured on synthetic workloads: **+0.7 ms** for a 100-item payload at our deepest real-data depth, **+1.6 ms** for a 100-item payload at chain-depth 20 (well beyond anything observed in production). Under 1% of typical tool-call latency. - LLM behaviour change unmeasured. JSON-trained LLMs may underperform on TOON. Quantify via `evals/workflows/` (master vs branch diff on `results.json`) before merge. - Savings concentrate on the four list endpoints; `dataset-items` averages near zero. Worthwhile because of *where* it helps, not uniform wins. --- ## Verified - **MCP spec (`2025-11-25`).** `TextContent.text` accepts any string. `structuredContent` and `outputSchema` are untouched. Spec-compliant clients pass `text` through to the model as-is; no client changes needed. ## Before merging an implementation 1. Run the workflow regression eval. Gate merge on no LLM task-success regression in `evals/workflows/results.json` vs master. 2. Confirm encode overhead under production load. ## Out of scope - Token-count measurement (tokenizer-dependent; bytes are the reproducible proxy). - Per-tool static encoding decisions (adaptive replaces them). - Changes to `structuredContent` or `outputSchema`. ## References - TOON: · - MCP spec: