Kernel

← Documentation

`kernel-daemon` — running sessions on your own machine

The daemon is the only Kernel component that runs on your hardware. It holds a credential for one machine, asks the server what to do, launches a coding agent in a directory you allowed, streams what the agent does back, and keeps going when the network does not.

It is a plain foreground process. kernel daemon run in a terminal is the supported way to run it; the macOS LaunchAgent below is an optional wrapper around exactly that command.

kernel daemon enroll --server URL [--name] [--workspace DIR]… [--token-store] [--sync-mode]
kernel daemon run [--log-level]
kernel daemon status
kernel daemon install | uninstall | start | stop      # macOS LaunchAgent

Every one of them takes --json. kernel-daemon <command> is the same tree under its own console script, which is what the LaunchAgent starts — so the service runs exactly what you run by hand.

What it needs before you start

  • Python 3.13 and this package installed on the machine (uv tool install, a virtualenv, whatever you use), so that kernel-daemon is on PATH.
  • A Kernel server you can reach.
  • An enrolment token, which an admin of your organization creates for you.
  • At least one directory the daemon may work in. A daemon with no allowed workspace refuses every session; see Workspaces.

Check the local half before there is a server to talk to:

$ kernel runtime doctor

That runs each adapter's doctor() on this machine over no network at all, and tells you which coding agents this host could actually run.

1. An admin reserves the machine

In the UI: Runtimes → Machines → Enrol a machine. Give it a name; the next screen shows the enrolment token once. From the API it is the same thing:

$ curl -sX POST "$KERNEL/api/v1/organizations/$ORG/machines/enrollment-tokens" \
    -H "Authorization: Bearer $ADMIN_TOKEN" \
    -H 'Content-Type: application/json' -d '{"name_hint": "laptop"}'

The reply carries a machine row in state pending and the plaintext token. Only the hash is stored, so a copy of the database cannot enrol anything. The token is good for one use within 24 hours; if it is lost, revoke the machine and reserve another.

2. The machine enrols itself

Put the secret in the environment rather than on the command line — arguments are visible to every account on the host through ps, and enroll warns when you pass one anyway:

$ export KERNEL_ENROLLMENT_TOKEN=…
$ kernel daemon enroll --server https://kernel.example.com \
      --name laptop \
      --workspace ~/src/acme \
      --workspace ~/src/tools
enrolled laptop as 5f0f…
config:    /Users/you/Library/Application Support/kernel/config.toml
token:     keyring (login keychain)
workspaces: /Users/you/src/acme, /Users/you/src/tools

enroll runs every adapter's doctor() first and sends the results with the enrolment, so the runtime registry knows what this host has before the daemon has run once. What comes back is the device token: long-lived, scoped to runtimes:read sessions:read sessions:write, narrowed by the server to this machine alone, and never printed by any command.

--token-store keyring (the default) uses the OS keyring when a backend on this machine really stores something; --token-store file writes token mode 0600 in the data directory instead. Use the file store on a headless host, where no keyring is unlocked.

--sync-mode decides how much of what a runtime says leaves this machine — see Sync mode.

3. Run it

$ kernel daemon run

It stays in the foreground and does four things at once:

heartbeat every 30 s, with a fresh doctor() for every adapter. The server calls a machine offline after 120 s, so one missed beat is not a fault
commands a long poll (?wait=25) that returns as soon as something is queued for this machine
sessions one asyncio task per session, each owning its runtime's process group
events batched every 500 ms or 100 events and posted with a source_seq from a counter persisted in SQLite

Exit codes: 0 asked to stop, 1 could not start, 3 this machine's device token has been revoked. Three is deliberate and final — the daemon stops polling and exits rather than retrying a credential that will never work again.

When the server goes away

Events stay in the local queue (queue.sqlite in the data directory) and the runtime keeps running. Posting is retried with a backoff from 1 s to 60 s, and after a reconnect the queue flushes in order. Every event carries the source_seq the daemon assigned it, so the server recognises a replayed one and answers duplicates instead of storing it twice; that is what makes the replay safe to repeat.

kernel daemon status shows how deep the queue is, how much of its 64 MiB it is using, when the server last saw this machine, and how many sessions this daemon believes are running. It answers the local half even when the server cannot be reached.

Workspaces are the whole of the daemon's authority

