Every failure in the kit is one error family with one shape. A capability does not invent its own, and there is no second format for the errors that happen to come from the CLI rather than from a route.
domain/reason
Every code the kit defines is enumerated in the reference, exit codes is the same contract at the CLI, and i18n is why the payload carries a code rather than a translated sentence.
Every code is two parts, and the domain is the capability’s own name:
auth/invalid_token
validation/invalid_input
core/not_found
controlplane/insufficient_scopeThat is the same segment that prefixes the capability’s tables and namespaces its migrations — which is what makes one capability’s codes incapable of colliding with another’s. A capability that ships a code outside its own domain is refused.
The code is the discriminator. It is a closed taxonomy: the kit’s own codes are a union, status is pinned to the one HTTP status each code maps to, and a mismatched pair fails validation rather than shipping.
Four fields, and only two cross the wire
| Field | Reaches a client | What it is |
|---|---|---|
code | Yes | domain/reason. Stable, and the thing to branch on |
message | Yes | The problem, in English |
params | Yes | Structured values a translating client interpolates into its own wording |
action | No | The operator’s remediation hint. The CLI’s second line |
detail | No | Internal context for logs and the audit trail |
action and detail are stripped by the HTTP codec. A remediation hint that says run pithy secrets provision is written for whoever is operating the deployment, and a browser is not that person. The CLI’s own JSON encoder keeps action for exactly the opposite reason: whoever ran the command is the operator it was written for.
That is two encoders over one schema, classified by who reads the line rather than by which encoder happened to be shared.
message stays English, permanently
This is the decision people ask about, so here is the reasoning rather than the rule.
An error payload crosses a boundary where the reader is unknown. A message translated on the server has already picked a language, and a client that knows better — because it knows its user’s account locale, and the server may not — cannot undo it.
So the payload carries the English and the structured params beside it, and a translating client renders:
its own string for this code and these params, falling back to the English when it has none.
The code is already the translation key, so there is no second identifier to keep in sync and no key that can drift from the code it describes. A locale file for the kit is, exactly, a file that covers the error codes.
The English is also what makes an unhandled error legible in a log, in an audit row and in a bug report — three places where a string in a language the reader does not speak is worse than useless.
What a client does with each
The status tells a client what kind of failure it is. The code tells it which one.
| Status | Means | What to do |
|---|---|---|
| 400 | The request was malformed | Fix the request. Retrying identically will not help |
| 401 | The credential is missing, expired or invalid | Mint a fresh access token from the session. If that fails, sign in again |
| 403 | The caller is who they say and may not do this | Do not retry. Show the user something true |
| 404 | It is not there | — |
| 429 | Rate limited | Back off |
| 5xx | The deployment is at fault | Retry with backoff, and page somebody |
401 and 403 are the pair worth separating in your client. A client that treats them the same loops: it refreshes a perfectly good token, gets another 403, and refreshes again.
Some codes exist specifically so a client can tell two similar-looking failures apart. A sign-in provider whose credential will not resolve answers its own code rather than a generic not-found, because nobody enabled this provider and this provider is broken right now need different words in front of a user who signs in with it every day.
Validation failures go through the same path
A request that fails its schema renders as validation/invalid_input with a 400, through the same error handler as everything else — rather than through the validator library’s own response shape.
One failure format, whatever failed. That is worth more than it sounds: a client with one error branch is a client that handles every error.
The payload carries the failing issues, so a form can highlight the field rather than showing a sentence about the request.
Where errors are thrown, and where they are not
A PithyError is the only thing a route throws deliberately. The error handler maps it to its declared status.
Anything else reaching the handler is a bug rather than a condition — and in the CLI, that distinction is visible: a PithyError renders as the problem and action lines, and anything else keeps its stack trace, because a stack trace means a CLI defect rather than a project problem.
In the CLI
Same payload, two lines:
No pithy.config.ts here.
Run pithy init, or cd into a project.The first line is message, the second is action. Under --json it is one {"error": …} line on stderr and a non-zero exit.
Every failure in the CLI has an action line, and that is a standard rather than an aspiration: an error that tells you what broke and not what to do costs a search, and there are a lot of commands.