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
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:
- Ship columnar as the new default and give
--json its meaning back as the expanded/legacy shape (single release, breaking)
- 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.
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
colsheader plus positionalrows— 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
printOutputinpackages/cli/src/commands/agent/output.tsalways emits JSON (the-j/--jsonflag is currently a documented no-op). Listings are wrapped in{items, page}bypaginateRowsinoutput-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
--verboseselections that includecontextorargsPreviewneed 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:Two conclusions:
Single-row results get worse. The
colsheader 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
textfield that dwarfs its keys, so the saving plateaus around 23%. Expect the headline number ongetTree/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/--verboseexist for the domain and tool listings (parseFieldsinoutput-shaping.ts) but not for tool call results, which are the large payloads. Trimming default fields ongetMessageswould save more than the encoding change does. Worth doing alongside, but tracked as its own concern.Also in scope: drop the page envelope
paginateRowsemits{"page":{"limit":20,"hasMore":false}}even when there is nothing more to fetch — pure overhead on every terminal page.Proposal: omit
pagewhenhasMoreis 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.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
output-shaping.ts, applied at ≥2 rowspagewhenhasMoreis false; emitnextas a runnable command when true--jsonflag to mean "row-keyed / expanded shape" as an escape hatchagent-output-format.test.tsandagent-output-shaping.test.tsskills/rozenite-agent/SKILL.mdanddomains/*.mdexamples to the new shapeCompatibility
Nothing first-party parses CLI stdout —
@rozenite/agent-sdktalks HTTP directly viatransport.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:
--jsonits meaning back as the expanded/legacy shape (single release, breaking)Notes
Filed without a label — this is a performance/output-format change and the repo has no matching label.