A start_session command names a directory that the server chose, and the daemon runs a coding agent in it. allowed_workspaces is what says which directories that may be, and it is empty unless you passed --workspace. A daemon nobody told where it may work refuses every managed session rather than guessing.

The comparison happens after the path is resolved, because .. and a symlink are how a permitted-looking name reaches somewhere else, and because the process is going to chdir into what the name resolves to. Containment is then a path comparison, not a string prefix — /code-secrets starts with /code and is not inside it.

To change the list, enrol again or edit allowed_workspaces in config.toml and restart.

Sync mode: what leaves this machine

metadata_only is the default. Under it a session's shape, cost and outcome reach Kernel while the conversation stays on the host: any single string in an event payload is cut to a 200-character preview, a list or mapping keeps at most 50 entries, and the whole serialised payload is capped at 4 KiB. --sync-mode full sends payloads as they are, still bounded at 256 KiB per event so that one runaway event cannot carry a quarter of a gigabyte into the events table.

Pick metadata_only unless you have decided, for that machine, that the conversation should be stored centrally.

The one other thing that leaves: which files a session changed

When a session ends, the daemon posts a diffstat of its workspace — paths, insertions and deletions, and nothing else — so that the handoff a person compiles afterwards can say which files the work touched. It is sent under both sync modes, because that is what the setting means rather than an exemption from it: a path with two line counts is metadata, and the endpoint it goes to (POST /api/v1/sessions/{id}/workspace) accepts a path and two integers per file and refuses any other field, so there is no way for a hunk or a file's contents to travel this way.

What it reads is git diff --numstat against the commit the workspace was on when the session started, plus the files git is not yet tracking. Paths are relative to the workspace, never absolute — your directory layout stays on your machine — and files your .gitignore excludes are never named. Counting the lines of a new file means reading it, which happens here and goes no further: what leaves is the number.

A workspace that is not a git repository, a git that does not answer within 30 seconds, or a server that refuses the report all end the same way — no report, and a session that ends exactly as it would have. It is a convenience for a handoff, never a condition of a session completing.

Files it owns

Both directories come from platformdirs, so they are the platform's own and never a working copy. KERNEL_DAEMON_CONFIG_DIR and KERNEL_DAEMON_DATA_DIR override them, which is how a second daemon on one host — or a test — gets its own state.

macOS Linux
config ~/Library/Application Support/kernel/config.toml ~/.config/kernel/config.toml
data ~/Library/Application Support/kernel/ ~/.local/share/kernel/

config.toml is what enrolment wrote and run reads back:

server_url = "https://kernel.example.com"
machine_id = "5f0f…"
machine_name = "laptop"
token_store = "keyring"
sync_mode = "metadata_only"
allowed_workspaces = ["/Users/you/src/acme"]
heartbeat_seconds = 30.0
poll_wait_seconds = 25.0
batch_interval_ms = 500
batch_max_events = 100
resume_after_restart = false

[runtimes.fake_codex]
script = "happy_path.json"

The data directory holds queue.sqlite (the offline outbox), runtimes.json, sessions.json (which sessions this daemon believes are running) and, with the file token store, token. The device token is never written into config.toml and never logged.

[runtimes.<key>] is this machine's own adapter configuration — a script path, a state directory, an executable. It is layered over whatever the runtime definition on the server says, and the settings that name something on this host are dropped from the server's copy entirely rather than merely overridden: a machine that configures nothing must not inherit an executable from the server.

resume_after_restart

Off by default. A restarted daemon cannot see what became of the runtime it lost — the process was its child and is gone, but a test run or a shell that runtime started may not be. Resuming can therefore put a second agent in a working tree that still has one, and two agents editing one tree is a corrupted result that looks like work: Kernel would record a session with events, metrics and a clean ending, and nothing in the record would say the tree had two writers. Failing the session says something true and leaves a person to decide.

Turn it on only for a runtime whose resume is exact, and know that is what you are accepting.

Running it as a service (macOS)

The wrapper is optional, reversible, and never root.

$ kernel daemon install     # writes ~/Library/LaunchAgents/io.kernel.daemon.plist
$ kernel daemon start
$ kernel daemon stop
$ kernel daemon uninstall   # boots the job out and deletes exactly that file

