Skip to main content
glnc

Field notes

Designing a stable, versioned JSON envelope for a CLI (and why --watch emits NDJSON)

by Arya Rahimi · · 8 min read

To give a command-line tool a real API contract, wrap every --json line in one consistent envelope: a versioned schema id, a timestamp, an ok boolean, a data payload, an error slot, and a meta block. Pretty human output is for people; it is not something a script can depend on. This note is how glnc actually does it, including the tradeoffs I would defend: NDJSON for streams, stdout kept data-only, and a single honesty flag a consumer can gate on before it acts.

The problem: pretty output is not a contract

Most CLIs grow a --json flag the same way: someone needs to script the tool, so the maintainer reaches for the nearest serializer and prints whatever struct happened to be in scope. It works on the first machine. Then a later release renames a field, or an error path prints a string where a number used to be, or a warning leaks onto stdout and a jq filter chokes. Nothing announced the change. The consumer just breaks, quietly, in production.

The fix is to treat machine output as an interface and not a side effect. The Command Line Interface Guidelines put it plainly: a program is also a building block, so its output should be predictable for other programs. Predictable means the same shape every time, a way to tell success from failure without parsing prose, and a version number so a breaking change is visible before it bites. glnc commits to that with one envelope, used by every command.

The envelope: one shape for every line

Every JSON or NDJSON line glnc emits has the same outer shape. The payload differs per command; the wrapper never does.

glnc balance vitalik.eth --json | jq .
{
"schema": "glnc.balance/v1", // <command>/<version>
"ts": "2026-06-28T12:34:56.789Z",
"ok": true,
"data": { /* command-specific payload */ },
"error": null,
"meta": { /* freshness + source attribution */ }
}

Each field earns its place. schema is a stable id of the form <command>/<version>, so a consumer can branch on exactly what it is reading. ts is a UTC ISO-8601 timestamp set at emit time, which matters most for NDJSON where each line is its own event. ok is the one-glance verdict: true or false, no string parsing. data carries the actual answer, and on failure it is null while error holds { code, message }. Putting errors in their own slot is the quiet win here: a failed call can never be mistaken for a real zero, because ok already said so and data is empty.

glnc balance not-a-real-address --json | jq "{ok, data, error}"
{
"ok": false,
"data": null,
"error": {
"code": "input", // stable string, not a localized sentence
"message": "could not detect a chain for that address"
}
}

The exact strings above are illustrative, but the discipline is real: a script reads .ok first, and only then trusts .data. Exit codes back this up at the process level (0 success, 1 input error, 2 all requests failed), so a pipeline can short-circuit before it even reaches jq.

Versioning discipline: additive within vN

The version in the schema id is a promise, and the value of a promise is in what it forbids. Within a given vN, new fields are additive. glnc can ship an extra key in data or meta and a consumer that does not know about it keeps working, because adding an optional field never breaks code that ignores unknown keys. The version number bumps only on a breaking change: an existing field changing meaning, type, or going away.

This is the same spirit as Semantic Versioning, scoped to a payload rather than a package. Additive growth is a minor change that needs no action from anyone; a breaking change earns a new major, which here means glnc.balance/v2 living alongside v1 rather than silently mutating it. A consumer that pinned to glnc.balance/v1 can keep parsing exactly what it parsed the day it was written.

Why streams are NDJSON, not a JSON array

The moment you add --watch, a single document is the wrong container. A long-poll that re-checks a wallet every fifteen seconds produces an open-ended sequence of results, and the natural temptation, a big top-level array, is a trap: an array is only valid JSON once its closing bracket arrives, and in a live stream that bracket never comes. A consumer would have to buffer forever and parse nothing.

So under --watch the output becomes NDJSON: one complete, self-contained envelope per line, terminated by a newline. This is the same line-delimited convention described at jsonlines.org. Each line stands alone, so jq, xargs, a while read loop, or a cron-driven pipe can react to poll three without waiting on poll four.

glnc balance 0xd8dA... --watch --interval 30 --json | jq -c --unbuffered "select(.event==\"poll\")"
{"schema":"glnc.balance.watch/v1","ts":"...","ok":true,"event":"poll","poll":0,...}
{"schema":"glnc.balance.watch/v1","ts":"...","ok":true,"event":"poll","poll":1,...}
{"schema":"glnc.balance.watch/v1","ts":"...","ok":true,"event":"poll","poll":2,...}
# Ctrl+C: {"event":"stop","reason":"sigint"}

Streams get their own schema ids (glnc.balance.watch/v1, glnc.gas.watch/v1) and an extra event field (poll, error, stop), so a single failed poll is just one {"event":"error"} line in an otherwise healthy stream rather than a crash. If you want one-object-per-line on a non-watch command too, --ndjson forces it.

Keeping stdout data-only

A contract is worthless if the channel is noisy. When --json or --ndjson is set, glnc keeps stdout data-only: every progress spinner, warning, and the watch-mode alternate screen goes to stderr or is suppressed. The stream a parser reads is nothing but envelopes.

