Skip to content

Emit columnar JSON for row-shaped agent output to cut token usage #318

Description

@V3RON

Summary

Agent-facing CLI output currently emits row-keyed JSON, which repeats every field name on every row. For the row-shaped payloads that dominate agent usage (tree nodes, request lists, storage keys, domains, tools) this is the single largest avoidable cost in the output path.

Proposal: encode row-shaped results columnar — a cols header plus positional rows — instead of an array of objects. Measured up to 65% fewer bytes on narrow-valued tables.

This is independent of transport (CLI, SDK, or a future MCP server) and can ship on its own.

Current output

printOutput in packages/cli/src/commands/agent/output.ts always emits JSON (the -j/--json flag is currently a documented no-op). Listings are wrapped in {items, page} by paginateRows in output-shaping.ts.

{"items":[{"nodeId":1,"label":"App","displayName":"App","elementType":"function","childCount":1},{"nodeId":4,"label":"NavigationRoot","displayName":"NavigationRoot","elementType":"function","childCount":2,"parentId":1}],"page":{"limit":20,"hasMore":false}}

Proposed output

{"cols":["nodeId","label","displayName","elementType","childCount","parentId"],"rows":[[1,"App","App","function",1,null],[4,"NavigationRoot","NavigationRoot","function",2,1]]}

Spaced out for readability:

{
  "cols": ["nodeId", "label", "displayName", "elementType", "childCount", "parentId"],
  "rows": [
    [ 1, "App",            "App",            "function", 1, null],
    [ 4, "NavigationRoot", "NavigationRoot", "function", 2,    1]
  ]
}

Nested values remain first-class — a cell can hold an object or array, so --verbose selections that include context or argsPreview need no special handling:

{
  "cols": ["seq", "timestamp", "level", "source", "context", "text"],
  "rows": [
    [1, 1730000000000, "info", "console", null, "App mounted"],
    [2, 1730000000100, "error", "exception",
      {"stack": "App.tsx:42", "componentStack": "ProductList"},
      "Warning: Each child in a list should have a unique \"key\" prop.\n    in ProductList (at App.tsx:42)"]
  ]
}

Payloads that are not row-shaped (react.getComponent, network.getRequestDetails, acks) stay exactly as they are. Compact JSON is already at the size floor for nested data — measured alternatives were worse: YAML-style indentation +3%, pretty-printed JSON +75%.

Measurements

Minified byte counts, comparing {items:[...]} against {cols,rows} for the same records:

rows tree nodes (short values) console messages (one long text field)
1 −7% −4%
2 28% 9%
5 51% 18%
10 59% 20%
25 63% 22%
50 64% 23%
100 65% 23%

Two conclusions:

Single-row results get worse. The cols header costs more than one row of field names saves. Columnar should only kick in at 2 or more rows; single-row results stay row-keyed.

The saving is very uneven, and depends on the ratio of field-name bytes to value bytes. Tree-node rows are short integers and identifiers, so names dominate and the saving approaches 65%. Console rows carry one long text field that dwarfs its keys, so the saving plateaus around 23%. Expect the headline number on getTree / searchNodes / listRequests / list-entries, and roughly a quarter of it on console-heavy work.

Related: for console specifically, the larger lever is projection, not encoding. --fields / --verbose exist for the domain and tool listings (parseFields in output-shaping.ts) but not for tool call results, which are the large payloads. Trimming default fields on getMessages would save more than the encoding change does. Worth doing alongside, but tracked as its own concern.

Also in scope: drop the page envelope

paginateRows emits {"page":{"limit":20,"hasMore":false}} even when there is nothing more to fetch — pure overhead on every terminal page.

Proposal: omit page when hasMore is false, and when it is true replace the bare cursor with the next command. Agents copy commands more reliably than base64 tokens, and the cursor is only tamper-resistant against a caller who has no reason to tamper.

"next": "rozenite agent call console.getMessages --cursor eyJ2IjoxLCJzZXEiOjJ9"

Determinism requirement

Whatever the selection rule ends up being, the output shape must be a function of (tool, selected fields, row count) and never of the observed values. If it depended on whether any record happened to carry a nested field, the same command with the same flags would return different shapes depending on what the app logged — which is far more confusing to a consumer than either shape on its own.

Suggested scope

  • Add a columnar encoder to output-shaping.ts, applied at ≥2 rows
  • Leave non-row payloads untouched (no envelope, no re-encoding)
  • Omit page when hasMore is false; emit next as a runnable command when true
  • Repurpose the currently no-op --json flag to mean "row-keyed / expanded shape" as an escape hatch
  • Update agent-output-format.test.ts and agent-output-shaping.test.ts
  • Update skills/rozenite-agent/SKILL.md and domains/*.md examples to the new shape
  • Document which shape is a stable contract and which may change

Compatibility

Nothing first-party parses CLI stdout — @rozenite/agent-sdk talks HTTP directly via transport.ts, so it is unaffected.

However, this does change the default stdout shape, which is a breaking change for anyone parsing it today. Two options worth discussing:

  1. Ship columnar as the new default and give --json its meaning back as the expanded/legacy shape (single release, breaking)
  2. Introduce the columnar shape behind an opt-in flag first, then flip the default in a later major

Notes

Filed without a label — this is a performance/output-format change and the repo has no matching label.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions