Skip to main content
pinbox is designed to be read by two audiences: you, at a terminal, and an agent running commands on your behalf. Both get the same facts in a different shape, and the shape is a contract — the same command, run the same way, always produces the same structure. This page describes that contract. It applies to every command; the command reference does not repeat it.

Two modes

Human mode prints aligned columns. JSON mode prints one JSON document.

How the mode is chosen

Most explicit wins: --json forces JSON even on a terminal. Nothing forces human mode. The practical consequence: you do not need to teach an agent to pass --json. Any process that captures pinbox’s output is by definition not a terminal, so it gets the envelope automatically. It also means piping changes the output:
--json is a global flag, so it works in either position — pinbox list --json and pinbox --json list are the same command.
pinbox export is the one exception, because its stdout is the artifact. See export.

The envelope

In JSON mode, a command prints exactly one pretty-printed JSON document to stdout and nothing to stderr. Success:
Failure:
data is whatever the command returns — a pin, an array of pins, a message, a set of checks. hint names the next command to run; nearly every error carries one. Failures print the envelope to stdout, not stderr, and still exit non-zero. A JSON consumer therefore parses one stream and branches on ok; it never has to merge two.

stdout is data, stderr is messaging

In human mode, stdout carries facts — one line per fact, no headers, no decoration. Counts, confirmations, and errors go to stderr.
The last line — 5 pins (5 open) — is on stderr. So is pinned to src/app.tsx:42 after pinbox pin, and replied to pin_… as human after pinbox reply. Commands that create something put the new id on stdout by itself, so 2>/dev/null leaves you with just the id. Remember that a pipe switches the whole command to JSON mode. To capture an id in a script, parse the envelope rather than the human line:

Exit codes

Exit codes map 1:1 from error codes and are part of the contract. They are append-only: a code never gets renumbered. Codes 1–5 and 10 are the ones you meet at a terminal. The rest come from the delivery path, the toolbar’s WebSocket transport, attachment uploads, and hosted deployments; they surface through the same envelope when they do. doctor is a special case: it exits 1 when a check failed but still prints "ok": true, because doctor itself ran. See doctor.

Every error names a next step

The hint field in JSON mode is the second stderr line in human mode. It says what to do next, not just what went wrong.

Ids are exact

A pin id is pin_ plus 10 characters; a thread message id is msg_ plus 10. There is no prefix matching — a truncated id is E_NOT_FOUND, and the hint says so. Copy ids whole.

Timestamps

Human output shows relative ages (just now, 2m ago, 3h ago, 4d ago). JSON output carries the stored ISO 8601 strings verbatim. Nothing rounds in JSON.

The hub starts itself

Every command talks to a small local hub process that owns the SQLite database. You never start it: the first command that needs it spawns it, it binds 127.0.0.1 on an ephemeral port, and it exits when idle. No transcript on this page contains a “starting hub…” line because there isn’t one — the plumbing is silent unless it fails, and when it fails you get E_HUB_UNREACHABLE (exit 5) pointing at pinbox doctor. Two commands exist but are not part of the human surface: serve runs the hub in the foreground for debugging, and session is how agent hooks register with the hub and pull pin context. Both have --help if you need them; neither appears in pinbox --help.

The contract is versioned

--json output, error codes, exit codes, and doctor check names are machine-readable surface, treated as a contract rather than as incidental formatting. They change deliberately and visibly. Build against them.