DEV Community

Fernando Rodriguez
Fernando Rodriguez

Posted on • Originally published at frr.dev

Why My CLI Output Isn't XML (And How I Ended Up Reinventing TOON Without Knowing It)

TL;DR: When your primary consumer is an LLM, XML and JSON waste tokens by repeating structure in every element. A compact positional format reduces consumption by 50%. Turns out this idea already had a name: TOON (Token-Oriented Object Notation). Same selective pressure — expensive tokens and repeated keys — same solution.


Anthropic uses XML for everything. Their system prompts are wrapped in <instructions>, their examples in <example>, their tools in <function>. If you work with Claude, you live surrounded by tags.

So when I asked Claude to design the output for a CLI meant to be consumed by LLMs, the obvious temptation was: XML. If the model gets information in XML, shouldn't the CLI return it in XML?

Turns out, no.

The problem: compulsive repetition

Imagine your CLI lists issues from a project tracker. You have 50 issues. In XML, each one looks like this:

<issue>
  <id>PROD-587</id>
  <state>Backlog</state>
  <labels>backend</labels>
  <title>Import sessions from NAS backup</title>
  <age_days>14</age_days>
</issue>
Enter fullscreen mode Exit fullscreen mode

Nice. Self-describing. Each field has its name. An XML parser knows exactly what each thing is.

Now multiply that by 50 issues. The tags <issue>, <id>, <state>, <labels>, <title>, <age_days> repeat 50 times each. That's 300 tags that provide no new information after the first repetition. Roughly 70 tokens per issue, 3,500 tokens to list 50 issues.

JSON improves things somewhat — it eliminates closing tags — but still repeats keys:

{
  "id": "PROD-587",
  "state": "Backlog",
  "labels": ["backend"],
  "title": "Import sessions from NAS backup",
  "age_days": 14
}
Enter fullscreen mode Exit fullscreen mode

Each line repeats "id":, "state":, "labels":, "title":, "age_days":. About 50 tokens per issue, 2,500 for all 50.

What if we eliminate all the repetition?

PROD-587 [Backlog] backend — Import sessions from NAS backup (14d)
Enter fullscreen mode Exit fullscreen mode

25 tokens. No keys. No tags. No braces or quotes. 1,250 tokens for 50 issues.

That's half of JSON, less than a third of XML. For the same thing.

"But an LLM needs structure"

This is where people look at me funny. "How does the LLM know what each field is?"

Valid question. And the answer is: the same way you know.

Look at this line:

PROD-587 [Backlog] backend — Import sessions from NAS backup (14d)
Enter fullscreen mode Exit fullscreen mode

Do you need someone to tell you that PROD-587 is the ID? That [Backlog] is the state? That what comes after the em dash is the title? No. You deduce it from position and visual formatting.

LLMs do exactly the same thing. They're pattern recognition machines for text. A consistent positional format — ID first, state in brackets, loose labels, title after the dash, metadata in parentheses — they understand it immediately. They don't need <state>Backlog</state> to know that "Backlog" is a state.

The key is distinguishing two operations that seem identical but aren't:

READING is what an LLM does. It has context, understands semantics, infers structure. A human reading a report doesn't need every word tagged — they understand through position and convention.

PARSING is what a program does. No context, no semantic understanding, needs explicit delimiters to extract fields. A jq '.state' needs the "state" key because it doesn't know what a state is.

XML and JSON are designed for parsing. They're interchange formats between machines that don't understand content. LLMs read. Explicit structure is redundant for them — and that redundancy costs tokens.

The spectrum: when to use what

I'm not saying XML and JSON are bad. They're bad for this use case. Here's the spectrum:

Format Tokens/issue Self-describing Best for
XML ~70 Total SOAP APIs, configs, schema documents
JSON ~50 Total REST APIs, service interchange
JSONL ~50 Total Scripts, jq, data pipelines
Positional ~25 No LLMs, humans, compact dashboards

