Error handling for automation

The envelope, the exit classes, and which failures are worth retrying.

Written By Dustin

Last updated 24 minutes ago

Every failure carries a TVO-<AREA>-<NNN> code. Branch on the code, never on the message text — the text is presentation and may be reworded; the code is a versioned contract.

The envelope

Under --json, and across the SDK and MCP edges, a failure is the same object: code, title, cause, exit_code, and a context map. Success uses a top-level result; failure uses error. Nothing else goes on stdout in JSON mode — warnings, hints, and deprecation notices are always stderr, so a parser never has to strip prose out of a document.

Exit classes

Exit codes are coarse — one per area class — so a script can branch on category cheaply while the precise code lives in the JSON.

ExitClass
0Success
1Generic / uncategorized
2CLI or configuration usage error
7Storage / integrity
11Crypto / key material
12Post-event plugin failure — the operation itself succeeded.
13Permission, policy, capability, or lock denial
17Sync / wire protocol
19Change / op-log
21Migration / bridge

Two exits will catch you out. Creating a conflict exits 0 — a conflict is a first-class object and a successful outcome, not a failure — so automation that treats zero as "nothing to look at" will silently build on top of conflicts. Check listConflicts or status, not just the exit code. And exit 12 is the inverse: the operation succeeded and is durable, and only a post-event plugin failed. Re-running a land that exited 12 re-runs work that already committed.

The error catalog in the documentation is the system of record for every code and its exit class. This table is the shape, not the register.

Usually worth retrying

CodeWhy
TVO-SYNC-* (exit 17)Transport, peer protocol, partial transfer. Retry with backoff.
TVO-FORGE-* compare-and-set conflicts (HTTP 409)A write that lost a race. Re-read the current version and retry.
TVO-CI-008The bridge's child would not start, exited non-zero, or timed out. Retry only after reading the preserved child output — the underlying cause may be terminal.

Never worth retrying

CodeWhy
TVO-PERM-* (exit 13)Policy denial. Retrying produces it again.
TVO-TOKEN-* (exit 13)Expired, revoked, or out of scope. Re-issue, do not retry.
TVO-NET-001Refused at network admission by CIDR policy, before authentication. A policy problem, not a transient one.
TVO-CLI-* (exit 2)A usage error in your own invocation.
TVO-CI-007, TVO-CI-010, TVO-CI-011 (exit 2)Invalid or unsafe bridge input, issuer policy, or OIDC exchange. Nothing was started or published.
TVO-CI-009Release verification failed. Retrying will fail identically, and should — no unverified binary was exposed.
TVO-CI-006A CI step referenced a secret the job token is not cleared for. Grant clearance for that secret's policy path, or drop the reference — retrying changes nothing.
TVO-GHC-001An honest 501: that route has no native TOVIO referent and never will.
TVO-PLUGIN-*Config or capability denial. Fail-closed by design. The one exception is a post-event plugin failure at exit 12 — the operation landed, so do not re-run it.

Errors are values in the SDK

The engine returns denials, it does not throw them, so a buggy or compromised edge cannot suppress one by swallowing an exception. Every SDK operation returns { ok: true, … } | { ok: false, error } and TypeScript forces the check.

const read = client.readFile("src/secret.rs");if (!read.ok) {  if (read.error.code === "TVO-PERM-001") { /* no clearance — ask a human */ }  return;}

unwrap(outcome) is the scripting shortcut: the payload, or a thrown TovioError carrying the envelope unmodified. Only construction sites throw — connect (a refused token yields no client) and openWriteSession. A malformed envelope degrades to TVO-CORE-000, never to an empty object, so error.code is always present.

Handle expiry deliberately

Long-running automation should renew before expiry rather than catching the failure: tovio agent renew <token_id> --expires-in <hours>. That is a re-issue, not an extension — the old token's window is unchanged.

Never log the token

Log the code and the operation. Not the credential, not the path scope of a protected read, not the contents of an error context map you have not inspected.