Errors
Every error code Kairos returns, its HTTP status, and what its details
carries.
The envelope
Section titled “The envelope”Every /api error has the same shape:
{ "error": { "code": "RESTORE_BLOCKED", "message": "The server cannot restore PLATFORM-T-0012. The item needs its board column (removed). …", "details": { "missing": ["its board column (removed)"] } }}code is stable and safe to branch on. message is for a human and may
change. details is present only where a code has something structured to
say; the table below states which do.
/scim/v2 uses the RFC 7644 envelope instead, not this one — except for a
path under /scim/v2 that is not routed at all, which answers a NOT_FOUND
in this envelope. See SCIM.
Generic
Section titled “Generic”| Code | Status | Meaning | details |
|---|---|---|---|
UNAUTHORIZED |
401 | No token, or a token that does not verify | — |
FORBIDDEN |
403 | Authenticated, but not allowed | one of five shapes — see below |
KEY_SCOPE |
403 | The request uses an agent key, and the scope of the key does not have the action. See The scope of an agent key | required_capability, board_id, scope_needs (the capability that the scope must have), reason (capability, board or no_board), key_scope (capabilities, boards) |
MEMBERSHIP_REQUIRED |
403 | Authenticated against the issuer, but not a member of this tenant | organization — the slug that was resolved |
NOT_FOUND |
404 | The thing the call is about does not exist | — |
TENANT_NOT_FOUND |
404 | The request host resolves to no provisioned tenant | — |
TENANT_NOT_READY |
503 | The schema of the organization did not migrate when the server started. The other organizations serve. An operator repairs the schema and runs kairos-server migrate-tenants; the organization then serves again within 30 seconds, with no restart |
— |
REQUEST_TIMEOUT |
503 | A request under /api or /mcp did not get its answer in KAIROS_REQUEST_TIMEOUT_SECS (30 seconds by default). The server stopped the request. A write can be complete or not complete: read the data again before you send the write again. See Request timeout |
timeout_secs |
CONFLICT |
409 | A state conflict. Usually optimistic concurrency: the submitted version is stale. Also emitted where a create collides with an existing row, or a delete is blocked by what still points at the entity |
current — the full current entity — on a version conflict only; see below |
VALIDATION |
422, or 400 on POST /api/search. 400 or 415 for an input that the server cannot read |
A body, a query or a reference is malformed, or names something that does not exist | field/fields where a specific field is at fault, plus any typed extras (e.g. cap, limit, offset, depth). field and allowed for a field that the route does not know. parameter and allowed for a query parameter that the route does not know. field and header for an Idempotency-Key that does not obey the rule (KAIROS-T-0686) |
INTERNAL |
500 | Server fault; the message is logged, not returned in detail | — |
VALIDATION is 422 everywhere except POST /api/search, which answers
400 for every rejection kairos_core::search::validate and the DTO
conversion produce — a blank q, an inverted date range, a bad limit or
offset, a missing or over-cap traverse.depth, a malformed UUID or
timestamp, an out-of-vocabulary enum value, an unknown field. The code is the
same; the status is not. Branch on the code.
Failed authentication
Section titled “Failed authentication”The throttle of failed logins answers 429 with a Retry-After header. See
Failed-authentication throttling.
| Code | Status | Meaning | details |
|---|---|---|---|
TOO_MANY_REQUESTS |
429 | Too many password logins failed for the account or from the address | retry_after_secs |
KEY_LOCKED_OUT |
429 | This API key failed too many times. The other keys from the same address continue to work | retry_after_secs, limit (key) |
ADDRESS_LOCKED_OUT |
429 | Too many API keys from this address failed. The server refuses each key from the address that is not in its auth cache | retry_after_secs, limit (address) |
The text of each code is the same for each key and each account. Thus a 429 does not tell which keys or accounts exist.
Notices
Section titled “Notices”| Code | Status | Meaning | details |
|---|---|---|---|
NOTICE_RATE_LIMITED |
429 | The agent key (or the person with no key) sent the limit of notices in the last hour. Kairos sent nothing | limit, window_secs, retry_after_secs |
An input that a route does not accept
Section titled “An input that a route does not accept”The server refuses each input that a route does not know. The server does
not ignore the input: an input that does nothing tells the client that it did
something. The refusal is VALIDATION, the message names the input, and the
server writes nothing. The rule has three parts:
| The input | The rule | Status |
|---|---|---|
| A field of the body | A route refuses a field that it does not know | 422 |
| A query parameter | A route refuses a parameter that it does not know. A route with no query parameters refuses each parameter | 400 |
| The body | A route with no body refuses a body that is not empty | 400 |
The rule applies to each route below /api, the GET routes too, and to
/ws/events.
A field of the body
Section titled “A field of the body”Each write route of the API has a body with a known set of fields. The table of
the body in Schemas gives the set. In the OpenAPI spec the
schema of the body has additionalProperties: false.
{ "error": { "code": "VALIDATION", "message": "The body has the field \"board_level\". This route does not accept that field. The fields of the body are: name, slug, team_id.", "details": { "field": "board_level", "allowed": ["name", "slug", "team_id"] } }}details.field is the first unknown field that the server finds. details.allowed
is the list of the fields of that body. For a field in an object of the body, the
two are about that object.
The body of an update has the fields of the update only. Do not send back the
full object that a GET gave. Fields such as id, created_at and
board_level are not fields of an update.
A body that the route cannot read for a different reason has the same envelope and the same code. The status tells the reasons apart:
| The body | Status | details |
|---|---|---|
| Has a field that the route does not know | 422 | field, allowed |
| Has a different fault of shape: a required field is absent, or a value has the wrong JSON type | 422 | — |
| Is not JSON | 400 | — |
Has no Content-Type: application/json header |
415 | — |
On POST /api/search the first two are 400, as each refusal of that route is.
A query parameter
Section titled “A query parameter”The page of each route in the REST API gives its query
parameters. GET /api/boards?limit=5&page=2 has this refusal:
{ "error": { "code": "VALIDATION", "message": "The request has the query parameter \"page\". This route does not accept that parameter. The query parameters of this route are: limit, offset.", "details": { "parameter": "page", "allowed": ["limit", "offset"] } }}details.parameter is the first unknown parameter that the server finds.
details.allowed is the list of the parameters of the route. For a route with
no query parameters the list is empty, and the message says This route has no query parameters.
A parameter with a value that the route cannot read has the same status and the
same code. GET /api/boards?limit=many has the message The value of the query parameter "limit" is not correct: invalid digit found in string. Its details
has parameter only.
access_token is the one query parameter of /ws/events.
A body on a route that has none
Section titled “A body on a route that has none”These routes accept no body:
- each
GETand eachDELETE - a restore:
POST /api/{family}/{short_code}/restore - the confirm and the reject of a proposal
- the rotate of a forge connection
POST /api/logout
A request to one of them with a body has this refusal:
{ "error": { "code": "VALIDATION", "message": "The request has a body. This route does not accept a body. Send the request with no body.", "details": {} }}A body of zero length is no body. {} and null are bodies, and the server
refuses them.
The routes that the rule does not apply to
Section titled “The routes that the rule does not apply to”A third party defines the inputs of these routes, so the rule does not apply:
| Route | The inputs are those of |
|---|---|
/scim/v2 |
RFC 7644. It tells a SCIM server to ignore an attribute that it does not know |
/webhooks |
The git forge |
POST /api/auth/token |
The OAuth standard |
/mcp, /.well-known/oauth-protected-resource |
The MCP transport and RFC 9728. The arguments of a tool have the rule: see below |
/healthz, /readyz, /metrics |
The probe of the platform that runs the server |
/version |
The CLI, which compares the version of the server with its own |
| Each path of the GUI, and its files | The browser. The GUI reads the query of its own pages |
Each MCP tool has the same rule for its arguments. See MCP tools.
FORBIDDEN’s details has five shapes, and which one arrives depends on
what kind of gate refused:
| Shape | Emitted by |
|---|---|
required_capability, board_id (null = the item or the write has no board, and the caller needs the admin role) |
a board capability check, or the edit rule. For the edit rule, the caller also did not create the item |
required_capability, board_id, held: "file_backlog" |
an MCP move (transition_item, move_item) refused to the person who sent a request, while the request is in the entry column of its board. The message says that the item is a request, that the team of the board moves it, and that the caller can edit, link and archive it |
required_role: "admin" |
tenant-wide configuration: templates and metadata definitions |
relationship, required_capability, board_id, any_of |
an edge of any relationship type where the caller can edit neither end (the link rule). required_capability and board_id are those of the source. any_of has one entry for each end: end, required_capability, board_id. For the create of a blocks edge, required_capability and board_id are those of the target, any_of has the target first, and each entry has gives (edge or proposal). For the supports edge of a document, the caller cannot edit the document: any_of has one entry, the target, and required_capability is manage_documents |
required: "deployment_admin" |
the cross-tenant provisioning routes, which are not scoped to any organization |
CONFLICT is not only optimistic concurrency. details.current is present
only on a version conflict; a client that reads it unconditionally will find
nothing on the others. The other 409s and what they carry instead:
| Condition | details |
|---|---|
| Deleting a team that still owns repositories | repositories — the slugs to re-home |
| Registering a repository whose slug is taken | — |
| The create or the update of a board with the slug of a live board. The create of a team whose delivery board gets such a slug | slug, and board (id, name): the board that has the slug |
| Registering a repository already registered for that forge | — |
| A confirm or a reject of an edge proposal that is decided already. Of two decisions at the same time, one gets this | — |
DEFINITION_IN_USE (its own code; see below) |
its own fields |
The distinction to internalise, because it decides how a client branches:
a reference that does not resolve is VALIDATION; the call’s own subject not
existing is NOT_FOUND. Creating a task that names a repository slug that does
not exist is VALIDATION — the repository was a reference in the body.
Fetching /api/tasks/ACME-T-9999 is NOT_FOUND — the task was the subject.
There is one exception: search refuses an unresolvable traverse.from
with NOT_FOUND, not VALIDATION, even though it is a reference in the body.
A client applying the general rule would branch wrongly here.
A retired short code
Section titled “A retired short code”When an item gets a new short code, its old code becomes a retired code. A
read, for example GET /api/tasks/{short_code} or the history, finds the item
from a retired code. The response has the item with its current short_code.
Kairos refuses a write with a retired code with NOT_FOUND. The message names
the current code, and details has these fields:
| Field | Value |
|---|---|
retired_code |
The retired code of the request. |
current_code |
The current code of the item. Send the write again with this code. |
A reference in a write follows a retired code. For example, the
source_short_code and the target_short_code of POST /api/relationships
follow it. The response has the current codes.
A limit or an offset that is out of the range
Section titled “A limit or an offset that is out of the range”The server does not refuse a limit or an offset that is out of the range.
It uses the nearest value of the range. A limit above the maximum becomes
the maximum. A limit below 1 becomes 1. An offset below 0 becomes 0. The
response gives the limit and the offset that the server used.
This rule applies to each list route and to POST /api/search. It also
applies to the MCP tools and the CLI commands that have these arguments. The
server refuses a value that is not an integer, with VALIDATION.
Board and column configuration
Section titled “Board and column configuration”| Code | Status | Meaning | details |
|---|---|---|---|
INVALID_TRANSITION |
422 | The move is not an edge in the board’s transition graph | from and to (id, name each) and allowed_targets — the columns reachable from the current one, as id/name pairs |
ITEM_NOT_ON_BOARD |
422 | The item has no board placement, so it cannot transition or be moved: an off-board ADR, and over MCP a document, which never has one | — |
NOT_CLAIMABLE |
422 | A hand-off or a release of the claim of a task that is not in a column that holds claims (Active) (KAIROS-T-0359) | — |
NO_CLAIM |
422 | A release of the claim of a task that has no claim | — |
TASK_DONE |
422 | A cancel of a task that is in a done column (KAIROS-T-0362), or a move to another board of a task that is in a done column (KAIROS-T-0564). Reopen the task first | short_code and fix for a move |
NO_DONE_COLUMN |
422 | A cancel of a task whose board has no done column | — |
TASK_LIVE |
422 | A purge of a task that is not archived (KAIROS-T-0570). Archive the task first | — |
TASK_NOT_DONE |
422 | A reopen of a task that is not in a done column (KAIROS-T-0564) | — |
NO_OPEN_COLUMN |
422 | A reopen of a task whose board has only done columns | board_id |
AGENT_TRIAGE_OFF |
403 | An agent (agent key or service account) triaged intake — a support-lane task in the entry column: a lane change to planned, a move out of the entry column, a move to another board, a cancel, an archive or a purge (KAIROS-T-0537) — on a board that lets a person decide (KAIROS-T-0428) | board_id and setting (agents_decide) |
BLOCKED |
422 | On a board with the policy blockers_gate on (KAIROS-T-0565): a move of a task with open blockers into a claims column or a done column. A transition, a reopen and a move to the board get it. A cancelled blocker is open. Complete each blocker, or remove its blocks link |
task, to_column, board_id, blockers (each with short_code and cancelled), policy (blockers_gate) and fix |
OWNER_REQUIRED |
422 | On a board with the policy owner_gate on (KAIROS-T-0643): a move of an initiative into Active while it has no owner, or a release of the owner of an initiative in Active or Monitoring. Claim the initiative first, or hand it to a member of the board |
initiative, to_column, board_id, policy (owner_gate) and fix for a move; column, policy and fix for a release |
PARENT_NOT_ACTIVE |
422 | A move of a task into a claims column (Active) while its parent initiative is not active, on a board with the policy parent_gate on (KAIROS-T-0641). A transition, a move to the board, a reopen and a create get it. Move the initiative to Active first |
initiative, initiative_column, to_column, board_id, policy (parent_gate) and fix |
COLUMN_NOT_EMPTY |
422 | The column still holds live items | item_count — live items only; archived ones do not count — and column (id, name) |
BOARD_NOT_EMPTY |
422 | The board still holds live items | item_count and items — the blocking short codes, capped at 20 even when item_count is higher. Deleting a team adds board_id and board (id, name, slug): the board that is not empty. A team of old data can have 2 or more delivery boards, and the delete of the team examines each |
DUPLICATE_COLUMN_NAME |
422 | A live column of that board already has the name | — |
DUPLICATE_COLUMN_POSITION |
422 | A live column of that board already holds the position | — |
DUPLICATE_TRANSITION |
422 | That edge already exists | — |
NO_ENTRY_COLUMN |
422 | The target board has no column to admit an arriving item | — |
TEAM_HAS_DELIVERY_BOARD |
422 | The team has a live delivery board. A team has only one delivery board | team_id and board (id, name, slug), the board that the team has |
TEAM_HAS_ADR_BOARD |
422 | The team has a live ADR board. A team has only one ADR board | team_id and board (id, name, slug), the board that the team has |
BOARD_OWNS_DOCUMENTS |
422 | The board is the owner of live documents. Name a different board for each document, or archive it. The delete of a team gives this code for a delivery board of the team | board_id, item_count and items: the short codes of the documents, capped at 20 |
LAST_DELIVERY_BOARD |
422 | The board is the only delivery board of a team. Delete the team to remove the team and its board together | board_id and team (id, name) |
BOARD_TEAM_IS_FIXED |
422 | The update of a board has a team_id that is not the team of the board. The team of a board does not change. Move the task to give work to a different team |
board_id and team_id, the team that the board has |
CODE_PREFIX_IS_FIXED |
422 | The body of a route other than a create has the field code_prefix, for example the update of a board. The short-code prefix of a board does not change. To give an item a code with a different prefix, move it to a different board with a rename |
field: code_prefix, and allowed: the fields of the body |
Moving and restoring work
Section titled “Moving and restoring work”| Code | Status | Meaning | details |
|---|---|---|---|
SAME_BOARD |
422 | The move’s target is the board the item is already on | — |
NOT_DELIVERY_BOARD |
422 | Cross-board moves are between delivery boards only. “Archive completed” (POST /api/boards/{id}/archive-completed) is for a delivery board only |
— |
RENAME_NOT_NEEDED |
422 | A move with a rename to a board whose prefix the code has already, or a rename of a document whose owner board does not change. Nothing changes. Do the move with no rename | field: rename. For a prefix, also short_code and code_prefix |
REQUEST_KEY_REUSED |
409 | An MCP write with a request_key, or a REST write with an Idempotency-Key header, that an earlier, different request of the same caller used in the last 24 hours: a different tool or route, or different arguments. Nothing is written. Use a new key for a new request (KAIROS-A-0065). For a REST route, first_tool is the method and the route of the first request (POST /api/tasks) |
request_key, first_tool, first_call_at |
SECTIONS_NOT_PASSED |
422 | MCP create_item, update_item and edit_item only (KAIROS-T-0730). The content of a document does not pass the typed sections of its template: a section is missing, a section has no text, or the text of a plain or an ears section breaks a rule. Nothing is written. Correct each section, and then send the write again. See Typed sections on an MCP write |
field (content), sections: each section that does not pass, with heading, type, problem (missing, empty or violations) and violations (each with rule, text and fix) |
RESTORE_BLOCKED |
422 | The item’s board, column, owning team or repository has been removed, so it has nowhere to return to. For a document, the board is its owner board | missing — a list naming each thing that is gone |
DICTIONARY_EXISTS |
409 | The board strategy owns a live dictionary already. An organization has one dictionary (KAIROS-T-0733, KAIROS-A-0079). The server refuses a second one at a document create. A restore of an archived dictionary next to a live one is RESTORE_BLOCKED. Edit the dictionary of the organization, or archive it first |
board: the slug of the board. existing: the code of its dictionary |
DICTIONARY_NOT_ON_STRATEGY |
422 | A dictionary on a board that is not the board strategy. An organization has one dictionary, on the board strategy, and a team has none (KAIROS-A-0079). The server refuses it at a document create and at a change of the owner board. A restore of an archived dictionary on a different board is RESTORE_BLOCKED. Put the dictionary on the board strategy, or add the terms to the dictionary of the organization |
field: board. board: the slug of the board of the write. dictionary_board: strategy |
Each document has an owner board. The server refuses a document create with
no board. It also refuses a change of the owner board with no board. The
refusal is VALIDATION with details.field = board (MCP: board, or
to_board for move_item). See
A document always has an owner.
Relationships
Section titled “Relationships”| Code | Status | Meaning | details |
|---|---|---|---|
CYCLE_DETECTED |
422 | The edge would create a cycle | — |
RELATIONSHIP_RULE |
422 | The edge is not legal between those two entity types. For an impacts link: the subject is not a document and not an ADR |
relationship. For an impacts link, also source_type and allowed_source_types |
ALREADY_LINKED |
422 | That edge already exists. For an impacts link: the item impacts that repository already |
— |
PARENT_EXISTS |
422 | A parent edge to an item that has a different parent. An item has one parent (KAIROS-T-0572). The message names the call that changes the parent: PUT /api/{entity_type}/{short_code}/parent, or MCP set_parent |
item, parent: the short codes of the item and of its parent |
Tenant configuration
Section titled “Tenant configuration”| Code | Status | Meaning | details |
|---|---|---|---|
DEFINITION_IN_USE |
409 | The metadata definition is still referenced | item_values, template_fields (the true counts) and items, templates (which ones). Each items entry is {short_code, archived} — an archived carrier is marked, and still blocks. items is capped at 20, so a definition stamped on thousands of items names twenty of them and counts the rest |
SLUG_CONFLICT |
422 | The slug is taken | — |
PROTECTED_PAGE |
422 | The team page is protected and cannot be removed | — |
NOT_ARCHIVED |
422 | A restore of a team page that is not archived | — |
LAST_ADMIN |
422 | The change would leave the organization with no admin | — |
CONFIRMATION_REQUIRED |
422 | A destructive call needs an explicit confirmation flag | — |
Note DEFINITION_IN_USE is 409, not 422, unlike its neighbours — it
reports a state conflict rather than a malformed request.
The code index
Section titled “The code index”| Code | Status | Meaning | details |
|---|---|---|---|
NO_QUERY_PROVIDER |
422 | POST /api/code-index/query-vector names a model of vectors that the organization has no provider for: not the embedded model of the server, and not the remote provider of the organization. The vector provider changed after the index was made. Build the index again |
model |
QUERY_VECTOR_FAILED |
502 | The provider of the model did not give the vector of the query: the remote endpoint failed or did not answer, its secret does not open, or the vector has a different dimension | model |
Deployment and integration
Section titled “Deployment and integration”| Code | Status | Meaning |
|---|---|---|
WEBHOOK_REJECTED |
401 | A forge webhook failed signature verification, or named an unknown tenant or connection — one indistinguishable refusal for all of them |
FORGE_NOT_CONFIGURED |
501 | The operation needs a forge connection the tenant has not set up |
PUBLIC_URL_NOT_CONFIGURED |
501 | The operation needs a publicly reachable URL the deployment has not been given |
SECRETS_NOT_CONFIGURED |
501 | The deployment has no KAIROS_SECRETS_KEY, so the server cannot keep the read token of a repository. details.setting names the setting |
CREDENTIAL_UNREADABLE |
409 | The stored read token of a repository does not decrypt: the key changed, the key is not set, or the row was changed. Set the token again |
IDP_UNREACHABLE |
502 | The OIDC issuer could not be reached |
WEB_DIST_MISSING |
503 | The GUI bundle is absent from the running binary |
WEBHOOK_REJECTED is served from /webhooks/{forge}/{tenant}/{connection_id}
rather than /api, and its envelope carries code and message only — no
details key at all.
What archived work refuses
Section titled “What archived work refuses”Archived items are readable and frozen, so a write to one is refused as though
the item were not there: NOT_FOUND with a message naming the short code. The
reason it is not a distinct code is that nothing a client can do differs — it
has to restore the item first either way. See
Archiving.
Related guides
Section titled “Related guides”The refusals a reader most often arrives here from:
RESTORE_BLOCKED→ Find archived workBOARD_NOT_EMPTY, and a team that will not delete → Wind down a teamCOLUMN_NOT_EMPTY,INVALID_TRANSITION,DUPLICATE_COLUMN_NAME→ Set up a boardSAME_BOARD,NOT_DELIVERY_BOARD→ Move work between boardsFORBIDDENorVALIDATIONon a task that you create for a different team → Send a request to a different teamVALIDATIONwithdetails.field=boardon a document → A document always has an ownerBOARD_OWNS_DOCUMENTS→ The owner of a documentFORGE_NOT_CONFIGURED,WEBHOOK_REJECTED→ Connect a git forgeSECRETS_NOT_CONFIGURED,CREDENTIAL_UNREADABLE→ Read a private repository
Related reading
Section titled “Related reading”- Capabilities — what
FORBIDDEN’srequired_capabilityrefers to - MCP tools — which of these codes each tool can return
- REST API — the per-endpoint response tables