Kernel

← Documentation

The `kernel` CLI

kernel is an HTTP client of /api/v1 and nothing else. It imports no service, opens no database connection and knows no model: everything it shows, a Kernel server told it, after applying the permission matrix, the tenant boundary and the token's scopes. So it runs anywhere the server is reachable, needs no database credentials, and can never do something the API does not already offer.

The three local-administration commands are the deliberate exception — kernel db …, kernel seed demo and kernel bootstrap-dev run on the database, are meant for a developer's own machine, and are documented in the quickstart.

Configuration

platformdirs.user_config_dir("kernel")/cli.toml~/Library/Application Support/kernel/cli.toml on macOS, ~/.config/kernel/cli.toml on Linux. Set KERNEL_CLI_CONFIG to put it somewhere else (one file per server, or one per shell).

server_url = "https://kernel.example.com"
token = "krn_…"
organization = "acme"
project = "AUTH"

The file is written through a temporary file in the same directory and renamed over the old one, so it exists only ever as mode 0600: the token is never briefly world-readable, and an interrupted save never loses it. It lives outside any working copy on purpose — a credential does not belong in a repository.

A change is a read-modify-write, and kernel holds an exclusive flock on a sibling cli.toml.lock across the pair, re-reading the file inside the lock. Without that, two overlapping commands each write what the other had not yet made — kernel auth login finishing beside a kernel project use puts the project back next to the token the login had just replaced. The login still exits 0 and still prints the new token's prefix, so you revoke the token you think you replaced and are locked out of a CLI holding a revoked one. What a command prints is what landed on disk, because it reports the value the lock returned.

The lock is on a sibling rather than on cli.toml itself because an atomic replace swaps the file's inode, and a lock held on the old inode would exclude nobody. A cli.toml.lock left behind by a killed command is only a file: flock is released by the operating system when its holder exits, so nothing has to be cleaned up and no stale lock can ever wedge kernel. A command that genuinely is still writing is waited for, and then named (cli.config_locked) rather than waited for forever.

organization and project are what kernel org use and kernel project use record, so the other commands need no --org/--project on every line. Switching organization clears the project: a project key is unique inside one organization and means nothing outside it.

Credentials: the environment first, then the file

Every command takes its server and its credential from one loader (kernelos.cli.config.resolve_credentials), in one precedence — the same one kernel-mcp and the scripts use, so a token has one place to be:

the token the server
1 KERNEL_SESSION_TOKEN — a session's own, from kernel session attach --export or a daemon KERNEL_URL
2 KERNEL_TOKEN KERNEL_SERVER_URL — the daemon's name for the same thing
3 cli.toml's token cli.toml's server_url

A blank variable is an unset one. The file's organization and project still apply whichever credential won, which is what lets a key like AUTH-12 resolve; with no file at all, address work by its id instead. kernel auth status says where the token came from (token from KERNEL_SESSION_TOKEN), never what it is.

A token is never taken from the command line. Anything shaped like a Kernel token among the arguments stops the command before a request is made (cli.token_on_command_line, exit 2): a command line is readable by every process on the machine through ps and lands in the shell's history, so it has leaked by the time it is parsed. Put it in the variable instead.

A session token is a session. With KERNEL_SESSION_TOKEN set — the dogfood protocol's eval "$(kernel session attach KBK-44 --export)" — every command authenticates as that session, and a decision, artifact, blocker or approval it records carries the session's id on the row and on its event without any --session being given; naming a different session is refused by the server (validation.failed). The server confines such a token to its own work item and closes the organization and project routes to it, so a key is resolved through GET /auth/whoami rather than the project listing, and a key that is not that work item's is refused here (cli.session_scope) naming the one it is. What the token may do is the server's rule: it records and asks, and it reads back the records of its own work itemkernel decision list, blocker list, artifact list, approval show and a handoff all answer for the Work it is attached to (D-055/KBK-46), each confined the same way its writes are, so another work item's row answers cli.session_scope just as a write to it does. It still does not list sessions (no sessions:read, so kernel session show is refused — D-035), mint tokens, or decide approvals.

