Scripting and CI
The CLI's machine contract — two output formats, schema-bearing JSON, frozen exit codes, streaming exceptions, and budget gates for CI.
Every command that returns data speaks a stable machine contract: one line of schema-tagged JSON on stdout, and an exit code drawn from a permanently frozen set. Scripts and CI pipelines can depend on both. This page is the contract.
Two formats
Output is either table or json. Those are the only two — there is no plain format.
tableis the default: human-readable, aligned columns, color when the stream is a terminal. Tables render at a fixed 80-column width, so a wide table may truncate cells with an ellipsis even in a wide terminal.jsonis the machine format: one compact object per invocation.
Select JSON with any of --json, --format json, the SOLWYN_FORMAT=json environment variable, or format = "json" in your config file. When more than one is present, the flag wins over the environment variable, which wins over config. A non-terminal stdout does not switch to JSON by itself — ask for it.
Passing --json together with --format table contradicts itself and is a usage error (exit 2), as is an invalid or empty SOLWYN_FORMAT. --json with --format json is redundant but accepted. The output flags are root options and must come before the subcommand: solwyn --json status, not solwyn status --json.
The JSON envelope
Success is a single line on stdout — a compact, ASCII-escaped JSON object whose first field is always schema:
{"schema":"solwyn.budget-status.v1","project_id":"proj_0123456789abcdef01234567","utilization_pct":41.2}One object per invocation, newline-terminated, never colored. The schema field names the payload's shape and version; the fields after it are that command's data.
Error envelopes are JSON lines on stderr, using one fixed schema:
{"schema":"solwyn.error.v1","code":"not_found","message":"Project not found.","hint":null,"detail":null}The error object always carries these fields: code (a stable machine string), message (human-readable), hint (a suggested next step, or null), and detail (structured context, or null). Parse stdout as data and keep stderr as a separate log. Confirmed --json writes can emit effects and prompts on stderr before a later JSON error envelope. Do not merge streams or parse all stderr as one JSON document. Native argument/help errors can also be plain text.
Schema names are stable and additive-only. Within a .v1 payload, fields are only ever added — never renamed or removed — so a parser written today keeps working. Two fields did widen their domain in 0.2.0: budget-check.v1's denied_by_period now also carries tag and run_stopped, and budget-confirm.v1's reservation_id can be null under --record-only. Each command's --help names its schema on the JSON schema: line.
Advisories on stderr
Stderr is not silent on success. In both formats, commands print advisory lines there: retention-clamp warnings, showing top 100 groups, savings summaries, drift caveats, and the X-Solwyn-* window headers of an export. Pagination cursors are the exception — in table mode they print to stderr, in JSON they are a payload field. Parse stdout for data and treat stderr as a log.
Commands that do not emit the envelope
| Command | What it emits instead |
|---|---|
config get, config set, config list | Plain text. The output flags are ignored and not validated. |
agent-context | The plain-text guardrails document. |
costs export, alerts export, attest | A raw byte stream — CSV, NDJSON, or a JSON document — copied to stdout unchanged. --json does not wrap it. |
mcp | JSON-RPC over stdio. |
Color
Human output is colored only when the stream is a terminal. Disable it with --no-color or by setting the NO_COLOR environment variable — either one turns color off for both stdout and stderr, and NO_COLOR counts as set even when empty. JSON output is never colored, so it is always safe to pipe.
Exit codes
The CLI's exit codes are permanently frozen across all major versions, so a pipeline can branch on them without pinning a CLI release:
| Code | Meaning |
|---|---|
0 | Success |
1 | Generic or API error — a network failure, an invalid server response, a plan-tier or quota limit (402) |
2 | Usage error — bad flags, conflicting options, a malformed request the API rejected with 400 or 422 on some commands |
3 | Authentication or authorization error — no credential, the wrong credential type, a read-only key attempting a write, an insufficient role (401 and 403) |
4 | Budget denied, or a utilization gate tripped |
5 | Not found — a missing project, or an expired or already-consumed reservation (404) |
Since 0.2.0 a 403 exits 3 rather than 1. Some commands print data to stdout and then exit nonzero: record for rejected/failed recording, channels test and notifications test for delivery failure, doctor for a degraded check, and alerts pending --fail-if-open for open deliveries (all exit 1). A utilization gate or denied budget check exits 4 with its decision. Do not assume nonzero means empty stdout.
Rate limits and retries
The shared client has one capacity/rate-limit replay allowance, with a wait capped at 10 seconds. Retry-After supports numeric seconds and HTTP dates. Eligible 429s use this allowance; GET reads and metadata ingest can also replay a 503 with Retry-After. JWT refresh does not replenish the allowance. There is no unbounded backoff loop, and downloads never replay after yielding bytes. Every request carries User-Agent: solwyn-cli/<version>.
Admission checks, confirmation, run stop, project creation, key rotation/revocation and durable batch submission disable automatic replay as specified by their command contracts. Billing/team writes and read-only preview/simulation POSTs never automatically replay a generic 503 or transport failure. Definite 401/429 rejection handling remains bounded where enabled. A retry policy is not proof that the previous attempt had no effect.
Service capacity and unknown outcomes
MCP reports fixed service_busy guidance. CLI capacity failures normally exit 1, rather than auth or budget-denial codes. Distinguish these cases before taking action:
| Response or operation | Meaning and recovery |
|---|---|
Single budget check or durable journal admission with Core's fixed Budget admission is busy; retry shortly detail | No reservation was made by that request; admission can be retried deliberately. Advisory uncounted tallies may already have been folded and can overcount on retry. |
Legacy CLI budget check --batch, including the same busy detail | Earlier items in the failed chunk may have reserved budget, even in the first chunk. Keep completed results; do not blindly resubmit the failed chunk. Later chunks were not submitted. |
| Recognized ingest rejection | Absence is proven for that attempt only under the precise rule below. Reuse original call IDs and attempt indices. |
Generic/proxy 503, transport failure, lost/invalid ingest reply, or journal outcome_unavailable | Outcome remains unknown. Preserve identities and inspect state. Journal recovery uses the same operation ID with recover_only. |
Accounting gauge rebuild or budget uncounted 503 | Read state is unknown, not zero spend or proof of admission absence. |
An ingest attempt is recognized as definitely unaccepted only for status 503, Retry-After: 1, and exactly one of these details:
Budget admission is busy; retry shortlyAccount tag registry is busy; retry the batch.Accounting maintenance is in progress; retry the batch.
Every earlier attempt for the failed chunk must also be provably nonaccepting: one of those recognized rejections or Core's pre-ingest 429/401. A later recognized rejection cannot erase earlier uncertainty, including across JWT refresh. A valid 202 with complete canonical dispositions resolves the call normally. Missing/different headers, proxy HTML/JSON and unrecognized details do not prove absence.
CLI record reports status=not_recorded only when no earlier chunk completed and every attempt for the failed chunk was nonaccepting. Partial multi-chunk writes retain status=failed and known dispositions; a proven rejection applies only to that failed chunk. Otherwise CLI retains failed and MCP retains recording=unknown. Retry attribution with the original identities regardless of whether absence was proven.
login retries definite auth_capacity_exceeded once with the bounded wait; continued exhaustion exits 1 with that code and a retry hint. Invitation acceptance retries only that definite before-mutation capacity rejection; other unknown acceptance outcomes must be inspected, never blindly replayed. A JWT account_membership_revoked response points to account recover instead of repeated login.
Confirmation and unattended writes
Billing writes, team writes, account rename, runs stop, tags retire, key revoke/rotate, webhook delete/rotate, and channel delete display effects on stderr and require interactive confirmation or --yes. Without a TTY and without --yes, they exit 2 before HTTP. Decline, EOF or Ctrl-C submits no write. projects delete has its separate mandatory --yes and terminal name-confirmation rule.
Inspect subscription, roster, invitation or resource state after an uncertain write before deliberately retrying. Checkout/portal URL creation does not prove payment completion; browsers never open in non-TTY execution, even with --yes. See plan, team, and invite.
Pagination and exports
costs events, runs list, and tags values accept --all and an optional starting --cursor. Their --limit is a per-page size (1–100, default 25). Filters and ordering persist across pages. A repeated cursor, inconsistent continuation, malformed response or later-page failure exits nonzero without partial success stdout. On pre-Production plans, costs events --limit 1 --all can pass the first-page onboarding probe then receive 402 on a cursor page; it still emits no partial stdout.
Raw cost exports are capped at 25,000 events or 32 MiB; aggregate exports at 10,000 pricing subgroups or 32 MiB. Simulation is capped at 25,000 events. Oversized windows return 422/exit 2 with no partial result; narrow the window. Exports stream raw CSV/NDJSON. Preserve X-Solwyn-Export-Snapshot, requested/effective windows and retention metadata from stderr; preserve CSV formula-safety prefixes.
Write an export to a temporary file and rename only after exit 0, so a connection failure cannot publish an incomplete file:
if solwyn costs export --format json > costs.ndjson.tmp 2> costs-export.log; then
mv costs.ndjson.tmp costs.ndjson
else
rm -f costs.ndjson.tmp
exit 1
fiMCP pagination retains its 10-page, 30-second, 1 MiB HTTP / 2 MiB result boundaries and can return earlier data with a first unread cursor on error. Check isError and ok, never treat a partial result as complete. See bounded MCP work.
Budget and quota periods use server receive time; analytics use the event's stored timestamp. A backdated batch counts in the applicable period when received, while appearing on its original analytics day within clock/retention bounds. Backdating cannot bypass current-period limits.
Gating a deploy on budget
solwyn budget status --max-utilization 80 prints the project's budget as usual, then exits 4 when utilization is at or above 80 percent. Drop it into a pipeline step to block a deploy while a project sits near its cap — the status still prints, so the job log shows why it stopped.
# Fail the job when the project is at or over 80% of its budget.
solwyn --json budget status --max-utilization 80Guarding un-instrumented spend
When your code makes a provider call that the Solwyn SDK does not already wrap, reserve budget before the call and settle it after. The key must be full-scope; a read-only key exits 3 at the check.
export SOLWYN_AGENT_RUN_ID="ci-$GITHUB_RUN_ID" # group the job's spend into one run
CALL_ID=$(uuidgen | tr A-F a-f)
# 1. Reserve before the un-instrumented call.
CHECK=$(solwyn --json budget check \
--provider openai --model gpt-4o --estimated-input-tokens 1200) || exit $?
RID=$(printf '%s' "$CHECK" | jq -r '.reservation_id // empty')
# 2. Make the call yourself, then settle with the real token counts — only if reserved.
if [ -n "$RID" ]; then
solwyn --json budget confirm \
--reservation-id "$RID" --call-id "$CALL_ID" \
--provider openai --model gpt-4o \
--input-tokens 1180 --output-tokens 320
fibudget check exits 4 when the reservation is denied — treat that as "do not spend". If the check returns an allowed result with a null reservation, there is nothing to settle, so skip confirm. A call that ran with no reservation at all is reported afterwards with record; many checks at once go through budget check --batch.
Exit 4 has three triggers: a denied budget check, any denied item in a budget check --batch, and the budget status --max-utilization gate. Same code, different meanings — read the surrounding output to tell them apart.
Never run this loop around a call the SDK already instruments — that double-counts the spend. See Agent integration for the full rule and solwyn budget for every option.