• Start
Sign In

Scripting

Drive surrealctl from CI and shell scripts: the JSON contract, exit codes for control flow, unattended runs, and waiting semantics.

This page covers running surrealctl where nobody is watching: in CI, in cron, and in shell scripts. It explains the output contract, how to branch on exit codes, how to keep a run from blocking on a prompt, and where to put a credential. It is for engineers automating control-plane work.

surrealctl splits its output by purpose, and the split is absolute.

  • stdout carries the command's data payload. Nothing else, ever.

  • stderr carries progress, prompts, warnings, hints, errors, and debug output.

So | jq always works, --json > out.json can still ask you a question, and a failed command leaves stdout empty rather than half a document. A broken downstream pipe is not an error: surrealctl instance list | head -3 exits 0 silently.

--json prints the bare wire payload on stdout, with no envelope. There is no .data[] to unwrap, because the stream split and the exit code already answer "did it work".

surrealctl instance list --json | jq -r '.[] | select(.state == "ready") | .name'
surrealctl instance get production --json | jq -r '.version'
surrealctl org list --json | jq -r '.[] | [.id, .name] | @tsv'

Every invocation emits exactly one document. Two commands stream instead, and say so in their own help: instance watch and instance logs --follow emit newline-delimited JSON, one object per line. Two more are not documents at all: completion writes a shell script, and token create writes a secret, so it has no --json form.

A field the API adds appears in --json without a new release of surrealctl, so treat the payload as open: read the keys you need and ignore the rest.

--limit, --sort, --columns, --wide, and --reverse are presentation only. They shape a table and never reach --json, so --limit 1 cannot silently truncate a pipeline.

On failure, --json writes one enveloped object to stderr and leaves stdout empty.

Error envelope
{
  "kind": "conflict",
  "message": "could not pause `api`: Instance is not in a valid state",
  "status": 409,
  "code": null,
  "request_id": "01J8XYZ7QK9M3P5R7T9V1W3Y5Z",
  "hint": "The instance is busy with another change.",
  "command": "surrealctl instance get --wait",
  "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.

KeyTypeContents
kindstringThe error class. Never null
messagestringThe whole failure chain, including 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 in a support report
hintstring or nullWhat to do about it
commandstring or nullA command to run next
docsstring or nullA documentation link, for the classes that have a useful one
retry_after_secsinteger or nullHow long to wait, from the response
exit_codeintegerThe process exit code, so the document and the shell agree

One further key appears conditionally: candidates, an array of suggested names, when a reference failed to resolve and surrealctl had near matches to offer.

kind is a closed vocabulary of eleven values, so it is safe to match on: auth, forbidden, not_found, conflict, invalid, rate_limited, upstream, network, not_available, wait_timeout, unknown.

if ! surrealctl instance pause api --json > /dev/null 2> error.json; then
    jq -r '"\(.kind): \(.message)"' < error.json >&2
fi

not_available is worth calling out: it means a feature is not enabled for this deployment. Nothing is broken.

Error text is not a contract. These numbers are.

CodeMeaning
0Success, including an empty list
1An unclassified failure
2Bad invocation, or a destructive command that could not confirm
3Not authenticated, or the credential expired and could not be renewed
4Authenticated but not allowed: scope, role, or credential kind
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, or the feature is not available
10A wait gave up. The operation is still running
11The credential store could not be read or written
30An interactive sign-in did not complete
130Interrupted

0, 1, and 2 keep their conventional meanings, so if ! surrealctl … and usage errors behave exactly as they do with the surreal CLI. Everything above 2 is additive.

10 earns its own code because "still running" is a genuinely different answer from "failed". A pipeline may reasonably poll again rather than roll back.

Capture the status rather than testing it inside an if, so set -e does not end the script and $? still means what you think it does.

create-if-missing.sh
#!/usr/bin/env bash
set -euo pipefail

status=0
surrealctl instance get api --json > instance.json 2> error.json || status=$?

case "$status" in
    0)  ;;
    5)  surrealctl instance create api --type shared-1 --region aws-euw1 --json > instance.json ;;
    3)  echo "credential expired; re-authenticate the runner" >&2; exit 1 ;;
    8)  exit 75 ;;  # tell the scheduler to requeue
    *)  jq -r '.message' < error.json >&2; exit "$status" ;;