Signing in

$ kernel auth login --server https://kernel.example.com --email you@example.com
Password:
server        https://kernel.example.com
organization  acme
token         krn_ab12cd34
token name    kernel-cli on your-laptop
config        /Users/you/Library/Application Support/kernel/cli.toml

The password buys a browser session, which the CLI uses for exactly one thing: minting an API token named kernel-cli on <host>. It then revokes that session and keeps only the token. A cookie in a configuration file would be a browser credential on disk — revocable only by signing out everywhere; the token is scoped, listed on the tokens page and revocable on its own.

The token is never printed, by any command. kernel auth status shows its prefix (krn_ab12cd34), which is what the tokens page shows too: enough to say which token this is, never enough to use it.

--org SLUG picks the organization when you belong to more than one.

To store a token you minted yourself instead of a password, put it in KERNEL_TOKEN and run kernel auth login with no --email — the CLI checks the server accepts it, then keeps it. On a terminal with neither --email nor KERNEL_TOKEN, it prompts for the token with the input hidden. A token is never passed as an argument: kernel auth login --token krn_… is refused before any request, the same way every other command refuses a token on the command line (ps shows it to every account on the machine, and the shell records it) — the refusal names KERNEL_TOKEN as where the token belongs.

What the CLI's token may do

kernel auth login asks for exactly the scopes its own tree needs, and no more:

approvals:read approvals:request artifacts:read artifacts:write blockers:read blockers:write
decisions:read decisions:write handoffs:read handoffs:write org:read runtimes:read
sessions:read sessions:write tokens:manage work:read work:write

Deliberately absent: org:write, runtimes:manage, webhooks:manage, audit:read and admin:*. This is the credential that ends up in shells, in scripts and on laptops — the most widely copied one in the product — so it gets the reach its own commands need and nothing else. On top of the scopes it is still bounded by the role of your membership, so it can never do more than you can.

The one place you meet the edge is kernel project create, which needs org:write:

$ kernel project create BILL --name Billing
kernel: auth.permission_denied: project.create requires the scope org:write
hint: creating a project needs the `org:write` scope, which `kernel auth login`
      leaves out on purpose: the CLI's token is the one credential that ends up in
      shells, in scripts and on laptops, …

The first line is the server's, and it names the scope: auth/permissions.py puts required_scope into the problem document, and the hint reads it from there rather than keeping a copy of the scope map. The hint appears only for a scope denial — a role denial is a different conversation, and advice about minting tokens would mislead someone whose problem is that they are a viewer.

org:write is one scope covering eight actions — org.update, org.members.invite, org.members.update_role, org.members.remove, project.create, project.update, project.archive, settings.update — so granting it to every CLI token to make one command convenient would hand every copy of that token the power to change roles and remove members. It is not in the default set for that reason.

A second, purpose-scoped credential

--scope (repeatable) replaces the default set for one login, and the CLI keeps one token per configuration file, so administrative work gets a credential of its own rather than widening the everyday one:

$ export KERNEL_CLI_CONFIG=~/kernel-admin.toml
$ kernel auth login --server https://kernel.example.com --email you@example.com \
      --scope org:read --scope org:write
$ kernel project create BILL --name Billing
$ unset KERNEL_CLI_CONFIG        # back to the everyday token, unchanged

Include org:read: the CLI resolves the organization slug you selected through GET /organizations, so a token without it cannot find the organization to act in.

It has to be auth login rather than kernel token create --scope org:write. A token never grants a scope it does not itself hold — the CLI's token has no org:write, so minting from it answers kernel: auth.scope_escalation: …. auth login mints under the browser session the password bought, which is bounded by your role alone.

The command tree

