Every error from the Driftless API (whether you reach it through the CLI, the MCP server, or directly) comes back as the same JSON envelope. An agent should branch on the machine-readable code, not parse the human message.
The envelope
{
"statusCode": 403,
"code": "MISSING_HUMAN_IDENTITY",
"message": "Merging into Knowledge requires owner/admin authority: your own session or an owned owner/admin key (an agent acting on your explicit request). A faceless agent (ownerless key / OAuth) cannot merge.",
"request_id": "a1b2c3d4",
"retryable": false,
"endpoint": "POST /api/v1/workspaces/acme/topics/auth-flow/approve"
}
| Field | Meaning |
|---|
statusCode | HTTP status. |
code | Stable, machine-readable error code (this list is additive-only). |
message | Human-readable, actionable. Tells you how to fix it. May change; don’t match on it. |
request_id | Correlation id. Quote it when reporting an issue. |
retryable | true only for genuine transient failures (5xx). A 4xx is a definitive answer, so retrying won’t help. |
endpoint | The method + path that produced the error. |
retryable is honest: the CLI auto-retries a GET once on a transient failure but never re-sends a write, and never retries a 4xx. If you build your own client, do the same.
Why a code at all
Input that’s wrong shouldn’t reach the agent as an opaque 500. Two layers guarantee that:
- Validation rejects a bad enum (
status, visibility) with a field-level 400 before it touches the database.
- A Postgres-error translator catches any constraint violation that slips through and turns it into a coded
4xx, never a generic 500.
After those two, a 500 means what it should: a real bug or an outage. That’s also the only time retryable is true.
Code catalog
code | Status | When |
|---|
VALIDATION_FAILED | 422 / 400 | A request body field failed validation (e.g. status must be one of: draft, proposed, reviewed, …). |
BAD_REQUEST | 400 | A generic bad request with no more specific code. |
INVALID_VALUE | 400 | A value violates a database CHECK constraint. |
INVALID_ID | 400 | A path/body identifier is not a valid UUID. |
REQUIRED_FIELD | 400 | A required field was missing. |
FK_VIOLATION | 400 | References a record that does not exist. |
SECRET_DETECTED | 400 | A topic field looked like a credential (API key, token, private key) and the write was blocked. Remove it, or set allow_secrets: true (CLI --allow-secrets) to override. The error masks the match; it never echoes the secret. |
Access & identity
code | Status | When |
|---|
UNAUTHORIZED | 401 | Missing or invalid API key / token. → driftless login --key <key>. |
FORBIDDEN | 403 | Authenticated, but not allowed. |
MISSING_HUMAN_IDENTITY | 403 | A faceless principal (an ownerless key, or an OAuth token with no owner/admin behind it) tried to merge into Knowledge. Re-issue the key from the dashboard so it carries your identity, or run the merge under an owner/admin. |
INSUFFICIENT_ROLE | 403 | You lack the workspace role for this action: only an owner/admin can approve. Ask one to promote you in Settings → Members. |
NOT_FOUND | 404 | The resource doesn’t exist (or another member’s private draft you can’t see, so we return 404, never 403). |
Governance & conflicts
code | Status | When |
|---|
DUPLICATE | 409 | A record with these values already exists. |
DUPLICATE_TOPIC | 409 | A topic with that slug already exists in the workspace. |
VERSION_CONFLICT | 409 | expected_version didn’t match: someone wrote since you read. Re-read and retry. |
CONFLICT | 409 | A generic state conflict. |
PROTECTED_RESOURCE | 403 | A protected/default resource can’t be modified or deleted. |
RATE_LIMITED | 429 | Too many requests. Back off. |
Server
code | Status | When |
|---|
INTERNAL | 500 | An unexpected failure. retryable: true. Quote request_id if it persists. |
Observability
Every captured error is also emitted to PostHog (api_error server-side, cli_command_error from the CLI) with code, status_code, and endpoint as properties, so the error surface is one queryable map, sliceable by type and endpoint rather than free-text.