esac

Options that matter in automation

NameDefaultEnvironment variableDescription
--json, -j
NoneSURREALCTL_JSONEmit machine-readable JSON on stdout.
--no-input
NoneSURREALCTL_NO_INPUTNever prompt for input; fail instead. Also spelled --non-interactive.
--yes, -y
NoneSURREALCTL_YESAssume yes for every confirmation.
--quiet, -q
NoneNoneSuppress progress and informational output.
--plain
NoneSURREALCTL_PLAINDisable tables, spinners, and relative times.
--org<ORG>
NoneSURREALCTL_ORGThe organisation to operate on, by id or name.
--token<TOKEN>
NoneSURREALCTL_TOKENPersonal access token to authenticate with.
--timeout<DURATION>
30sSURREALCTL_TIMEOUTMaximum time to wait for a single API request.
--retries<N>
3SURREALCTL_RETRIESHow many times to retry a failed request.
--debug
NoneSURREALCTL_DEBUGLog every API request and response to stderr.

Every one of these has an environment variable, so a pipeline can set them once for the whole job rather than repeating flags on each step.

Two rules govern prompts.

A destructive command refuses rather than proceeds when it cannot ask. With no --yes and no command-specific --force, instance delete in a non-interactive session exits 2 having sent nothing. A command that quietly went ahead because there was no terminal would be the worst possible default.

--no-input makes any session behave as an unattended one, even at a terminal. Use it to test a pipeline locally: if the script works with --no-input, it will work in CI.

export SURREALCTL_NO_INPUT=1
surrealctl instance delete staging --force

surrealctl detects a CI environment and switches to plain output on its own: no spinners, no borders, no relative times. CI=false, CI=0, and an empty CI all mean not CI, so a system that sets the variable deliberately is not trapped in plain output.

What CI changes is structure, interactivity, and how much progress is reported. What it must not change, and does not: colour, --json bytes, exit codes, which requests are made, timeouts, poll intervals, --wait defaults, and confirmation semantics.

--json output is byte-identical whatever the terminal, the width, or the colour settings. Plain output does not depend on terminal width either, so it is stable to diff between runs. For parsing, still prefer --json.

Put a personal access token in the environment and leave it out of argv, where ps and shell history can both read it. See Authentication for how to create one.

export SURREALCTL_TOKEN="$(cat /run/secrets/surrealctl)"
export SURREALCTL_ORG=67upifj5dt6p87ch3nh5t3a8
surrealctl instance list --json

Set SURREALCTL_ORG to the organisation id. An id is used as it stands; a name costs a lookup on every command.

.github/workflows/instance-report.yml
name: instance-report

on:
  schedule:
    - cron: "0 6 * * *"

jobs:
  report:
    runs-on: ubuntu-latest
    env:
      SURREALCTL_TOKEN: ${{ secrets.SURREALCTL_TOKEN }}
      SURREALCTL_ORG: ${{ vars.SURREALCTL_ORG_ID }}
      SURREALCTL_NO_INPUT: "1"
    steps:
      - run: curl -fsSL https://download.surrealdb.com/surrealctl/install.sh | sh
      - run: surrealctl status
      - run: surrealctl instance list --json > instances.json
      - uses: actions/upload-artifact@v4
        with:
          name: instances
          path: instances.json

surrealctl status early in a job is worth the one second it costs: it names which credential is in use, whether the API answers, and which organisation resolved, all before a later step fails for one of those reasons.

Important