kernel auth      login [--scope …] | status
kernel org       list | use SLUG
kernel project   list | create KEY --name … | use KEY
kernel work      list | create | show KEY | update KEY | transition KEY COMMAND
kernel session   list [KEY] | show ID | attach KEY | start KEY | heartbeat [ID] | cancel ID | complete ID | fail ID
kernel handoff   create KEY | compile ID | list KEY | show ID | accept ID | reject ID | complete ID
kernel decision  add KEY | list KEY
kernel artifact  add KEY | list KEY | get ID
kernel blocker   add KEY | resolve ID | list KEY
kernel approval  request KEY | list [KEY] | show ID | approve ID | reject ID
kernel token     create | list | revoke ID
kernel runtime   list | doctor
kernel daemon    enroll | run | status | install | uninstall | start | stop
kernel doctor

kernel daemon … enrols this machine and runs the host daemon on it; the whole group, including the optional macOS LaunchAgent, is documented with the daemon. It is also the whole of the kernel-daemon console script, so a service runs exactly what you run by hand.

Work

$ kernel work create --title "Implement OAuth2 + PKCE" \
    --goal "Third-party sign-in without a password" \
    --priority high --type implementation \
    --label auth --label security \
    --constraint "no new dependencies" \
    --accept "PKCE is mandatory for public clients"
$ kernel work list --state ready
$ kernel work list --all-projects
$ kernel work show AUTH-1
$ kernel work update AUTH-1 --title "Implement OAuth2 + PKCE (public clients)"
$ kernel work transition AUTH-1 make_ready
$ kernel work transition AUTH-1 start --reason "the agent picked it up"

Work is addressed by its key everywhere you type it. update sends only the options you gave — an unset field is unchanged — and refuses an update of nothing rather than sending an empty PATCH; a repeated option (--label) replaces the list. Which commands transition accepts is the server's to say: one it never accepts is refused naming the accepted set, and one the current state forbids is refused naming the commands that would work instead. kernel work show lists those under allowed.

Sessions

Two ways to open a session, differing in who runs it.

attach registers an external session: an orchestrator that is already running tells Kernel it exists and gets a session token to report against. That token is in the 201 and nowhere else, so the command prints it once:

$ kernel session attach AUTH-1 --purpose implementation
id       0d9c…  status  running  …

token  krn_9f2c…
(shown once: Kernel keeps only its hash)

$ eval "$(kernel session attach AUTH-1 --purpose implementation --export)"
$ echo "${KERNEL_SESSION_TOKEN:0:12}…"
krn_9f2c1a8b…

--export prints export KERNEL_SESSION_TOKEN=… and export KERNEL_SESSION_ID=… and nothing else, so it can be evaluated; it cannot be combined with --json, which a shell cannot evaluate. No later command shows that token again — session show, session list and their --json never carry it. Losing it means registering another session; there is no way to read it back, which is the point.

Keep it alive. Nothing heartbeats an attached session but you, and a session silent for KERNEL_SESSION_STALE_AFTER_SECONDS (five minutes by default) is reported stale — on the Sessions tab, on its page and in Needs You, where a person decides whether it finished or failed. So the line after the eval is the loop:

$ eval "$(kernel session attach AUTH-1 --purpose implementation --export)"
$ kernel session heartbeat --every 30 &

heartbeat beats $KERNEL_SESSION_ID (or the id you pass) once, or every N seconds with --every. The loop ends by itself, without another beat, as soon as the process that started it is gone: it watches its parent, because a background job of a non-interactive shell gets no signal when that shell exits, and a heartbeat that outlives its agent is a dead agent looking alive for ever. A refusal that cannot improve — the session ended, the credential is dead — ends the loop with exit 1; an unreachable server is reported and the next beat is due on schedule.

start registers a managed session: the work goes to a machine's daemon as a queued start_session command carrying the session's token, so the token never passes through the terminal at all. The session waits in pending until the daemon picks the command up.

Which one you want is a question about who runs the agent: external agents for an orchestrator that runs itself, the daemon for a machine Kernel drives — running Codex or Claude Code.

