Skip to content

JSON output and exit codes

JSON without an envelope on standard output, errors only on standard error, --raw for contract violations, and the 13 exit codes from 0 to 12.

4 minute readCLIUpdated: 10/25/2026

On this page

Before you start

For
Anyone who calls the CLI from scripts and has to tell result, error, and retry apart
Prerequisite
the CLI called directly through node …/dist/main.js or as szenera, never through pnpm run, which reports every failure as 1

What the CLI writes to which output and which exit code means which kind of error: the table a script branches on, without comparing any text.

The output format

Situation What the CLI does
Standard output is not a terminal (pipe, file) JSON, without --json being needed
Standard output is a terminal Header line and table for humans
--json or --table forces the format and takes precedence over the environment variable
SZENERA_OUTPUT=json or table forces the format and takes precedence over the detection

In JSON mode, standard output carries the payload and nothing else: no envelope, no ok, no dauerMs. A list is therefore .items[], not .daten.items[], and nextCursor appears directly in the response as long as more pages follow:

szenera scene list --json | jq -r '.items[].id'
szenera device list > geraete.json

For humans the opposite holds: the header line always comes first, even for a write command without a table. Below it comes the table or, if the command has none, the data as JSON.

Errors go to standard error

Errors always land on standard error, never on standard output. If szenera device list > geraete.json fails, it leaves no file that looks like a result. In JSON mode the error carries its kind as a word, so a log stays readable later without this table:

{
  "error": {
    "kind": "forbidden",
    "message": "…",
    "exitCode": 4,
    "code": "Permission.Denied",
    "status": 403,
    "traceId": "trc_…"
  }
}

code, status, traceId, details, and retryAfterSeconds are present when the error came from the API. In table mode the same message reads ✗ …, followed by HTTP 403 · Permission.Denied and the trace line Spur trc_…. Progress lines of waiting commands also go to standard error, and standard output carries exactly one document at the end.

The exit codes

Code Kind Where it comes from
0 ok all good
1 usage unknown command, missing positional argument, unknown flag, flag without a value, missing --yes, wrong ID prefix, and a request that contradicts the contract (with field path)
2 config profile missing or broken, no key prefix, profile blocked (readOnly: true)
3 auth HTTP 401
4 forbidden HTTP 403
5 not-found HTTP 404
6 conflict HTTP 409, a state or version conflict
7 validation HTTP 422, the server rejected the input
8 rate-limited HTTP 429. retryAfterSeconds is in the error
9 server HTTP 5xx or a response that contradicts the contract
10 network no HTTP response at all: no network, no name resolution, aborted
11 failed the operation went through and ended in failure (processing failed, upload rejected, command failed)
12 timeout the time limit was reached. The outcome is unknown

Codes 1 and 2 are errors on your side, 3 to 8 are refusals by the server with a clear cause, 9 and 10 are causes where a later attempt makes sense, and 11 and 12 are the outcomes of an operation that ends long after its call. Other status codes below 500 that match no row end as 1.

11 and 12 are not 9

Both went through: the call was right, and the server answered. With 11 the result is negative. asset upload was able to push the bytes, but processing failed, and the finding is in the error. With 12 the result is open, because the operation may still be running. Anyone who treats 12 like “failed” triggers a running operation a second time. Anyone who treats 11 like 9 repeats a call whose answer is already settled.

Request and response are two sides

The generated client checks both directions against the same schemas. If no network packet went out for the call, the request was at fault: exit 1, with a field path:

$ szenera scene update scn_01K… --document=kaputt.json --version=2
{ "error": { "kind": "usage", "exitCode": 1, "message":
  "Die Anfrage an „composition.scenes.update" entspricht nicht dem Vertrag:
     · draftDocument.canvas: Required
     · draftDocument.widgets: Expected array, received string" } }

If the response breaks the contract, the usual reason is a version skew: the other end is older or newer than the CLI and does not send a field that the CLI already requires. The message says so, names the places, and shows the way to the raw response:

$ szenera device get dev_01K… --raw
✗ Die Antwort auf „fleet.devices.get" widerspricht dem Vertrag, den diese CLI (0.1.0) kennt.
    Wahrscheinlich fährt die Gegenstelle einen anderen Stand — nicht: dort ist etwas kaputt.
    · displays: Required
    Was tatsächlich ankam, zeigt „--raw".

--raw applies everywhere and, for exactly this error, writes the raw response to standard output (null if no body arrived). The diagnosis stays on standard error, and the exit code stays 9. It is the only place where a failed call writes anything to standard output, so the document can go through jq instead of being fished out of an error message.