Machine-Readable Output

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against jq 1.8, GitHub CLI (gh) 2.x, JSON Schema 2020-12, Python 3.12 and the docsctl running example of this sub-section, as checked on 2026-09-30. The docsctl outputs shown are illustrative; the fictional tool exists only on these pages.

Text tables are for eyes. A program that has to scrape column positions out of a table breaks the day a title gets longer or a column is added, and an agent that does it burns tokens re-deriving a format the tool could have simply stated. The fix is to give every command a second output mode that is a documented data format, and to treat that format as a public API: named, versioned, ordered and described by a schema. The streams page already established where the data goes (see Streams & Exit Codes: stdout only); this page is about what the data looks like.

--json, JSON Lines and --format

Offer one flag that selects the format, and one shortcut for the common case. docsctl follows the contract used across this sub-section: --format json|jsonl|text, with --json as an alias for --format json.

Table 1. The three output formats of docsctl
Format Shape Use it when

text

Human tables and prose. Aligned columns, colour on a terminal, wording free to change between releases.

A person is reading. Never parse it; it is stable only for eyes.

json

One JSON document on stdout, wrapped in an envelope that carries schemaVersion, the results and paging state.

The caller wants the whole answer at once and will parse it in one go (the default choice for agents).

jsonl

JSON Lines: one complete JSON object per line, no envelope, and a final summary line.

The result set is large or streamed, or the consumer processes records one at a time (while read, xargs, log shippers).

A few rules keep the formats trustworthy:

  • The default is text for people; machine formats are opt-in. The contract adds one convenience for non-interactive use: DOCSCTL_FORMAT=json in the environment (or format = "json" in the config file) changes the default, so an agent harness sets it once instead of appending --json to every call.

  • No decoration in machine modes. No banners, no ANSI colour, no progress on stdout, no trailing "Done". Colour and progress are already off when stdout is not a terminal (see TTY, Colour & Interactivity).

  • Errors are machine-readable too. In machine modes the error is the JSON error envelope on stderr, with the exit code in the process status; a half-written result on stdout is never mistaken for an error.

  • JSON Lines needs a terminator. A stream that stops is ambiguous: finished or killed? docsctl ends every successful jsonl stream with {"schemaVersion":1,"type":"summary","total":N,"nextCursor":null}, so a reader that never sees that line knows the output is incomplete.

The same query in the three formats
docsctl search kalman --limit 2                     # text: for eyes
docsctl search kalman --limit 2 --json              # one JSON document
docsctl search kalman --limit 2 --format jsonl      # one object per line + a summary line
The jsonl output (one line each; long lines shortened for the page)
{"component":"irurueta-statistics","id":"irurueta-statistics::kalman.adoc","score":0.91,"title":"Kalman filter","url":"https://albertoirurueta.github.io/docs/irurueta-statistics/latest/kalman.html"}
{"component":"irurueta-algebra","id":"irurueta-algebra::matrix-inversion.adoc","score":0.74,"title":"Matrix inversion","url":"https://albertoirurueta.github.io/docs/irurueta-algebra/latest/matrix-inversion.html"}
{"schemaVersion":1,"total":2,"type":"summary","nextCursor":null}

References: JSON Lines, clig.dev — Output (which recommends a --json flag and --plain for stable line-oriented text).

Field projection: --fields and --jq

An agent rarely needs every field. Each unneeded field costs tokens (and for a person, scrolling). Give callers two ways to cut the output down, and make them do different jobs:

  • --fields a,b,c selects top-level fields of each record. It is declarative, validated (an unknown field name is a usage error, exit 2, listing the valid names) and cheap to implement on the server side, because the tool can skip computing what was not asked for.

  • --jq EXPR post-filters the JSON with a jq expression. It is a full query language for reshaping, counting and filtering, and it works only with JSON output. Embedding it saves callers a pipe and, more usefully, works in environments where jq is not installed.

Projecting and filtering docsctl output
# Only ids and titles
docsctl search kalman --json --fields id,title

