surrealctl is driven as often by pipelines and agents as by people. This page is the contract those callers can rely on: which stream carries what, what --json guarantees, and what each exit code means.
The framing rule is short. stderr text is not a contract. The exit codes are.
The stream split
stdout carries the command's data payload, and nothing else, ever.
stderr carries progress, prompts, warnings, hints, errors and debug output.
So | jq always works, --json > out.json can still prompt you, and a failed command leaves stdout empty. Exactly one document is written to stdout per invocation.
A broken downstream pipe exits 0 silently, so surrealctl instance list | head -3 behaves as you expect.
surrealctl instance list --json > instances.json --json
On success, stdout carries the bare wire payload, pretty-printed with a two-space indent and no envelope. An envelope would force .data[] on every consumer for ever, and the stream split plus the exit code already answer did it work.
surrealctl instance get production --json{
"id": "67upif0m8sh1cn1p2c8t",
"name": "production",
"slug": "production-6xk2",
"state": "ready",
"type": "shared-1",
"region": "aws-euw1",
"version": "3.2.4",
"host": "production-6xk2.aws-euw1.surreal.cloud"
}Because the payload is the API's own body rather than a struct assembled here, a field the API adds this morning appears in --json this afternoon with no new release.
On failure, stdout gets zero bytes and an enveloped object goes to stderr:
{
"kind": "conflict",
"message": "could not pause `api`: Instance is not in a valid state",
"status": 409,
"code": null,
"request_id": "01J8XYZ2QK7M4N0P8R9S1T2V3W",
"hint": "The instance is busy with another change. Wait for it to settle, then try again.",
"command": "surrealctl instance get api",
"docs": null,
"retry_after_secs": null,
"exit_code": 6
}Every key is always present, with an explicit null rather than being omitted, so a consumer can index without checking first.
| Key | Type | Meaning |
|---|---|---|
kind | string | The error class. Never null |
message | string | The full message chain, including the layer naming which command failed |
status | integer or null | The HTTP status, when there was one |
code | string or null | The API's own error code, when it sent a non-empty one |
request_id | string or null | The request identifier to quote when reporting a problem |
hint | string or null | What to do about it |
command | string or null | A command to run next |
docs | string or null | A documentation link, for authentication and rate-limit failures only |
retry_after_secs | integer or null | From the Retry-After header |
exit_code | integer | Matches the process exit code exactly |
One further key appears conditionally: candidates, an array of strings, when a resource reference could not be resolved and there were near matches to suggest.
docs is populated only where a link explains something. Authentication and permission failures link to the authentication documentation; rate limits link to the rate-limit documentation; everything else is null, because a link to a generic index in place of an explanation is worse than no link.
The kind vocabulary
Eleven values, and the set is closed:
kind | Exit code | Meaning |
|---|---|---|
auth | 3 | Not authenticated, or the credential could not be renewed |
forbidden | 4 | Authenticated but not allowed |
not_found | 5 | The named resource does not exist |
conflict | 6 | Not in a state that allows this, or a precondition failed |
invalid | 7 | The API rejected the request as invalid |
rate_limited | 8 | Rate limited after the retry budget was spent |
upstream | 9 | The API's upstream failed |
network | 9 | The API could not be reached |
not_available | 9 | The feature is not enabled for this deployment |
wait_timeout | 10 | A wait gave up; the operation is still running |
unknown | 1 | Unclassified |
not_available is worth calling out: it is HTTP 501, and it means nothing is broken — the feature is simply not enabled here. Every Spectron route answers this way on a deployment without Spectron.
What --json guarantees
Additions are non-breaking and expected. Renames, removals and type changes are breaking.
Output is byte-identical regardless of
CI,NO_COLOR,TERM, terminal width, or--color always.--jsonis never coloured.Money stays in integer minor units — cents, or millicents where the API uses them. Formatting happens in the view layer only.
Timestamps stay verbatim RFC 3339, including the
9999-12-31T23:59:59Zsentinel that means "never expires".Presentation flags never reach it.
--columns,--wide,--sort,--reverseand--limitarrange a table;--jsonalways emits the complete payload.Relative times never appear. "3 days ago" is a rendering, not data.
Two commands are documented exceptions. token create has no --json form at all, because the secret is returned exactly once and can neither go inside the document nor share stdout with it. completion emits a shell script rather than a view, so --json does not wrap it.
Output shapes
| Shape | Produced by | Under --json |
|---|---|---|
| Table | Every list verb, every catalog command, org roles/permissions/usage/spend/plans, instance jwks/metrics/logs/usage, instance backup list, token scopes, auth scopes, spectron scopes/verbs/providers/usage, config list, context list, status | A JSON array |
| Detail | Every get, create, update, delete, archive and use, plus whoami, version, context show, open, instance estimate, instance capabilities get/set, instance backup create, instance backup policy get/set, spectron config | The wire body |
| Scalar | instance endpoint, config get, config path | The single value |
| Secret | instance token, token create, spectron key create/rotate, spectron scoped-key create, spectron access-token create | A one-field document, except token create, which refuses --json |
| Composite | instance status — a phase plus a snapshot table, as one document | The whole status body |
| NDJSON on stdout | instance watch, instance logs --follow | One compact object per line |
| Raw bytes | completion, api --raw | Written straight through |
A scalar is the value alone plus a newline, byte-identical in every text mode, with no label and no styling — so $(…) captures exactly the value:
ENDPOINT=$(surrealctl instance endpoint production)config get on an unset key writes zero bytes, so [ -z "$(surrealctl config get org)" ] and wc -l agree with each other.
NDJSON streams
A command that waits reports its progress as newline-delimited JSON on stderr, then writes one final document to stdout. The split is what keeps "exactly one document on stdout" true for a command that reports progress.
{"event":"started","resource":"instance","goal":"ready","state":"pending"}
{"event":"transition","resource":"instance","from":"pending","to":"provisioning","elapsed_secs":4}
{"event":"heartbeat","resource":"instance","state":"provisioning","elapsed_secs":64}
{"event":"finished","resource":"instance","outcome":"succeeded","state":"ready","elapsed_secs":91,"succeeded":true}Two more event shapes appear when the API pushes back: poll_failed, carrying a detail, and throttled, carrying retry_after_secs.
instance watch inverts the split and writes the same stream to stdout, because a watch is a stream by nature and there is no single final object to be the answer. instance logs --follow does the same with log lines. Both say so in their own --help.
List presentation flags
Every list-shaped command accepts the same six flags.
Sorting, reversing and limiting are always applied on this side, because the API has no pagination, no sort parameter and no search. They are applied in one order — sort, then reverse, then limit — and sorting compares the rendered value of each cell, so the display order matches the sort order.
--wide reveals the columns a table hides by default. --columns takes the column ids, which are what --sort also accepts. Run a command with --wide --no-header to see everything a row carries.
surrealctl instance list --columns name,state,version --sort namesurrealctl instance list --wide --plain > instances.tsvAn unknown column id is a usage error, exit 2. It is caught as the rows are arranged for display rather than before the request, so the list has already been fetched by the time it is reported. The message names every column the row carries, including the ones only --wide shows:
Unknown sort column `naem`. Available columns: name, state, type, version, region, compute_units, storage, id, slug, host, access_type, organization_idNone of these flags reach --json, which always emits the complete payload. A --limit 1 must not silently truncate a pipeline.
auth scopes is the one list-shaped command that carries only --no-header. It has no --columns, --wide, --sort, --reverse or --limit.
Output modes
--json chooses the encoding; --plain chooses the decoration. The encoding is resolved per stream, so surrealctl instance list | less gives plain data on stdout while a terminal on stderr keeps its spinner.
Encoding is decided by the first match:
--jsonorSURREALCTL_JSON--plainorSURREALCTL_PLAINAn agent environment is detected
CIis set to something other thanfalse,0or emptyTERM=dumbThe stream is not a terminal
Otherwise, rich output
Colour is decided independently, and deliberately does not depend on --plain or CI — GitHub Actions sets CI, is not a terminal, and renders ANSI perfectly. --color always colours piped output; --json is never coloured, even then.
Plain output is space-padded to its content width, with uppercase headers, no borders and no truncation, and is byte-identical at any terminal width. CI=1 is byte-for-byte equivalent to --plain.
What CI changes is structure, interactivity and progress granularity. What it must never change: colour, --json bytes, exit codes, which requests are made, timeouts, poll intervals, --wait defaults, or confirmation semantics.
The secret guard
Every command that mints a credential refuses to write it to a terminal, where it would stay in your scrollback. The check happens before the request, so a forgotten --reveal never costs a credential nobody can recover.
Refusing to print a secret to a terminal, where it would stay in your scrollback.
Pipe the output, or pass --reveal if you meant to see it.That is exit 2. Either pipe the output, or pass --reveal to say you meant it.
surrealctl instance token production | pbcopysurrealctl instance token production --revealThe confirmation guard
A destructive command has exactly three outcomes:
--yes(or the command's own--force) is set — it proceeds without asking.A person is present — it prompts on stderr, defaulting to no.
Neither — it is an error, exit
2, with nothing sent:
Refusing to delete `production` without confirmation in a non-interactive session.
Pass --yes to confirm, or run this from a terminal.Declining a prompt is not a failure. The command says what it did not do — Nothing was deleted. — and exits 0.
Exit codes
Codes are never reused, and none exceeds 125 except for the signal convention.
| Code | Meaning |
|---|---|
0 | The command did what was asked. Includes an empty list |
1 | A failure that does not fit any category below |
2 | Bad invocation: a usage error, client-side validation, a required value missing in a non-interactive session, or a destructive command that could not confirm |
3 | Not authenticated, or the credential expired and could not be renewed |
4 | Authenticated but not allowed: insufficient scopes, insufficient role, or a credential kind this operation refuses |
5 | The named resource does not exist |
6 | The resource is not in a state that allows this, or a precondition failed |
7 | The API rejected the request as invalid |
8 | Rate limited, after the retry budget was spent |
9 | The API or its upstream is unreachable, the feature is not available, or this client is too old |
10 | A wait gave up. The operation is still running |
11 | The credential store could not be read or written |
30 | An interactive login flow did not complete |
130 | Interrupted |
0, 1 and 2 keep their conventional meanings, so if ! surrealctl … behaves exactly as it does with the surreal CLI, and usage errors exit 2 as every other command-line tool does. Everything above 2 is additive.
10 earns its own code because still running is a genuinely different answer from failed, and a pipeline may reasonably poll again rather than roll back. See Long-running operations.
if surrealctl instance create api --type shared-1 --region aws-euw1 --json > instance.json; then
echo "ready: $(jq -r .name instance.json)"
else
case $? in
6) echo "an instance called api already exists" ;;
8) echo "rate limited; requeue this job" ;;
10) echo "still provisioning; poll again next run" ;;
*) echo "failed" ; exit 1 ;;
esac
fi status is the single deliberate exception to "stdout is empty on failure". Its table is the diagnosis, so it prints even when the command exits non-zero. The exit code comes from the first fatal check — 3 for a credential problem, 9 for an unreachable API.
Related pages
Global flags —
--json,--plain,--quiet,--color,--yesLong-running operations — waiting, polling and exit code
10Overview — the rest of the reference