• Start
Sign In

surrealctl

Output and exit codes

The surrealctl --json contract, the stdout and stderr split, NDJSON streams, the shared list presentation flags, the secret and confirmation guards, and every exit code.

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.

  • 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.

Capture the data, keep the diagnostics
surrealctl instance list --json > instances.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
Output
{
  "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:

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.

KeyTypeMeaning
kindstringThe error class. Never null
messagestringThe full message chain, including the layer naming which command failed
statusinteger or nullThe HTTP status, when there was one
codestring or nullThe API's own error code, when it sent a non-empty one
request_idstring or nullThe request identifier to quote when reporting a problem
hintstring or nullWhat to do about it
commandstring or nullA command to run next
docsstring or nullA documentation link, for authentication and rate-limit failures only
retry_after_secsinteger or nullFrom the Retry-After header
exit_codeintegerMatches 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.

Eleven values, and the set is closed:

kindExit codeMeaning
auth3Not authenticated, or the credential could not be renewed
forbidden4Authenticated but not allowed
not_found5The named resource does not exist
conflict6Not in a state that allows this, or a precondition failed
invalid7The API rejected the request as invalid
rate_limited8Rate limited after the retry budget was spent
upstream9The API's upstream failed
network9The API could not be reached
not_available9The feature is not enabled for this deployment
wait_timeout10A wait gave up; the operation is still running
unknown1Unclassified

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.

  • 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. --json is 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:59Z sentinel that means "never expires".

  • Presentation flags never reach it. --columns, --wide, --sort, --reverse and --limit arrange a table; --json always 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.

ShapeProduced byUnder --json
TableEvery 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, statusA JSON array
DetailEvery 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 configThe wire body
Scalarinstance endpoint, config get, config pathThe single value
Secretinstance token, token create, spectron key create/rotate, spectron scoped-key create, spectron access-token createA one-field document, except token create, which refuses --json
Compositeinstance status — a phase plus a snapshot table, as one documentThe whole status body
NDJSON on stdoutinstance watch, instance logs --followOne compact object per line
Raw bytescompletion, api --rawWritten 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.

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.

stderr, during instance create --json
{"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.

Every list-shaped command accepts the same six flags.

Presentation flags

NameDescription
--columns<IDS>
Show only these columns, comma-separated. Conflicts with --wide.
--wide
Show every column, including the ones hidden by default.
--no-header
Omit the header row.
--sort<ID>
Sort by this column. An unrecognised id is a usage error listing the valid ones.
--reverse
Reverse the sort order.
--limit<N>
Show at most this many rows.

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.

Just the names and states, longest-lived first
surrealctl instance list --columns name,state,version --sort name
Every column, for a spreadsheet
surrealctl instance list --wide --plain > instances.tsv

An 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_id

None of these flags reach --json, which always emits the complete payload. A --limit 1 must not silently truncate a pipeline.

Note

auth scopes is the one list-shaped command that carries only --no-header. It has no --columns, --wide, --sort, --reverse or --limit.

--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:

  1. --json or SURREALCTL_JSON

  2. --plain or SURREALCTL_PLAIN

  3. An agent environment is detected

  4. CI is set to something other than false, 0 or empty

  5. TERM=dumb

  6. The stream is not a terminal

  7. 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.

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.

Pipe it
surrealctl instance token production | pbcopy
Or ask for it on screen
surrealctl instance token production --reveal

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.

Codes are never reused, and none exceeds 125 except for the signal convention.

CodeMeaning
0The command did what was asked. Includes an empty list
1A failure that does not fit any category below
2Bad invocation: a usage error, client-side validation, a required value missing in a non-interactive session, or a destructive command that could not confirm
3Not authenticated, or the credential expired and could not be renewed
4Authenticated but not allowed: insufficient scopes, insufficient role, or a credential kind this operation refuses
5The named resource does not exist
6The resource is not in a state that allows this, or a precondition failed
7The API rejected the request as invalid
8Rate limited, after the retry budget was spent
9The API or its upstream is unreachable, the feature is not available, or this client is too old
10A wait gave up. The operation is still running
11The credential store could not be read or written
30An interactive login flow did not complete
130Interrupted

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.

Branch on the outcome
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
Important

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.

Was this page helpful?