Kernel

← Documentation

API error codes

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.

Keyboard shortcuts

Shortcuts are a faster way to do what the keyboard already does, never the only way.

?
Show this help
Esc
Close dialogs
/
Search
g h
Go to Home
g p
Go to Projects
g w
Go to Work
g n
Go to Needs You
g r
Go to Runtimes
g a
Go to Activity
c
Create Work
j k
Move between rows in Needs You and work lists
Enter
Open the focused row
a r
Approve or reject the focused approval
e
Edit the open item