`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 thatkernel-daemonis onPATH. - 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_URLand its credential inKERNEL_SESSION_TOKEN(alsoKERNEL_SESSION_IDandKERNEL_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-onlyorworkspace-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.