glnc balance 0xd8dA... --json 2>/dev/null | jq .
# stdout: only the JSON document. stderr (the spinner) was
# routed away, so jq never sees a stray progress line.

The stdout-for-data, stderr-for-humans split is old Unix wisdom, and it is the difference between a pipe that works and one that randomly fails when a warning happens to print. Redirecting stderr is still worth doing in scripts: progress chatter is intentional, but it is not part of the API.

The honesty field: gating on meta.partial

The field I am proudest of is the least glamorous. glnc reads from public RPC endpoints, the CoinGecko price API, and the Uniswap token list, and any of those can degrade on a given call. A price feed times out, a token list falls back to a cached or hardcoded copy, one chain RPC fails. The naive move is to emit a smaller number and say nothing. That is how a flaky upstream gets mistaken for a real drained wallet, and how automation fires on a lie.

So meta records source attribution, and meta.partial consolidates it into one boolean: true if RPC, prices, or the token list degraded in any way for this call. The per-source detail is still there (cache age, rate-limit status, token-list fallback), but a script that just wants a go or no-go reads one field.

glnc balance 0xd8dA... --json | jq .meta
{
"sources": {
"rpc": { "ok": true, "chainsFailed": [] },
"prices": { "ok": true, "provider": "coingecko",
"cacheAgeSec": 12, "stale": false,
"rateLimited": false, "unpriced": [] },
"tokenList": { "ok": true, "source": "uniswap", "fallback": false }
},
"partial": false, "warnings": []
}

The rule for any script that mutates state (sends a page, moves funds, writes a row) is to check the flag first and bail when it is set:

glnc balance $WALLET --json | jq -e '.meta.partial == false' >/dev/null || exit 0
# partial data: jq -e exits non-zero, the || fires, we exit 0 quietly.
# clean data: the guard passes and the rest of the script runs.

For a hard stop at the process level rather than in a filter, --strict makes a partial one-shot exit 3 instead of 0. The full alerting version of this pattern, with a state file so you do not page on every poll, is in the Slack balance alert note.

Letting consumers validate the version they parse

A version id is only useful if a consumer can read it before committing to a payload. The schema id is right there in the envelope, so the cheapest check is a single jq read. glnc also ships a schema command so a script can discover every id the binary it is talking to actually emits.

glnc schema
glnc.balance/v1
glnc.balance.watch/v1
glnc.tx/v1
glnc.gas/v1
glnc.gas.watch/v1
glnc.alert/v1
glnc.history/v1

Pass a name (glnc schema balance) to print a single id. A defensive consumer asserts the version it was written against and fails loudly on drift, which turns a future v2 from a silent breakage into a one-line, obvious error:

cat check.sh
#!/usr/bin/env bash
set -euo pipefail
want="glnc.balance/v1"
got="$(glnc balance "$WALLET" --json | jq -r .schema)"
[[ "$got" == "$want" ]] || { echo "schema drift: got $got, want $want" >&2; exit 1; }

That is the whole contract in one assertion. The script states the version it understands; the binary states the version it speaks; a mismatch stops the pipeline instead of corrupting whatever is downstream.

The tradeoffs I would defend

None of this is free, and that is the point of writing it down. The envelope is verbose: a one-line answer is wrapped in five keys of ceremony, which is overhead for a human and noise to skim. NDJSON gives up pretty-printing, since the whole value is one object per line. The version promise is a constraint on me, not the user: once glnc.balance/v1 ships, I cannot rename a field without minting a v2 and carrying both. I take those costs on purpose. A CLI that other programs depend on is infrastructure, and infrastructure that changes shape without warning is worse than no infrastructure at all. The full field-by-field reference lives in the JSON and NDJSON docs, and the balance reference shows the payload this envelope wraps.

FAQ

Why not just print plain JSON from a CLI? Plain ad-hoc JSON is not a contract: fields drift, errors look like data, and consumers break silently. A consistent envelope with a versioned schema id gives downstream scripts a shape they can rely on and a way to detect breaking changes.

Why use NDJSON instead of a JSON array for streaming output? With --watch you want one self-contained object per line so tools like jq, xargs, and cron can react incrementally. A single growing array can only be parsed once it closes, which never happens in a live stream.

What is meta.partial and why does it matter? It is a consolidated honesty signal set to true when any source (RPC, prices, or token list) degraded for that call. Scripts gate automation on it so a flaky upstream is not mistaken for a real zero before anything mutates.

How do consumers validate the output they are parsing? Each envelope carries a schema id, and glnc schema prints every id (or one by name), so a script can assert it is reading the version it expects before trusting the payload.

When do you bump the schema version? Only on breaking changes. New fields are additive within a given vN, so adding optional data never forces consumers to update; the version number changes when an existing field's meaning or shape changes.

Next, read the JSON envelope reference or see how the same design holds up against a lower-level tool in glnc vs cast.