install and uninstall are both safe to run twice, and uninstall removes only the file install wrote. The plist is generated with plistlib, so a machine name or a path containing < becomes text rather than markup. It is a LaunchAgent in your own GUI domain: the path is built from your home directory and checked to be inside it, so there is no argument by which these commands could write a LaunchDaemon into /Library — the file that would need root and would run as root.

KeepAlive is {"Crashed": true}: launchd restarts the daemon when it dies abnormally and leaves it alone when it exits. That is what keeps exit code 3 meaningful — a revoked token must not become an endless run of 401s.

Logs go to ~/Library/Logs/kernel-daemon.log and ~/Library/Logs/kernel-daemon.err.log; kernel daemon install prints the path it used. If you enrolled with KERNEL_DAEMON_CONFIG_DIR or KERNEL_DAEMON_DATA_DIR set, those two are carried into the agent, because launchd starts a job with almost no environment and an agent looking in the platform default would find nothing and exit 1 every time.

There is no Linux unit yet; run it under whatever supervisor that host already uses, with kernel-daemon run as the command.

Starting a session on the machine

From the UI: the work item's Sessions tab → Start session, choosing the runtime, the machine and a workspace inside its allowlist. From the CLI:

$ kernel session start AUTH-1 --runtime fake_codex --machine laptop --workspace ~/src/acme

The session is pending until the daemon picks the command up; then it is running, its events appear on the session page as they arrive, and it ends completed, failed or cancelled. The session's own token travels to the daemon inside the command and never passes through your terminal.

What the runtime is started with. The daemon writes the prompt itself, from the command. A session registered for a handoff is briefed from that handoff's compiled package — the document a person read before accepting — rendered through the adapter for its runtime; any other session is briefed from the work item's goal, constraints and acceptance criteria. The daemon logs which (session … briefed from handoff … (package v1, …) or … from its work item). After the briefing it appends one paragraph of its own saying how to reach Kernel, and which paragraph depends on what the session can actually reach — the adapter answers that from the sandbox it is about to launch under (D-086 / DF-008):

  • A session with the network is told the server's URL is in KERNEL_SERVER_URL and its credential in KERNEL_SESSION_TOKEN (also KERNEL_SESSION_ID and KERNEL_WORK_KEY), and which requests record a decision, an artifact or a blocker and complete the work item.
  • A session whose sandbox has no network — a Codex session under read-only or workspace-write, which is the default — is told exactly that, told not to raise a blocker about not reaching Kernel, and told that recording goes through the daemon instead: the tools its runtime wires in, and otherwise its final message and the files it leaves, which the daemon reports to Kernel as the session's events and its workspace report. That note names no URL and no credential variable, because naming an API a sandbox cannot open costs a session the turns it spends discovering that and then a blocker somebody has to read.

The prompt names variables and never their values — it goes to a model provider — and the token itself is in the child's environment and nowhere else on the machine. Both notes are stored verbatim under tests/fixtures/access_note/, so a reworded one arrives as a diff. See Handoffs.

fake_codex is the deterministic runtime this build ships for exactly this: it replays a recorded script through the real adapter, the real daemon and the real API, so a machine can be proved end to end before a provider CLI is involved. Codex and Claude Code are the real ones.

Revoking a machine

Runtimes → Machines → Revoke, or POST /api/v1/machines/{id}/revoke. The machine and its device token are revoked together and take effect on the daemon's very next request; anything it had buffered is refused. Sessions already running on that host are not stopped by this — stop them there. Enrol the machine again to bring it back; revocation cannot be undone.

Troubleshooting

symptom what it means
daemon.workspace_refused the session named a directory outside this machine's allowlist, or named none at all
exit code 3 the device token has been revoked; enrol again
exit code 1 at startup no config, or no token where the config says the token store is
machine shows Offline nothing has beaten for 120 s — the daemon is not running, or cannot reach the server
a runtime shows Problems on the registry the machine's own doctor() said so; the report is quoted next to the machine's name
kernel daemon status says the queue is filling the server has been unreachable for a while. At 64 MiB, or for an event older than seven days, the daemon drops the oldest — the newest are the ones that still describe what a session is doing — and records a session.error marker so the history says where the hole is

See also: the CLI, and the runtimes a daemon can run: Codex, Claude Code, external agents.

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