$ kernel session start AUTH-1 --runtime fake_codex --machine laptop --workspace ~/src/acme
$ kernel session list AUTH-1
$ kernel session list --status running
$ kernel session complete 0d9c… --metrics-json '{"tokens_in": 120}'
$ kernel session fail 0d9c… --reason "the runtime crashed"
$ kernel session cancel 0d9c…

--machine takes a machine's name or its id. Failing a session raises a session_failed blocker, which blocks the work item.

Handoffs

A handoff moves one work item from the session that did the work to the session that takes it on — including from one runtime to another. What travels is not a transcript but a compiled package: the goal, what was decided, what exists, what is unresolved, and what to do next.

$ kernel handoff create AUTH-1 --to claude --purpose review \
    --objective "review the migration and say whether it is reversible" \
    --from 0d9c…
$ kernel handoff list AUTH-1
$ kernel handoff show 7b31…
$ kernel handoff accept 7b31… --session 4f0a…
$ kernel handoff complete 7b31…

create is two API calls — request, then compile — because a requested handoff with an empty package is of no use to anybody. --from names the session handing off; it is what fills the package's source runtime and its relevant_files, so give it when there is one. --to takes a runtime key (kernel runtime list shows them).

accept is the step that matters: the session you name becomes the continuation of that work item, and Kernel records the lineage on the session itself (kernel session show shows it as handoff_id, and as parent_session_id pointing at the session the package came from, unless the session was registered with a parent of its own). The session has to belong to the same work item, has to still be active, and cannot be the one that handed off. A handoff is accepted once.

A rejection is not the end of a handoff. The destination turns a package down with a typed reason, the requester amends the work, and the same handoff is compiled again:

$ kernel handoff reject 7b31… --reason missing_context --note "no test plan"
$ kernel work update AUTH-1 --accept "a test plan names the fixture that proves it"
$ kernel handoff compile 7b31… --objective "review the migration, now with a test plan"
$ kernel handoff accept 7b31… --session 4f0a…

--reason is one of missing_context, wrong_runtime, superseded, other; anything else is refused before a request is sent. The reason stays on the record after the recompile and after the acceptance — it is why the second package exists.

compile answers with the version it produced, and the version moves only when the package does. Recompiling work that has not changed gives you back the version and hash you already had, so "is this still the package I read?" has an answer. A version that counted compilations instead would answer nothing.

show prints the row and a summary of the package; --json, on every command here, carries the whole document.

Decisions

$ kernel decision add AUTH-1 --statement "Use PostgreSQL" \
    --rationale "the team knows it" \
    --artifact 9c1e… --uri https://example.com/adr-1
$ kernel decision add AUTH-1 --statement "Use SQLite for tests" --supersedes 4b7d…
$ kernel decision list AUTH-1
$ kernel decision list AUTH-1 --current

A decision is append-only: there is no edit and no delete, and a decision that turned out wrong is replaced by recording a new one with --supersedes; both stay on record, list shows the whole history, and --current keeps only what nothing has superseded. Evidence is given by reference — --artifact names an artifact version of this organization, --uri something outside Kernel — and either may be repeated. --session is rarely needed: under KERNEL_SESSION_TOKEN the server attributes the decision to that session.

Artifacts

$ kernel artifact add AUTH-1 --type report --title "test report" --file ./report.md
$ kernel artifact add AUTH-1 --type link --title "the PR" --external-uri https://example.com/pr/1
$ kernel artifact list AUTH-1 --type report
$ kernel artifact get 9c1e… --output ./downloads/

add attaches the next version of --title: the same title again is version 2. An upload declares what the command computed — the file's size and SHA-256 — and the server holds it to both (artifact.checksum_mismatch), so a truncated upload cannot land as a whole one; its media type is --content-type, else what the filename suggests, and either is checked against the bytes and refused on disagreement (artifact.type_mismatch) rather than believed.

