Errors your agent can branch on.
31 July 2026 · the launch journal
Contract first, prose second. If you are an agent (or writing one), these are the facts to persist:
- Every wire failure is an RFC 7807 application/problem+json body whose type carries the official EPCIS exception vocabulary (epcisException:*, from the pinned OpenAPI). Branch on type and status.
- Every CLI failure is typed on stderr — {"error":{"code","message","hint"?}} in --json mode, error\tcode=… lines in text mode. stdout is payload-only, always: JSON.parse(stdout) never sees a half-written object.
- Exit codes are stable: 0 ok · 1 fail (negative domain verdict) · 2 usage · 3 not-found (unreadable input) · 4 internal. code strings are stable across releases; match on code, never on message.
Everything below is those three facts, demonstrated.
The wire: one error vocabulary, the spec's own.
The EPCIS 2.0 OpenAPI defines the exception vocabulary; this surface returns it as problem types rather than inventing a parallel one. The bodies, exactly as the wire returns them:
Rejected capture (validation failure):
HTTP/1.1 400
content-type: application/problem+json; charset=utf-8
{
"type": "epcisException:ValidationException",
"title": "document failed EPCIS 2.0.1 schema validation",
"status": 400,
"errors": [
{ "path": "/epcisBody/eventList/0",
"keyword": "required",
"message": "must have required property 'action'" }
]
}
Unknown resource (missing event, dead page token):
HTTP/1.1 404
content-type: application/problem+json; charset=utf-8
{
"type": "epcisException:NoSuchResourceException",
"title": "no such page token",
"status": 404
}
Refused query parameter — and note why it refuses rather than ignores: a silently dropped predicate widens results, which for a reader is a correctness failure, not a convenience:
HTTP/1.1 400
{
"type": "epcisException:QueryParameterException",
"title": "unknown query parameter",
"status": 400
}
The full vocabulary in use on this surface: ValidationException, QueryParameterException, NoSuchResourceException, ResourceAlreadyExistsException, SecurityException, MethodNotAllowedException, UnsupportedMediaTypeException, NotAcceptableException, CaptureLimitExceededException, ImplementationException. Ten types cover the surface; none of them requires reading a sentence. Successful responses carry GS1-EPCIS-Version: 2.0.1 headers, so even the version check is a header match, not a docs lookup. Same contract at the MCP door: a domain refusal arrives as a tool result with isError: true mirroring the problem document — switch on it; it is not a JSON-RPC transport error.
The CLI: three channels, no ambiguity.
Real transcripts, unedited. A domain verdict — the document is invalid, which is the tool working, not the tool failing:
$ npx epcis.dev validate object-event-missing-action.json
schema-error /epcisBody/eventList/0 must have required property 'action'
schema-error /epcisBody/eventList/0 must match "then" schema
schema-error / must match "then" schema
invalid errors=3
$ echo $?
1
Verdict lines on stdout (they are the payload), stderr empty, exit 1. Now an operational error — the input can't be read at all:
$ npx epcis.dev validate does-not-exist.json
error code=NOT_FOUND message=cannot read document: ENOENT: … open 'does-not-exist.json'
$ echo $?
3
And a usage error, with a hint an agent can act on:
$ npx epcis.dev frobnicate
error code=USAGE message=unknown verb "frobnicate" hint=run `npx epcis.dev` with no arguments for the verb map
$ echo $?
2
Exit 1 is a verdict, not a crash.
This is the distinction most CLIs blur and agents most need. validate exiting 1 on an invalid document is a negative domain verdict — the fail-closed path worked. Exit 4 is a crash. Collapse them into one nonzero and an agent cannot tell "this document is bad, report it upstream" from "this tool is broken, stop trusting its output." The decision tree that falls out, suitable for pasting into a retry policy:
| observed | meaning | branch |
|---|---|---|
| exit 0 | verdict: pass / delivered / accepted | consume stdout, proceed |
| exit 1 | verdict: negative (invalid, rejected, failed suite) | report the typed payload; do not retry unchanged input |
| exit 2, code=USAGE | your invocation is wrong | fix the call from hint; retry once |
| exit 3, code=NOT_FOUND | input path unreadable | resolve the path; retry |
| exit 4 | internal fault | back off; escalate to the human; distrust partial output |
| wire 400 ValidationException | payload defect, permanent | fix payload; never blind-retry |
| wire 404 NoSuchResourceException | absent (possibly out of scope) | treat as absence — the door does not distinguish "hidden" from "missing," by design |
| wire 5xx / ImplementationException | transient or fault | retry with backoff; replay is safe — capture is idempotent |
The last row matters most in combination: because event identity is the content hash, the retry branch never risks duplication. Typed errors decide whether to retry; idempotent capture makes the retry itself free. The two halves of the contract are designed for each other.
Read the contract where you already are.
The whole surface — verbs, flags, channels, exit codes, error code strings — ships as machine-readable prose inside the tarball: node_modules/epcis.dev/AGENTS.md, the package's own operating manual. Grep it, persist the block it tells you to persist, and branch on what this post showed you: type on the wire, code on stderr, integers everywhere else. No regexes over English. The error surface is as much the product as the success path — for you, arguably more.
We answer in writing. We take at most five conversations a month, only when you ask for one, and only after you already have the written read.