A personal access token reads the control plane and cannot write to it. A job that creates, updates, or deletes needs a login session on a dedicated machine account. See What a personal access token cannot do.

instance create, update, delete, pause, and resume wait for the operation to finish, in every output mode including --json. You have three choices.

ChoiceBehaviourUse it when
DefaultWait up to 15 minutesThe next step needs the instance ready
--wait-timeout <DURATION>Wait that long, then exit 10 if it has not settledThe job has its own time budget
--no-waitReturn as soon as the request is acceptedSomething else will follow up

Under --json, a waiting command streams progress as newline-delimited JSON on stderr and puts one final document on stdout. That split is what keeps "exactly one document on stdout" true for a command that reports progress.

Progress events, on stderr
{"event":"started","resource":"instance","goal":"ready","state":"pending"}
{"event":"transition","resource":"instance","from":"pending","to":"ready","elapsed_secs":86}
{"event":"finished","resource":"instance","outcome":"succeeded","state":"ready","elapsed_secs":86,"succeeded":true}

Six event types appear: started, transition, heartbeat, poll_failed, throttled, and finished. instance watch --json inverts the stream and writes these to stdout instead, because for a watch the transitions are the answer.

Treat exit 10 as "poll again", not "failed".

fail-fast-then-follow.sh
#!/usr/bin/env bash
set -euo pipefail

status=0
surrealctl instance create api \
    --type shared-1 --region aws-euw1 \
    --wait-timeout 2m --json > instance.json || status=$?

if [ "$status" -eq 10 ]; then
    echo "still provisioning after two minutes; following the state" >&2
    surrealctl instance watch api
elif [ "$status" -ne 0 ]; then
    exit "$status"
fi

Interrupting a wait does not stop the operation. It continues server-side, and surrealctl instance watch <name> picks the state back up.

--timeout bounds a single request and --retries bounds how many times one is retried. Retry behaviour depends on what the request would change: a read is retried on a connection failure or a 5xx, and a create is never retried after a 5xx, because the instance may exist and a replay would bill for two. Rate limits honour the server's Retry-After.

That leaves a small amount for your script to know about re-runs.

CommandRepeating it
instance createCreates a second instance. Check with instance get first
instance backup createTakes a second snapshot
instance pause, instance resumeSafe. Already-paused prints a note, emits the same document, exits 0
instance deleteSafe. A missing instance while waiting counts as done
auth logoutSafe. Signed out already still exits 0 with a document
instance capabilities setSafe. No change means no write

When a route has no verb yet, surrealctl api sends the request with the credential, headers, and retry policy already handled.

surrealctl api get /api/cloud/v0/organizations
surrealctl api get /api/cloud/v0/organizations --include
surrealctl api post /api/cloud/v0/organizations -d @body.json --force

The path is a path, not a URL — the host comes from --api. Any write method asks for confirmation unless you pass --force. --query key=value and --header name:value are repeatable. Authentication headers cannot be overridden here; change the credential instead. --include prints the status and headers to stderr, and --raw prints the body exactly as it arrived.

Values a script should not have to repeat can live in the profile instead.

surrealctl config list
surrealctl config set json true
surrealctl config set org acme
surrealctl config path

An environment variable outranks the configured value, and config set warns you when the matching variable is exported. config get prints the value alone, and nothing at all when there is none, so [ -z "$(surrealctl config get org)" ] behaves.

  • Set SURREALCTL_NO_INPUT=1 so nothing can block on a prompt.

  • Set SURREALCTL_ORG to an organisation id.

  • Pass --json and parse with jq. Never grep stderr.

  • Branch on exit codes, not on messages.

  • Treat 10 as "poll again", and 8 as "requeue".

  • Pass --yes or a command's --force deliberately, on the steps that need it.

  • Give a read-only job a personal access token, and nothing more.

  • Run surrealctl status first, so a misconfigured job says so in one line.

Was this page helpful?