# The ids of hits above a score threshold, one per line (raw strings)
docsctl search kalman --json --jq '.results[] | select(.score > 0.8) | .id'

# Equivalent with an external jq, for environments that have it
docsctl search kalman --json | jq -r '.results[] | select(.score > 0.8) | .id'

Projection interacts with the schema: a projected result is a subset of the documented record, never a differently named one, and the envelope (schemaVersion, total, nextCursor, truncated) is always present so paging still works. --jq output is whatever the expression yields; it is a query result, not a schema-versioned document, and the documentation should say so.

Reference: the jq manual.

Deterministic output

Identical inputs should produce byte-identical outputs. Determinism is what lets an agent (or a test, or a cache, or git diff) recognise "nothing changed", and it removes a class of flaky behaviour where the model reacts to a reordering that meant nothing.

  • Sort object keys. JSON objects are unordered by definition, but serialisers emit whatever order the language library uses. Sort keys explicitly (sort_keys=True in Python).

  • Fix the order of arrays and say what it is. Search results are ordered by descending score, ties broken by id; list-components is sorted by name; versions are newest first. "Order of arrival from a hash map" is not an order.

  • Keep volatile values out of the payload, or isolate them. Timestamps, durations, request IDs and hostnames belong on stderr (with --verbose) or in one clearly named field (generatedAt) that callers can drop. Otherwise a cache key or a snapshot test changes on every run.

  • Use one number format and one encoding. Numbers as JSON numbers (no "0.91" strings, no thousands separators), UTF-8 always, timestamps as RFC 3339 in UTC.

  • Pretty-print only for TTYs. Compact single-line JSON is cheaper in tokens; indented JSON is for a person at a terminal (an opt-in flag, or automatically when stdout is a TTY and the format was defaulted).

Deterministic serialisation (Python)
import json
import sys


def emit(document: dict) -> None:
    # Sorted keys and compact separators: same input, same bytes, fewer tokens.
    json.dump(document, sys.stdout, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
    sys.stdout.write("\n")


def ranked(results: list[dict]) -> list[dict]:
    # A total order: score descending, then id, so ties never shuffle between runs.
    return sorted(results, key=lambda r: (-r["score"], r["id"]))

Reference: Python — json.

schemaVersion and additive-only evolution

Once someone scripts against your JSON, its shape is an API. docsctl marks every machine-readable document with "schemaVersion": 1 and follows two rules.

  1. Within a schema version, changes are additive only. New optional fields may appear; existing fields are never removed, renamed, retyped or given a different meaning. Consumers must ignore fields they do not know.

  2. A breaking change bumps schemaVersion. The old version stays selectable for a deprecation period (selected through a flag or an environment variable; the contract of this sub-section leaves the exact spelling open), the release notes say so, and docsctl schema --json (see Self-Describing Help) reports which versions a command can emit.

The corollary for consumers is the robustness principle: read the fields you need by name, do not assert that the object has only those fields, and check schemaVersion before relying on the shape.

A consumer that tolerates additive change and refuses an unknown version (Python)
import json
import subprocess

SUPPORTED_SCHEMA = 1

proc = subprocess.run(
    ["docsctl", "search", "--json", "--no-input", "--", "kalman"],
    capture_output=True, text=True, check=False,
)
if proc.returncode != 0:
    raise RuntimeError(json.loads(proc.stderr)["error"]["hint"])

doc = json.loads(proc.stdout)
if doc["schemaVersion"] != SUPPORTED_SCHEMA:
    raise RuntimeError(f"unsupported docsctl schemaVersion {doc['schemaVersion']}")

for hit in doc["results"]:          # unknown extra fields are simply ignored
    print(hit["id"], hit["title"])

The call passes an argv list (never a shell string) and -- before the user-supplied query, as the security rules of this sub-section require.

Publishing JSON Schemas for outputs

A schema turns "we promise this shape" into something a machine can check. Publish one JSON Schema document per command and version, at a stable URL, and reference it from three places:

  • the documentation (a page per command, with an example that validates against it);

  • the output itself, optionally, through a link field or HTTP header when the tool is a service;

  • docsctl schema --json, where each command’s output.schema holds the URL. An MCP wrapper embeds that schema as a tool’s outputSchema (MCP wants the schema inline, not a URL), so the same file serves both worlds (see MCP Primitives).

A shortened JSON Schema (draft 2020-12) for docsctl search --json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.invalid/docsctl/schemas/search.v1.json",
  "type": "object",
  "required": ["schemaVersion", "query", "total", "nextCursor", "truncated", "results"],
  "properties": {
    "schemaVersion": { "const": 1 },
    "query": { "type": "string" },
    "total": { "type": "integer", "minimum": 0 },
    "nextCursor": { "type": ["string", "null"] },
    "truncated": { "type": "boolean" },
    "results": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["id", "component", "title", "url", "score"],
        "properties": {
          "id": { "type": "string" },
          "component": { "type": "string" },
          "title": { "type": "string" },
          "url": { "type": "string", "format": "uri" },
          "score": { "type": "number", "minimum": 0, "maximum": 1 },
          "snippet": { "type": "string" }
        }
      }
    }
  }
}