The rule is simple: if the consumer understands context (human, LLM), you can eliminate explicit structure. If the consumer doesn't understand context (script, parser), you need keys.

That's why my CLI has two modes: the compact positional format by default (for the LLM and for you in the terminal) and --json for when you need to pipe the output through jq or process it with a script.

The twist: someone had already thought of this

Here's the good part.

A few weeks after adopting the format Claude had proposed, someone asked me: "Have you looked at TOON?"

TOON — Token-Oriented Object Notation — is a format that appeared in November 2025. Its premise: a compact encoding of the JSON data model, designed specifically to minimize tokens in LLM prompts.

What does it look like? Like this:

issues[3]{id,state,labels,title,age_days}:
 PROD-587,Backlog,backend,Import sessions from NAS backup,14
 PROD-612,Todo,tokamak,Fix auth token refresh,2
 PROD-501,Done,frontend,Migrate database to new schema,30
Enter fullscreen mode Exit fullscreen mode

Notice. A header with the fields ({id,state,labels,title,age_days}) and the number of elements ([3]). After that, just positional data separated by commas. No repeating keys. No tags. No quotes on strings.

It's exactly what Claude had proposed for my CLI — but with an explicit header that makes the format self-describing.

My CLI's version:

PROD-587 [Backlog] backend — Import sessions from NAS backup (14d)
PROD-612 [Todo] tokamak — Fix auth token refresh (2d)
PROD-501 [Done] frontend — Migrate database to new schema (30d)
Enter fullscreen mode Exit fullscreen mode

Essentially the same thing. Positional format, no key repetition, ~25 tokens per line. The difference: TOON adds a schema header; the format I chose for my CLI uses visual conventions (brackets for state, em dash to separate the title).

Convergent evolution

In biology there's a beautiful concept: convergent evolution. Unrelated species develop similar traits because they face the same selective pressure. Octopus eyes and human eyes are structurally similar, but evolved completely independently. Same pressure — need to see — same solution.

TOON and my CLI's format are convergent evolution applied to software design. The selective pressure is identical: expensive tokens, repeating keys, consumer that understands by position. The convergent solution: eliminate repetition and trust in order.

The difference between TOON and what my CLI does is the header. And that difference matters.

Without a header, the format works well when the LLM already has context about what each field is (because I've told it in the system prompt or because the pattern is obvious). But if someone sees the output for the first time, they have to guess. TOON solves that: the header says once what fields there are, and then the LLM doesn't need it repeated.

It's the difference between an implicit contract and an explicit contract. And in engineering, explicit contracts usually win in the long run.

Should I migrate to TOON?

I've asked myself this. And the honest answer is: it depends on who consumes your output.

If your CLI is always consumed by the same agent with the same system prompt that describes the format, the positional format without header works well. It's more compact (you save the header line) and the context is already given.

If your CLI can be consumed by different agents or humans without prior context, TOON is better. One extra header line is a marginal cost that buys self-description.

In my case, the consumer is always the same agent with a CLAUDE.md that describes the format. So I'm sticking with my positional hack. But if I packaged the CLI for public use, I'd migrate to TOON without hesitation.

What I learned

Three things:

1. Don't design for parsers if your consumer isn't a parser. XML and JSON are great for machines that need explicit keys. LLMs aren't that machine. Design for how your consumer processes information, not for how you think it should process it.

2. Repetition is the enemy. In a list of 50 elements, every key that repeats 50 times is informational garbage. It's like putting "Name:" in front of every name on a shopping list. After the second one, your brain stops reading it — but it still takes up space.

3. If your solution converges with something that already exists, you're probably on the right track. I'm not saying you should always reinvent the wheel. I'm saying that if it comes out round — whether you propose it or your tool does — it's a good sign.

The next time you design output for a tool that an LLM will consume, ask yourself a question before reaching for serde_json::to_string(): does my consumer need to parse this, or just read it?

If the answer is "read it," every repeated key is a token you're burning for nothing. And tokens, like money at the bar, go faster than you think.

Top comments (0)