get writes the bytes to a file and never prints them: through a temporary file beside the target, hashed as they arrive and compared with the row's checksum before the file takes its name (cli.checksum_mismatch removes it). --output is a file, or a directory to put the artifact's own filename in; an existing file is refused without --overwrite. An artifact recorded by --external-uri has no bytes here and the server says so (artifact.no_content).

Blockers

$ kernel blocker add AUTH-1 --severity high --title "CI is down" --description "no runners"
$ kernel blocker list AUTH-1
$ kernel blocker resolve 7b31… --resolution "runners back"

A high or critical blocker blocks the work item; low and medium are notes. Neither move is a command here — the server moves the work to blocked and back when the last blocking blocker is resolved — so the command line cannot desynchronise the two. list is the work item's open blockers.

resolve sends what it was given and lets the server say what is missing: the written resolution is required by BlockerResolve on the server, and this command keeps no second copy of that rule. Leave --resolution out and the answer is validation.failed with the field named under it, and the blocker stays open. A resolved blocker is closed to a second resolution (resource.closed), so a retry after a lost answer never rewrites it.

Approvals

$ kernel approval request AUTH-1 --action "run the migration" --reason "prod" \
    --required-role admin --expires-at 2026-09-05T09:00:00Z
$ kernel approval list                # the organization's pending approvals
$ kernel approval list AUTH-1
$ kernel approval show 3f7b…          # state is the answer, once decided
$ kernel approval approve 3f7b… --note "go"
$ kernel approval reject 3f7b… --reason "not on a Friday"

request parks running or review work in waiting_human until nothing pending remains; approve and reject are a person's answer, and the work resumes either way — a rejection refuses the action that was asked about, for good, and doing it anyway means asking again. Approving may be silent and rejecting may not: --reason is required by the server's ApprovalRejection, and as with blockers this command holds no copy of the rule — leave it out and the refusal names the field.

show is here because list shows what is pending: an agent that asked and was answered cannot learn the answer from a listing it has left, and state on the row is where the yes or no is written.

Deciding needs admin:*, which the token kernel auth login mints deliberately lacks — a token that lives in a file on a laptop should not be able to approve — and a session token may request and never decide. The refusal names the scope, and the command says how to hold it without widening the everyday token: a second, purpose-scoped credential in its own file (KERNEL_CLI_CONFIG=~/kernel-admin.toml kernel auth login … --scope org:read --scope admin:*), or the browser, which is where a person usually decides.

Tokens

$ kernel token create --name ci --scope work:read --scope sessions:read
$ kernel token create --name confined --scope work:write --project AUTH
$ kernel token list
$ kernel token revoke 3f7b…

The plaintext exists in exactly one place — the 201 of create — and this command prints it once for the same reason attach does. list shows the prefix, never anything usable. Revocation takes effect on the very next request. The scope vocabulary belongs to the server: an unknown scope is refused there, naming it.

A token minted here can never be wider than the token that minted it — asking for a scope the CLI does not hold answers auth.scope_escalation. To go wider, sign in again with kernel auth login --scope … (see A second, purpose-scoped credential).

Runtimes and health

$ kernel runtime list      # the registry, and which machines report each runtime
$ kernel runtime doctor    # the adapters on THIS machine; no server is contacted
$ kernel doctor

runtime doctor and the local half of kernel doctor run the adapters on this machine over no network at all, so they answer "can this laptop run a coding agent" before there is a server to enrol with.

kernel doctor asks three questions and answers all three whatever the others say, because the useful answer to "it does not work" is which part:

server reachable, and migrated to the revision this build expects (/health/ready)
auth the stored token is still accepted, and for which organization
runtimes what the adapters on this machine found

It prints its report either way and exits 1 when the server or the token is not usable, so kernel doctor --json is a health check a script can read on both paths.

Output and errors

Every command takes --json. Human output is whitespace-aligned plain text and nothing else — no colour, no box drawing, no truncation — so | grep and | awk behave and a value is never cut short at somebody's terminal width. A list prints a table; --json gives {"items": [...]} with the API's own documents inside, every page followed to the end.

