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 item —
kernel 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.pyparses each module and refuses anything underkernelos.services,kernelos.db,kernelos.api,kernelos.auth,kernelos.web,kernelos.eventsorkernelos.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.