Every failure /api/v1 reports is an RFC 9457 problem document
(application/problem+json, kernelos.api.errors) with the same seven keys:
{
"type": "about:blank",
"title": "Forbidden",
"status": 403,
"detail": "org.read requires the scope org:read",
"code": "auth.permission_denied",
"details": {"action": "org.read", "required_scope": "org:read"},
"request_id": "01J..."
}
detail is prose and may be reworded. code is the contract: a stable dotted
identifier a client branches on, and the subject of this page. status is the HTTP status;
where a code can carry more than one, the table says so. request_id is the request's
X-Request-ID, which is also the correlation id of every event the request appended — quote
it in a bug report and the whole causal chain can be found.
details is free-form per code and is documented below where it carries something a client
can act on. Two rules hold everywhere: it never echoes the offending input (a rejected
password does not come back in a validation error), and where it names an id, that id is one
the caller itself sent. A refusal that says "not found" and echoes the id you asked for tells
you nothing you did not already know, which is the point — see
Not found means several things.
The kernel CLI prints kernel: <code>: <detail> on stderr and exits 1, so every code here
is greppable at a terminal. Failures the CLI decides for itself carry a cli. code instead
and are listed in the CLI guide; they never come from a server.
Authentication — the caller is not who they need to be (401)
| Code |
Status |
What happened |
What to do |
auth.required |
401 |
No credential at all, or a web session that has expired or been signed out |
Sign in, or send Authorization: Bearer <token>. Some routes are a person's own act and refuse a token even when it is valid — creating an organization is one — so a token holder can meet this on a route that authenticated fine |
auth.invalid_credentials |
401 |
The email or the password was wrong |
Ask for both again. The answer never says which was wrong, and it is the same for an address that was never registered |
auth.invalid_token |
401 |
The bearer token is malformed, unknown, revoked or expired |
Stop using it and obtain another. One answer covers all four on purpose, so probing cannot tell a revoked token from a typo. Revocation takes effect on the very next request |
auth.invalid_enrollment_token |
401 |
The one-time secret presented to POST /machines/enroll is unknown |
Ask an admin for a fresh enrolment token. A daemon must not read this as its own device token being revoked — that is auth.invalid_token — and must not stop |
Authorization — the caller may not do this (403)
| Code |
Status |
What happened |
What to do |
auth.permission_denied |
403 |
The permission matrix refused the action. details.action names it, details.required_role the minimum role, details.role the caller's; details.required_scope is present when it was the token's scopes rather than the role that fell short |
With required_scope: use a token that holds it. Without it: the caller's role is too low, and only a member with a higher role can act or promote them |
auth.scope_escalation |
403 |
A token tried to mint a token holding scopes it does not hold itself, or to make a change that hands one out. details.scopes lists the scopes that were refused |
A token never widens anything. Mint from a wider credential: kernel auth login --scope … mints under the browser session a password bought, which is bounded by the role alone |
auth.project_scope |
403 |
A token confined to specific projects reached outside them in a way that cannot be answered as "not there": minting a token that is not confined to those same projects (details.project_ids echoes the ids the caller sent, empty when the caller asked for no confinement at all), or an act that cannot be confined to a project at all such as creating one (details.action names it) |
Use an unconfined token. A confined token cannot widen itself, by design; nothing it can send will make this succeed |
auth.session_scope |
403 |
A session token reached past its own session, or past its session's work item. details.resource and details.id echo what was asked for |
A session token is issued for one session and is not a general credential. Use the token that created the session |
auth.machine_scope |
403 |
Something other than a machine's own daemon asked for that machine's command queue. details.machine_id names it |
Only the enrolled machine takes its own commands: taking one hands back a session token meant for that daemon. Reading a machine, beating for it and acknowledging a command stay open to members |
auth.csrf_failed |
403 |
An unsafe cookie-authenticated request carried no X-CSRF-Token, or the wrong one |
Send the token the session issued. Bearer-authenticated requests are not subject to CSRF and never meet this |
auth.origin_mismatch |
403 |
An unsafe request's Origin or Referer named a host other than the request host |
Send the request from the app's own origin. A cross-site caller cannot fix this, which is the intent |
saved_filter.no_owner |
403 |
The caller tried to rename, re-define or delete a saved filter they do not own (a shared filter may be deleted only by its owner or an org admin) |
Only the filter's owner may edit or delete it; an admin may delete a shared one. A non-owner can save their own filter instead |
invitation.email_mismatch |
403 |
The invitation was issued to a different email address than the signed-in user's |
Sign in as the invited address, or ask for a new invitation |
Not found means several things
| Code |
Status |
What happened |
What to do |
resource.not_found |
404 |
No such row for this caller. details.id echoes the id from the request |
Four situations answer identically and deliberately: the id names nothing; it names a row of another organization; it names a row a project-confined token may not see; and it names somebody else's token. A caller cannot tell them apart, so a 404 is never evidence that an id is free |
resource.gone |
410 |
The row existed and is no longer usable |
Not retryable. Obtain a fresh one — an expired invitation has to be reissued |
The change cannot happen now
| Code |
Status |
What happened |
What to do |
state.illegal_transition |
409 |
The aggregate's state machine has no such move. details.state is where it is, details.command what was asked; for Work, details.allowed lists the commands that would succeed |
Read details.allowed and send one of those, or refresh — somebody else may have moved it. A daemon reporting the end of a session treats this as a no-op: the session had already ended |
resource.closed |
409 |
A change with no command at all — an update, a heartbeat, a new session, a new blocker — on a closed aggregate: terminal Work, an ended session, a resolved blocker, an archived project |
reopen the Work first, or unarchive the project. Completed and cancelled Work still accepts the records that describe what happened (artifacts, decisions, outcomes) and refuses everything that would continue it. Like state.illegal_transition, a no-op for a daemon reporting an end twice |
resource.conflict |
409 |
A precondition on another row failed — a disabled runtime, an expired approval, a session already continuing a handoff, a superseded decision — or the database refused the write on a constraint |
Read detail, fix the named row, retry. The constraint case never leaks SQL or the failing values |
work.graph_cycle |
409 |
Adding this dependency (blocks/blocked_by) or setting this parent_work_id would create a loop in the project's precedence graph; details.source and details.target name the two ends |
Point the dependency or parent the other way, or remove the link that would close the loop. Precedence must stay acyclic so the graph and any ordering over it terminate |
capability.unsupported |
409 |
The runtime cannot do what was asked; send_instruction on an adapter without a channel is the usual one |
A fact about the adapter, not a state of the row: the same request would be accepted by another runtime, and retrying this one will not help |
org.slug_taken |
409 |
The slug is in use. details.slug names it |
Choose another, or send no slug at all and let it be derived (a repeat gets a numbered suffix) |
org.last_owner |
409 |
The change would leave the organization with no owner |
Promote another member to owner first, then retry |
org.already_member |
409 |
The invited address already belongs to the organization. details.email names it |
Nothing to do: they are already in |
invitation.used |
409 |
The invitation was already accepted |
Ask for a new one |
invitation.expired |
410 |
The invitation's lifetime ran out |
Ask for a new one |
auth.email_taken |
409 |
Registration named an address that is already registered. details.email names it |
Sign in instead, or reset the password |
tenant.cross_organization_reference |
400 |
A request named a row belonging to a different organization than the one it is acting in |
A bug in the caller: the reference cannot be followed. Not a 404, because the row is one the caller may well be able to see — through the right organization |
The request was not well formed
| Code |
Status |
What happened |
What to do |
validation.failed |
422 |
The body, the query or a path parameter did not validate. details.errors is a list of {"loc", "msg", "type"}, loc being the path to the offending field (["body", "name"], ["path", "organization_id"]) |
Fix the named fields. The input itself is never echoed back, so a secret sent to the wrong field does not reappear in the error |
jobs.unknown_kind |
400 |
A background job was enqueued for a kind nothing in this build handles |
A deployment or a programming error, not something a client sends. If it appears after a deployment, an old row is being claimed by a build that no longer knows its kind |
request.body_too_large |
413 |
The request body passed KERNEL_MAX_REQUEST_BYTES (26 MiB by default), the ceiling every body-accepting route shares. details.max_bytes is the limit |
Send a smaller body. The refusal happens at the ASGI receive channel while the body is still arriving — before any reader (the CSRF check included) buffers it — so nothing over the limit is read. Distinct from artifact.too_large, which the artifact routes raise against their own, tighter, artifact-specific cap |
Idempotency
Idempotency-Key lets a client retry an unsafe request without making the change twice; the
key is scoped to the user and the route (kernelos.api.idempotency).
| Code |
Status |
What happened |
What to do |
idempotency.key_reused |
409 |
The same key arrived with a different request |
Use a fresh key. A key names one request, not one attempt |
idempotency.in_progress |
409 |
The same key is being processed by a concurrent request right now |
Wait and retry the same key: the first attempt is still running, and its answer will be replayed |
idempotency.unsupported |
400 |
The route cannot replay its answer and therefore refuses the header |
Send the request without the key. Creating an invitation is the case: replaying it would mean storing the plaintext token, which is what hashing it avoids. Retrying simply creates a fresh invitation |
Too many requests
| Code |
Status |
What happened |
What to do |
auth.rate_limited |
429 |
Too many attempts too quickly — sign-in failures, or a repeated export |
Wait. The response carries a Retry-After header and details.retry_after_seconds; honour it rather than polling |
Artifacts and uploads
An upload describes itself three times — the declared content type, the filename's
extension and the bytes — and Kernel stores it only when the ones it can check agree.
These codes say which description was refused. A content type Kernel does not recognise is
not an error: it is stored as application/octet-stream, because an allowlist that
decides whether an artifact may exist is one the next file format outgrows. The filename is never a path and never the storage key: the
key is minted by Kernel and never appears in a response.
| Code |
Status |
What happened |
What to do |
artifact.too_large |
413 |
The upload passed KERNEL_ARTIFACT_MAX_BYTES (25 MiB by default). details.max_bytes is the limit |
Send a smaller artifact, or record it by external_uri instead. The refusal happens while the body is still arriving, so nothing over the limit is ever written |
artifact.type_mismatch |
415 |
The bytes are not what the upload said they were: the magic bytes name another type, a binary type carried no signature, a text type carried binary, or a known extension names a different type. details.declared and details.detected say which two disagreed |
Send the real content type, or the file the type describes. Kernel will not relabel a file for you |
artifact.checksum_mismatch |
422 |
The upload declared a size_bytes or a sha256 and the stored bytes do not bear it out. details carries the claim and the measurement side by side |
Send the file again. The pair distinguishes a truncated upload from a wrong claim; nothing was stored and no artifact was created |
artifact.rejected |
422 |
The installation's malware scanner refused the upload; details.reason is whatever it said |
Nothing was stored and no artifact was created. Ask the operator who configured the scanner |
artifact.no_content |
409 |
GET /artifacts/{id}/content on an artifact that records content held elsewhere. details.external_uri is where it lives |
Fetch the URI yourself. Kernel does not follow a URI an agent stored |
artifact.invalid_key |
500 |
A stored artifact's storage key is not one this store can address |
Never caused by a request: keys are minted, so this means a row was written by something other than Kernel |
artifact.s3_unavailable |
500 |
KERNEL_ARTIFACT_STORAGE=s3 on an installation without the s3 extra |
Install it: pip install 'kernel-agentos[s3]' |
Runtimes
These come from the adapter layer. A daemon meets them locally; they reach an API response
when a runtime is driven inside a request.
| Code |
Status |
What happened |
What to do |
runtime.unknown_adapter |
400 |
No adapter in this build serves that runtime key or adapter type |
Check the runtime registry (kernel runtime list); the build may predate the runtime |
runtime.adapter_error |
500 |
An adapter could not do its job — as opposed to refusing, which is capability.unsupported |
Retryable in principle. kernel runtime doctor reports what the adapters on a machine actually find |
runtime.channel_blocked |
500 |
The runtime stopped reading what Kernel was telling it — wedged, or busy in something that never returns |
The session is not going to hear anything more. End it rather than waiting on a pipe that will not drain |
runtime.disabled |
409 |
A session was asked to start on a runtime this organization has switched off — a built-in disabled in the organization's settings, or one of its own runtimes with enabled false |
Enable the runtime in the registry, or start the session on an enabled one. The scheduler already excludes disabled runtimes |
runtime.launch_failed |
500 |
The runtime's process could not be started, or died before it said anything |
Check the adapter's binary and the workspace on that machine, with kernel runtime doctor |
session.wait_timeout |
408 |
The wait-for-session long poll reached its server deadline while the session was still running |
Not an error — reissue the wait (kernel session wait does this until its own --timeout); the session has not ended |
Faults and transport
| Code |
Status |
What happened |
What to do |
internal.error |
500 |
An unhandled exception. The body carries no exception text and no SQL; the server logged the failure against this request_id |
Retry once; if it persists, quote the request_id |
http.method_not_allowed |
405 |
The path exists, the method does not |
Check the OpenAPI document at /api/v1/openapi.json |
http.error |
varies |
Any other bare HTTP failure raised before a route was reached; the status is the real one |
Read status. A body that is not a problem document at all did not come from Kernel — a proxy's HTML error page is not this |
mcp.transport_unavailable |
503 |
POST /mcp with a valid bearer on a build that has no MCP HTTP transport (kernelos.mcp.server.http_transport is absent). The gate in front of it still refuses a missing or bad bearer with auth.invalid_token, so this is only ever seen after authenticating |
Use kernel-mcp over stdio against the same server, or a build that ships the MCP package. Retrying against this server will not help; see docs/mcp.md |
kernel.error |
400 |
The base of the error hierarchy, carried by an error that set no code of its own |
You should never see it. It means a raise site omitted its code; worth reporting |
Codes a server never sends
The kernel-daemon process raises these about its own work. They travel in its logs and
its session reports, never in an API response — a client polling /api/v1 will not meet
them.
| Code |
Status |
What happened |
daemon.request_failed |
502 |
A request from the daemon to Kernel did not succeed |
daemon.server_unavailable |
502 |
It may work later: the daemon keeps what it was carrying and backs off |
daemon.request_refused |
502 |
It will not work later: the daemon drops what it was carrying and records why |
daemon.token_revoked |
401 |
This machine's device token no longer works, so the daemon stops |
daemon.workspace_refused |
403 |
A command named a workspace this daemon is not allowed to run a session in |
daemon.session_refused |
409 |
This daemon will not run that session, and the reason is worth recording |
daemon.launchagent_failed |
400 |
The macOS service wrapper could not do what was asked |
Keeping this page true
tests/unit/domain/test_errors.py walks every code string in src/kernelos/ — the code
attribute of each error class and every code= a raise site overrides — and fails unless
each one appears in a table above and each row here names a code that exists. The overridden
ones are why: auth.project_scope and auth.scope_escalation are PermissionDenied with a
code of their own, so a test that walked only the classes never saw them, and they went
undocumented for four milestones (KI-027). Adding a code to src/ now means adding a row
here in the same change.