JSON Output Contract (agent-consumable --json)
Status: stable · Schema version: 1 · Ticket: RTL-P2.765 · Module: sdk/cli/output.py
RouteRTL commands that emit --json wrap their payload in one envelope so
an agent (or any script) can chain commands without parsing prose or
reverse-engineering each command's ad-hoc dict shape.
The envelope
{
"schema_version": 1,
"ok": true,
"data": { "...": "command-specific payload (any JSON value)" },
"errors": [],
"warnings": []
}
| Field | Type | Meaning |
|---|---|---|
schema_version | int | One integer for the whole CLI. Bumped only on a breaking change to the envelope itself — never for additive changes to a command's data. |
ok | bool | Did the command succeed? Mirrors the exit code: ok == (exit_code == 0). |
data | any | The command-specific payload — whatever the command used to print bare. Object, array, scalar, or null. |
errors | string[] | Human-readable error messages. Empty when ok is true. |
warnings | string[] | Non-fatal advisories. May be present even when ok is true. |
Rules
oktracks the exit code. A command that emitsok: falseMUST also exit non-zero.emit_jsonnever callssys.exit— the command owns its exit code. An agent can therefore branch on either signal.schema_versionis global and stable. Adding a key to a command'sdatadoes not bump it; only a change an existing parser could choke on does. Consumers should tolerate unknown keys insidedata.- Adoption is additive. New and backfilled commands use the envelope.
The ~30 commands that already emit ad-hoc JSON migrate opportunistically
under RTL-P3.919 — they are deliberately not rewritten in one
breaking sweep. Until a command is migrated, treat its
--jsonas command-specific (un-enveloped). See decision record for rationale.
Producing it (command authors)
from sdk.cli.output import emit_json
@click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
def mycmd(as_json):
result = {"widgets": [...]}
if as_json:
emit_json(result) # ok=True by default
return
# ... human-readable rendering ...
For a failure path: emit_json(None, ok=False, errors=["no project found"])
and then exit non-zero.
Consuming it (agents / scripts)
import json, subprocess
out = subprocess.run(["rr", "rules", "check", "--json"], capture_output=True, text=True)
env = json.loads(out.stdout)
if env["ok"]:
use(env["data"])
else:
handle(env["errors"])
sdk.cli.output.is_envelope(obj) validates the shape before you trust it.
Enforcement
sdk/cli/tests/test_json_output_contract_p2765.py guards the contract:
- Regression — read-only query commands that already emit
--json(JSON_CONTRACT_STABLE) must never lose it. - Self-cleaning worklist —
JSON_BACKFILL_PENDINGis the exact RTL-P3.919 backfill list. The moment a pending command starts emitting--json, the guard fails until its entry is removed, so the worklist cannot silently rot.
The MCP
_CLI_SEMANTIC_ANNOTATIONStable is intentionally not the read-only oracle here — it is opt-in and largely unannotated. The agent-consumable surface is an explicit curated registry in the test.