# JSON output and exit codes

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.

> **Type:** reference
> **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`

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

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

```json
{
  "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.

## Related

- [Scripts and CI](/cli/scripts-and-ci)
- [When the CLI aborts](/cli/troubleshooting)
- [The error format](/api/error-format)
