The Kernel API
A reference for integrators building against Kernel's HTTP API — the same API the CLI, the
web app and the daemon use. Every route documented here lives under /api/v1, speaks JSON,
and is described in the machine-readable OpenAPI document the server publishes at
/api/v1/openapi.json. This page is the prose around that document:
the cross-cutting contracts (auth, scopes, pagination, idempotency, rate limiting, errors,
webhooks) that every route shares, and a map of the resource families. For the stable error
code a client branches on, see docs/api-errors.md.
Two runnable examples accompany this page:
examples/integrations/external_agent/ registers
an external session and reports on it, and
examples/integrations/webhook_receiver/
verifies a delivery signature. Both are exercised by the test suite, so they cannot drift
from what the API does.
Base URL and versioning
Every endpoint is prefixed /api/v1. v1 is the contract version, not a router detail: a
change that would break a client built against these shapes lands under a new prefix rather
than mutating this one. Additive changes — a new optional field, a new route — happen in
place. The OpenAPI document is the source of truth for shapes and is what to generate a
client from.
https://<your-kernel-host>/api/v1/...
Authentication
Most routes require a credential. There are two kinds:
- A bearer token — send
Authorization: Bearer <token>. This is what agents, daemons and scripts use. A token's plaintext has the formkrn_<prefix>_<secret>: an 8-character public prefix that identifies the token in listings and logs, and a 43-character secret that is shown once, in the201that minted it (POST /api/v1/organizations/{id}/tokens), and never again — Kernel stores only its hash. - A cookie session — what the web app uses. A signed-in browser carries a session cookie;
unsafe requests (
POST,PATCH,DELETE) under a cookie must also carry the session's CSRF token inX-CSRF-Token. Bearer requests carry no cookie and are not subject to CSRF.
When both a cookie and a bearer token are present, the bearer wins.
Whoami for a credential
GET /api/v1/tokens/self returns the token this request authenticated with — its name,
organization, scopes, project restriction and last-used time — without its hash. Use it to
show what a credential holds rather than infer it. It needs no scope: a credential may
always introspect itself. A cookie session holds no API token and is answered 404 here; the
person behind a cookie is GET /api/v1/me, and the raw caller of any request (token or
cookie) is GET /api/v1/auth/whoami.
Some routes are a person's own act and refuse a token even when it is valid — creating an
organization is one — answering 401 auth.required. Mint tokens under an organization that
already exists.
Changing your own password
POST /api/v1/me/password takes {current_password, new_password} and is the route the
Profile & security page's form drives, so the page and the API cannot drift. It is a
person's own act: cookie session only (a token is answered 401), CSRF-checked, and no
permission beyond being signed in.
The current password must verify — a stolen cookie alone cannot set a new one. A wrong one
is refused exactly as a failed login is: 401 auth.invalid_credentials with the same
message, for the same single argon2 verification, so neither the answer nor its timing says
anything. A new password that fails the strength rule register applies is 422 validation.failed pointing at body.new_password.
On success every other session of that person is signed out and the caller is issued a
fresh one: the response sets the session cookie and returns {user, csrf_token} exactly as
login does, and the CSRF token from before the change stops working with the session it
belonged to.
Scopes
A token carries a set of scopes; every action checks one. The vocabulary is:
work:read work:write
sessions:read sessions:write
artifacts:read artifacts:write
decisions:read decisions:write
blockers:read blockers:write
approvals:read approvals:request
handoffs:read handoffs:write
runtimes:read runtimes:manage
org:read org:write
webhooks:manage tokens:manage
audit:read analytics:read
admin:*
admin:* is the wildcard: a token holding it satisfies the scope half of every action. A few
acts have no narrower scope on purpose — deciding an approval, deleting an organization,
transferring ownership — and require admin:* explicitly.
A token can never widen itself: minting a token holding scopes the caller does not itself hold
is refused 403 auth.scope_escalation.
How a 403 names what was missing
When a request fails on scope, the problem document carries details.required_scope — the
scope the caller would need:
{
"status": 403,
"code": "auth.permission_denied",
"detail": "org.read requires the scope org:read",
"details": {"action": "org.read", "required_scope": "org:read"}
}
Read required_scope from the response rather than hard-coding a route's scope: it is part of
the API surface, and the CLI's own "you need a token with X" hint is built on it. When the
role rather than the scope fell short, required_scope is absent and details.required_role
names the minimum role instead.
Pagination
List endpoints are keyset-paginated (not offset), so a page is stable while rows are being inserted. Two query parameters:
limit— how many rows,1–200(default50).cursor— an opaque token; omit it for the first page.
The response is {"items": [...], "next_cursor": "..."}. When next_cursor is non-null, pass
it back as ?cursor= to get the next page; a null next_cursor is the last page. The cursor
is opaque — do not parse it. An invalid cursor is 422.
GET /api/v1/organizations/{id}/work?limit=100
GET /api/v1/organizations/{id}/work?limit=100&cursor=eyJ...
Filtering the work listings
GET /projects/{id}/work and GET /organizations/{id}/work narrow with query parameters that
combine with AND. Every one of them is applied in the database, so a filtered page costs the
rows that match and paging under a filter walks only those rows:
state— oneWorkState(backlog,ready,running, …). Both listings.label— one label, matched exactly (no prefix, no substring, case-sensitive), 1–100 characters. Both listings; answered from a GIN index over the work item's labels.owner_user_id,project_id— the organization listing only. Aproject_idbelonging to another organization is404; anowner_user_idthat owns nothing here matches nothing.
GET /api/v1/projects/{id}/work?label=dogfood:M12&limit=100
GET /api/v1/organizations/{id}/work?label=release-blocker&state=running
Prefer ?label= to listing a project and filtering client-side: it is what keeps an
integration keyed on a label — a backlog importer, a release gate — proportional to its own
rows rather than to the project. For anything looser than an exact label, use search
(GET /organizations/{id}/search).
Idempotency
An unsafe request can be retried safely by sending an Idempotency-Key header — any unique
string you choose (a UUID is typical) that names one logical request, not one attempt:
POST /api/v1/projects/{id}/work
Idempotency-Key: 5f1d0c2e-...
The key is scoped to the organization (or user) and the route, so it can never replay across tenants. The semantics:
- Replay. A retry with the same key and the same body returns the original response — same status, same body — without doing the work twice.
- Key reused for a different request. The same key with a different body is refused
409 idempotency.key_reused. A key names one request; use a fresh key for a new one. - In flight. The same key arriving while the first attempt is still running is refused
409 idempotency.in_progress. Wait and retry the same key; the first attempt's answer will be replayed once it lands.
A few routes cannot replay their answer because it carries a one-time secret — creating a
token, creating a webhook, registering a session — and so refuse the header outright with
400 idempotency.unsupported. Retrying one of those simply mints another (and you revoke the
one you lost).
Rate limiting
The API is rate-limited by a token bucket whose counters live in PostgreSQL, so a limit is shared across every worker process rather than multiplied by their number. A bearer token is limited per token; every other caller is limited per client address; login and registration get a stricter per-address allowance on top.
Every answer carries the current budget:
RateLimit-Limit— the per-minute capacity.RateLimit-Remaining— how many requests are left in the window.RateLimit-Reset— seconds until the bucket is full again.
A refusal is 429 auth.rate_limited and additionally carries a Retry-After header (and
details.retry_after_seconds) — honour it rather than polling. The limiter fails open: if
its store is unreachable it lets traffic through unthrottled rather than taking the API down,
so a client should treat 429 as authoritative but its absence as no guarantee.
Errors
Every failure is an RFC 9457 problem document,
Content-Type: application/problem+json, with the same 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 between releases. code is the contract — a stable
dotted identifier to branch on. request_id is the request's X-Request-ID, and 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. The full catalogue of codes, with what each means and what to do
about it, is docs/api-errors.md. Two rules hold everywhere: details
never echoes the offending input, and a 404 resource.not_found covers "no such row",
"another tenant's row" and "a row your token may not see" identically, so a 404 is never
evidence that an id is free.
Webhooks
Kernel delivers events to your endpoints over signed HTTP POSTs. Manage subscriptions with
webhooks:manage:
POST /api/v1/organizations/{id}/webhooks {"url": "...", "event_types": ["work.created"]}
GET /api/v1/organizations/{id}/webhooks
GET /api/v1/webhooks/{id}
PATCH /api/v1/webhooks/{id}
DELETE /api/v1/webhooks/{id}
POST /api/v1/webhooks/{id}/test # send a ping delivery
GET /api/v1/webhooks/{id}/deliveries # the delivery log, with captured responses
POST /api/v1/webhooks/{id}/deliveries/{delivery_id}/replay
Create returns the signing secret (whsec_...) in that one 201 response and nowhere
else. Each delivery is a POST with a JSON body and these headers:
| Header | Meaning |
|---|---|
Kernel-Signature |
t=<unix-seconds>,v1=<hex> — the signature (below) |
Kernel-Event-Id |
the event's id (absent for a test ping) |
Kernel-Webhook-Id |
which webhook this delivery is for |
Kernel-Delivery-Attempt |
1 on the first try, higher on a retry |
Verifying the signature
v1 = HMAC-SHA256(secret, "<t>.<body>") over the exact bytes of the request body. To
verify a delivery:
- Read the raw request body bytes — re-serialising the parsed JSON changes the bytes and breaks the check.
- Parse
Kernel-Signatureintotandv1. - Reject a stale
t: ifabs(now - t) > 300seconds, refuse it. This five-minute window is what makes a captured request non-replayable against you. - Recompute
HMAC-SHA256(secret, f"{t}." + body)and compare it tov1with a constant-time comparison (hmac.compare_digest), never==.
A worked, dependency-free receiver is in
examples/integrations/webhook_receiver/.
Delivery, replay and auto-disable
Delivery is at-least-once: deduplicate on the event's id, because a retry or a replay
arrives with the same id and a fresh signature. Return a 2xx quickly and do slow work
out of band — Kernel waits on your response, and a receiver that hangs is timed out and
retried. A failed delivery is retried with exponential backoff up to a bounded number of
attempts; a replay (from the API or the CLI) re-sends a past delivery under a new
signature. After a configurable number of consecutive failed deliveries across events (default
15), the webhook auto-disables itself and Kernel records a webhook.disabled event; a
successful delivery resets the counter. A test ping never counts toward auto-disable.
Notifications
Per-user notifications (in-app, and email when SMTP is configured). Reads take org:read,
mutations org:write; a notification is delivered to a member, so the routes are org-scoped and
a notification you cannot see is a 404.
| method + path | does |
|---|---|
GET /api/v1/organizations/{id}/notifications (?unread_only=) |
the caller's notifications in this org |
GET /api/v1/organizations/{id}/notifications/unread-count |
the unread badge count |
POST /api/v1/organizations/{id}/notifications/read-all |
mark every one read |
POST /api/v1/notifications/{id}/read |
mark one read |
GET /api/v1/organizations/{id}/notification-preferences |
per-kind in-app/email preferences |
PUT /api/v1/organizations/{id}/notification-preferences |
set them |
The kinds are approval_requested, approval_decided, blocker_reported, blocker_resolved,
session_completed, session_failed (NotificationKind); a preference row is in-app and/or
email per kind, honoured by the delivery fanout.
Data portability, privacy and retention
Export and import. Every export is a deterministic byte stream, so a re-export of unchanged
data is identical (and a repeated export may be rate-limited, auth.rate_limited).
| method + path | scope | does |
|---|---|---|
GET /api/v1/organizations/{id}/export |
org.export (owner) |
the whole organization as an application/x-tar attachment |
GET /api/v1/projects/{id}/work-export |
work:read |
a project's Work as a portable document |
POST /api/v1/projects/{id}/work-import |
work:write |
import it into a project; returns an id remap and refuses a graph cycle (work.graph_cycle) |
GET /api/v1/organizations/{id}/members/{user_id}/export |
self or owner | one member's own data (GDPR access) |
POST /api/v1/organizations/{id}/members/{user_id}/anonymise |
self or owner | anonymise a member, leaving a tombstone (GDPR erasure) |
Privacy modes. A project runs in one of metadata_only < artifacts_selected < full_session_history (PrivacyMode), and an organization sets a ceiling no project may
exceed; a project above the ceiling is refused identically via the API and the web form. The
mode is the project's privacy_mode field (PATCH /projects/{id}); the org ceiling and the
retention policy live in the organization's settings (PATCH /organizations/{id}).
metadata_only stores no session content at all, enforced at both the server and the daemon.
Retention. An organization's retention policy (with floors) is swept in batches by a
background job; the sweep emits retention.swept.
Resource families
The routes, grouped. This is a map, not the full list — the OpenAPI document at
/api/v1/openapi.json is authoritative and every operation there
carries a stable operationId, a summary and a description.
- Auth & identity —
POST /auth/register,POST /auth/login,POST /auth/logout,GET /auth/sessions,GET /me,POST /me/password,GET /auth/whoami. - Organizations & members —
GET|POST /organizations,GET|PATCH /organizations/{id},GET /organizations/by-slug/{slug}, the members (/organizations/{id}/members...), invitations (/organizations/{id}/invitations,POST /invitations/{token}/accept). - Projects —
GET|POST /organizations/{id}/projects,GET|PATCH /projects/{id},GET /organizations/{id}/projects/by-key/{key},POST /projects/{id}/archive. - Work —
GET|POST /projects/{id}/work,GET|PATCH /work/{id},POST /work/{id}/transition,POST /work/{id}/reopen,GET /work/{id}/allowed-commands,GET /projects/{id}/work/by-key/{key},GET /organizations/{id}/work. - Sessions —
POST /work/{id}/sessions(register),GET /sessions/{id},POST /sessions/{id}/events,.../heartbeat,.../workspace,.../start,.../complete,.../fail,.../cancel,.../instruction. Seedocs/runtimes/external-agent.md. - Tokens —
GET|POST /organizations/{id}/tokens,GET /tokens/self,GET|DELETE /tokens/{id}. - Webhooks — see the section above.
- Machines & runtimes — machine enrolment and command queue
(
/organizations/{id}/machines...,/machines/{id}...), and the runtime registry (/organizations/{id}/runtimes...,.../doctor,.../test). - Decisions, blockers, approvals, outcomes — the work-object records:
/work/{id}/decisions,/work/{id}/blockers,/work/{id}/approvals,/work/{id}/outcomes, addressed afterwards by id (/decisions/{id},/blockers/{id},/approvals/{id}), plus the/organizations/{id}/needs-youinbox. - Artifacts & handoffs —
/work/{id}/artifacts(with/artifacts/{id}/content),/work/{id}/handoffs(withcompile,accept,reject,complete), and the organization's artifact types and work templates. - Events, audit & search —
/organizations/{id}/events(and its SSE.../events/stream),/organizations/{id}/audit(withaudit.csv),/organizations/{id}/search, saved filters. - Graph & analytics — work dependencies and the work graph (
/work/{id}/dependencies,/work/{id}/graph,/projects/{id}/graph) and aggregate analytics (/organizations/{id}/analytics/...). - Notifications — the caller's inbox and per-kind preferences
(
/organizations/{id}/notifications,.../notifications/unread-count,.../notifications/read-all,/notifications/{id}/read,/organizations/{id}/notification-preferences). See the section above. - Data portability — org export (
/organizations/{id}/export), work export/import (/projects/{id}/work-export,.../work-import) and per-member GDPR export/anonymise (/organizations/{id}/members/{user_id}/export,.../anonymise). See the section above.
Outside /api/v1, two unauthenticated operational endpoints answer for load balancers and
scrapers: GET /health/live and GET /health/ready (the latter 200 only when the database is
reachable and migrated to this build's revision), and GET /metrics (Prometheus, no tenant
content). These carry no operationId and are absent from the OpenAPI document by design.
See also
docs/api-errors.md— every errorcode.docs/runtimes/external-agent.md— the external-session flow in depth.docs/cli.md— thekernelCLI, which drives this API.docs/mcp.md— the MCP surface over the same system of record.- The runnable examples under
examples/integrations/.