The schema deliberately does not set additionalProperties: false: closing the object would turn every additive change into a breaking one. Test the pair in CI by validating real output against the published schema (a JSON Schema validator in the language of your choice).

Examples modelled on gh

The GitHub CLI is the reference implementation to copy, because it solved this problem for a large command surface with three cooperating flags (see the official formatting guide):

  • --json fields takes a comma-separated list of fields and prints JSON with exactly those. Run with --json and no value, it prints the list of available fields instead, which doubles as discovery.

  • --jq expression filters that JSON with jq syntax, no external jq required.

  • --template string formats it with a Go template, for callers who want lines of text rather than JSON.

gh, the three flags in use
# Discover the fields, then ask for two of them
gh pr list --json
gh pr list --json number,title

# Reshape with --jq: one line per open pull request
gh pr list --json number,title --jq '.[] | "\(.number)\t\(.title)"'

# Or render with a Go template
gh pr list --json number,title --template '{{range .}}{{.number}} {{.title}}{{"\n"}}{{end}}'

docsctl borrows the ideas and adapts the naming: --fields for projection (--json is already a switch, not a field list, because it doubles as the format flag), --jq unchanged, and no --template. A template language is one more thing to document and to keep injection-safe; jq already reshapes into any text an agent needs, and --format text covers people.

docsctl search --json, the full round trip
$ docsctl search kalman --json --limit 2
{"nextCursor":null,"query":"kalman","results":[{"component":"irurueta-statistics","id":"irurueta-statistics::kalman.adoc","score":0.91,"snippet":"A Kalman filter estimates the state of a linear system...","title":"Kalman filter","url":"https://albertoirurueta.github.io/docs/irurueta-statistics/latest/kalman.html"},{"component":"irurueta-algebra","id":"irurueta-algebra::matrix-inversion.adoc","score":0.74,"snippet":"Inverting a matrix is a building block of the update step...","title":"Matrix inversion","url":"https://albertoirurueta.github.io/docs/irurueta-algebra/latest/matrix-inversion.html"}],"schemaVersion":1,"total":2,"truncated":false}
Cutting the same call down to what an agent needs next
$ docsctl search kalman --json --limit 2 --fields id,title
$ docsctl search kalman --json --limit 2 --jq '[.results[].id]'
{"nextCursor":null,"query":"kalman","results":[{"id":"irurueta-statistics::kalman.adoc","title":"Kalman filter"},{"id":"irurueta-algebra::matrix-inversion.adoc","title":"Matrix inversion"}],"schemaVersion":1,"total":2,"truncated":false}
["irurueta-statistics::kalman.adoc","irurueta-algebra::matrix-inversion.adoc"]

The id values are component::path strings, the same form that docsctl show accepts, so the output of one command is a valid argument to the next. The token effect of projection is worked out in Agent Ergonomics; the tool-design background is in Tool Design.