URL: https://sabermage.github.io/spt-releases/reference/json-shapes.md
Content-Type: text/markdown
Method: text

---

# JSON output shapes

<!-- [doc->REQ-DOC-DELIVERY-VOCAB] the machine-consumer reference: send-outcome vocabulary (canonical home cross-linked to Messaging), the endpoint-digest --json schema, the shell relay MAC-stamped frame prefix + api poll auth, and the full --json shapes catalog (seed #3) -->

Every read/status command takes a global **`--json`** flag and prints one
pretty JSON value to **stdout** (status lines stay on stderr — see the
[`api` output discipline](../harness-contract/api.md)). This page is the
machine-consumer's reference: the send-outcome vocabulary you classify by, the
session-digest schema, the shell relay's MAC-stamped frames, and the catalog
of `--json` shapes.

> A JSON value on **stdout**, a status tag on **stderr**, the truth in the
> **exit code**. A program reads whichever it needs and never has to scrape a
> human line.

## Send outcomes

The closed set of `spt send` outcome lines — `SENT`, `SENT(WAN)`, `QUEUED`,
`QUEUED(idle-only)`, `DEFERRED`, `NO_PERCH`, and the WAN failure tags — is
documented with its exact conditions in
[Messaging → Send outcomes](../messaging/overview.md#send-outcomes--the-closed-set).
The one rule a caller must encode: **classify by exit code** (`0` = every
delivered/spooled outcome, non-zero = every failure), and treat **`QUEUED` as
success** — the message is durably spooled and drains when the target next
comes online; never retry on it.

## Session digest — `endpoint digest --json`

`spt endpoint digest <id> --json` prints the endpoint's activity digest as a
projection of its session logs (not a PTY scrape). The top-level object:

```json
{ "turns": [ /* Turn, oldest → newest */ ] }
```

A `--after <seq>` cursor that predated the retained window adds one top-level
field, `"after_predates_window": true`, so a consumer knows it missed rows.

**`Turn`** — one user-opened turn:

| Field | Type | Notes |
|---|---|---|
| `input` | string \| null | The input that opened the turn; `null` for a preamble turn. |
| `entries` | array of entry | Agent/tool/boundary/context entries in stream order. |
| `input_seq` | number | Omitted when absent. |
| `partial` | bool | Omitted when false; `true` on the open trailing turn. |

**Entry** — an externally-tagged variant (the tag key names the kind):

| Variant | Fields |
|---|---|
| `Agent` | `text` (string); `seq`, `ts` optional |
| `ToolSprint` | `tools` (array of `{name, arg}`); `seq`, `ts` optional — consecutive tool uses collapse into one sprint |
| `Boundary` | `kind` ∈ `clear` \| `compact` \| `boot`; `ts` optional |
| `Context` | `kind` ∈ `psyche_download` \| `echo_mirror` \| `owl_message`; `body` (string); `ts` optional |

`ts` is an RFC3339-UTC ordering key. `--follow --json` streams per-update
deltas instead of the snapshot shape above.

> This is the digest **read** shape. It is distinct from the digest **record**
> an adapter *pushes* via `spt api digest-entry` / a `[digest]` extractor —
> that ingest contract (`role`/`text`/`tool`/`ts`) is documented in the
> [manifest digest-record reference](../harness-contract/manifest.md#session-digest--the-digest-record-contract).

## The shell relay — MAC-stamped frames

Two poll surfaces authenticate differently:

- **`spt api poll <id>`** — the agent hook-channel drain. Reads the caller's
  own perch spool; the manifest `[inject]` set must include the hook method.
  Each row prints as one whole `<EVENT …>` envelope on stdout.
- **`spt api poll <shell-id> --link <token>`** — the shell relay drain. The
  **link token is the credential**: it resolves the `(owner, shell)` pair and
  is refused (exit 1, `AUTH_REFUSED`) if no instance holds it. Rows are emitted
  **raw, one per line — deliberately not `<EVENT>`-wrapped** (the shell child
  parses its own vocabulary).

Shell-relay frames are **MAC-stamped**. The on-wire form is:

```text
<mac> <frame>
```

— a **64-hex-char HMAC-SHA256** over the frame bytes, one ASCII space, then the
raw frame. The key is `SHA-256(link_token)`; a frame with no valid MAC is
dropped, never processed. Agent-perch surfaces (`spt ready`, `api listen`,
`api poll <id>`) **never** emit stamped frames — a consumer of agent traffic
only ever sees `<EVENT>` / `<EVENT-PART>` lines.

## `--json` catalog

Commands that emit `--json`, and the top-level shape each prints. Fields marked
optional are omitted when empty.

| Command | Top-level shape |
|---|---|
| `endpoint list` · `whoami` | `{ self, subnets[], local[] }` — `self`: `{id, status, ready, alive, unbound, description, psyche_host_error, translation_fault?}`; `subnets[]`: `{name, endpoints[]}` where each endpoint is `{id, node, node_label, status, resources, endpoint_type?, project?}`; `local[]`: `{id, state, address, ready, alive, unbound, project?}` |
| `endpoint digest` | `{ turns[] }` — see [above](#session-digest--endpoint-digest---json) |
| `endpoint description show` | `{ id, description }` |
| `endpoint role` | `{ id, role }` |
| `api endpoint-info [<id>]` | `{ id, endpoint_type, adapter, local_node:{label,key}, attached_node:{label,key}\|null, controlled, project, cwd, subnets[] }` (always JSON) |
| `daemon status` | `{ running, pid, net_up, pump_heartbeat_ms, managed_by, managed_active, subnets[], local_endpoints[], broker_image?, broker_stale?, stall_evict_count?, stall_evict_last_ms? }` |
| `subnet status [--nodes]` | `{ daemon_running, subnets[] }` — each `{name, node_count, endpoint_count, nodes[]}` |
| `subnet show-code` | `{ subnet, code, otpauth_uri? }` |
| `notif list` | `{ notifs[] }` — each `{notif_id, subnet, kind, state, from_id, head}` |
| `access list` | `{ entries[] }` — each `{endpoint, nodes[], locked}` |
| `grant list` | `{ grants[] }` — each `{capability, agent, node, qualifier}` |
| `adapter list` | `{ adapters[] }` — each `{name, kind, mode, version, source_dir, active}` |
| `adapter version <option>` | `{ adapter, version }` |
| `shell list` | `{ owner, shells[], instantiable[] }` — each shell `{id, alias, adapter, status}` |

All shapes are **additive-forever**: new keys may appear, existing keys keep
their meaning. Parse tolerantly (ignore unknown fields) and a newer daemon
never breaks an older consumer.