Kernel

← Documentation

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 form krn_<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 the 201 that 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 in X-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, 1200 (default 50).
  • 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 — one WorkState (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. A project_id belonging to another organization is 404; an owner_user_id that 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:

  1. Read the raw request body bytes — re-serialising the parsed JSON changes the bytes and breaks the check.
  2. Parse Kernel-Signature into t and v1.
  3. Reject a stale t: if abs(now - t) > 300 seconds, refuse it. This five-minute window is what makes a captured request non-replayable against you.
  4. Recompute HMAC-SHA256(secret, f"{t}." + body) and compare it to v1 with 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 & identityPOST /auth/register, POST /auth/login, POST /auth/logout, GET /auth/sessions, GET /me, POST /me/password, GET /auth/whoami.
  • Organizations & membersGET|POST /organizations, GET|PATCH /organizations/{id}, GET /organizations/by-slug/{slug}, the members (/organizations/{id}/members...), invitations (/organizations/{id}/invitations, POST /invitations/{token}/accept).
  • ProjectsGET|POST /organizations/{id}/projects, GET|PATCH /projects/{id}, GET /organizations/{id}/projects/by-key/{key}, POST /projects/{id}/archive.
  • WorkGET|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.
  • SessionsPOST /work/{id}/sessions (register), GET /sessions/{id}, POST /sessions/{id}/events, .../heartbeat, .../workspace, .../start, .../complete, .../fail, .../cancel, .../instruction. See docs/runtimes/external-agent.md.
  • TokensGET|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-you inbox.
  • Artifacts & handoffs/work/{id}/artifacts (with /artifacts/{id}/content), /work/{id}/handoffs (with compile, 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 (with audit.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

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