Failures go to stderr as kernel: <code>: <detail>:

$ kernel work show AUTH-999
kernel: resource.not_found: WorkItem not found
$ echo $?
1

The code is the one the server chose (RFC 9457 problem documents, kernelos.api.errors), so a script can branch on it; every one of them is listed in the API error reference, with what it means and what to do about it. What the CLI decides itself uses the same shape under a cli. code — cli.not_configured, cli.no_organization, cli.no_project, cli.runtime_not_found, cli.machine_not_found, cli.invalid_argument, cli.server_unreachable, cli.config_invalid, cli.config_locked, cli.context_changed, cli.unexpected_response, cli.too_many_rows, cli.too_many_pages, cli.token_on_command_line, cli.session_scope, cli.checksum_mismatch, cli.usage.

A validation.failed refusal is followed by the fields the server named, one per line (body.resolution: Field required), read from the problem document's details.errors — the rule a schema holds is reported at the terminal without any command keeping a copy.

Exit code Meaning
0 it worked
1 it failed: the server refused it, or could not be reached
2 the invocation was not usable — a bad option, or context the CLI needs and has not got

A body that is not a problem document is never echoed at the terminal: a proxy's HTML error page tells the reader nothing and may carry anything, so it is reported as http.<status>.

What a server says is data, not instructions

A terminal is not an inert display. Everything kernel prints in its human output came from a server — a work item's title, a runtime's key, a problem document's detail and every value under its details — and written raw those strings can set the window title, move the cursor, repaint a line that was already read, or open a new line that reads exactly like kernel:'s own machine-readable output.

So every server-supplied string is put through one function as it is parsed: control characters become spaces, runs of whitespace collapse, and the result is bounded. An escape sequence keeps its letters and loses its escape, so you still see that something odd arrived. This happens at the boundary, once — not at each place something is printed — so a field added later is safe because the path is safe.

A credential is the exception, and goes the other way: a token has to be printed byte for byte, and sanitising one would hand you a credential that looks right and does not work. So its shape is checked instead, and a token that is not a Kernel token is refused with cli.unexpected_response and nothing is printed.

Characters that are invisible or reorder what is around them go the same way, judged by Unicode category rather than by a copied table of ranges. org:\u202edaer would otherwise display as org:read, and org\u200b:\u200bwrite as org:write while being a different string — a hint that names a scope has to name the scope that arrived. Lone surrogates go too: stdout is opened errors="strict", so one reaching it would take the command down.

--json was always safe here: JSON escapes control characters, so nothing reaches the terminal that a terminal would act on.

Walking a server's structure is bounded too

Sanitising the strings in a response means the CLI walks structure a server chose, and a walk is itself something a server can attack: nesting costs stack and breadth costs memory, and a crashed CLI reports nothing at all — worse than the escape sequence above. So every traversal has a bound, and refuses rather than truncating, because a structure that shape is not something to render but evidence the answer is malformed:

the parse a body nested past what json.loads can walk is cli.unexpected_response, not a crash
a problem document's details deeper than 8 or larger than 1000 values is dropped, and the report says so
a listing more rows than limit × pages could honestly produce is cli.too_many_rows
a session's metrics the runtime's own dictionary, so nobody's to predict: refused the same way

Two rules the tests hold the tree to

  • No command module imports a service, the database or a model. tests/unit/cli/test_command_tree.py parses each module and refuses anything under kernelos.services, kernelos.db, kernelos.api, kernelos.auth, kernelos.web, kernelos.events or kernelos.jobs.
  • Every command answers --json, checked over the whole tree rather than command by command, so a new one cannot forget.

The integration suite (tests/integration/cli/) runs the real CLI against a real uvicorn server on a free port, over a migrated database — no mocks, no ASGI shortcut. If a command works there, it works against a deployed Kernel.

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