Kernel

← Documentation

Handoffs

A handoff moves one work item from the session that did the work to the session that takes it on — often on a different runtime: Codex to Claude Code, or back. What travels is not the first session's transcript but a compiled package: the goal, what was decided, what exists, what is unresolved, which files were touched, and what the next session is being asked to do. A person reads it before anyone accepts it.

This page covers what a package is and is not, why its version behaves the way it does, how the destination is briefed, the lifecycle over the API, the CLI and the UI, and the live rehearsal that proves the chain with real runtimes. Error codes are in API error codes.

Why a package and not a transcript

A transcript is what one runtime said to one model. It is long, it is in that runtime's own vocabulary, it contains everything the agent tried and abandoned, and — under the default sync_mode: metadata_only — most of it never left the machine it ran on. Handing it to a second runtime would mean handing over the wrong thing in the wrong shape and, for most of it, something Kernel does not have.

What the next session needs is smaller and older: the goal it is working towards, the decisions already taken so it does not retake them, the artifacts that exist so it does not remake them, the blockers that are open so it does not walk into them, and one sentence saying what it is now being asked to do. Kernel compiles exactly that, deterministically, from the work item's own record. Because it is compiled from the record, anything the first session never recorded is not in it — which is the reason every briefing ends by telling the session to record what it does.

The package

HandoffPackageV1 (kernel_handoff_version: 1). The stored document is JSON; the API and the UI show it whole.

Field What it carries
work id, key, title, goal, task_type, priority, state
constraints[], acceptance_criteria[] The work item's own lists, as text
state.phase The work state at compile time
state.completed[] Milestones from the work's history: state changes and completed sessions, each once
state.unresolved[] Open blockers, handoffs a destination turned down, and criteria the latest outcome recorded as unmet
decisions[] The newest 20 standing decisions — never a superseded one — with statement, rationale, recorded_at, evidence[]
artifacts[] The newest 20 titles, each at its latest version: id, type, title, version, uri_or_download, sha256
blockers[] Every open blocker: id, severity, title, status
relevant_files[] Up to 100 paths the source session touched, in codepoint order
next objective (what the destination is asked to do) and purpose (why it is being handed over)
source runtime, session_id, provider_session_id, machine — all optional
evidence_links[] Every URI the decisions and artifacts point at, sorted and deduplicated
generated_at When it was compiled; excluded from the hash
truncation decisions_omitted, artifacts_omitted, files_omitted: what the bounds above dropped

What is never in it

File contents. relevant_files is a list of paths, and the workspace report it comes from is paths with two line counts (POST /api/v1/sessions/{id}/workspace, written by the daemon: WorkspaceFileReport has a path, an insertions and a deletions, and refuses anything else). There is no field a diff hunk could travel in. The daemon that reads the checkout is the only part of Kernel that can see the code, and it sends a diffstat, never a diff. The destination session has the workspace in front of it and can look for itself.

The transcript. See above. Session events are on the session's own timeline, bounded by the machine's sync mode; the package does not carry them.

Superseded decisions. A superseded decision is not what was decided. Carrying it would hand the destination a statement the source has already withdrawn.

Older artifact versions. Every version stays on the record; the package carries the current one, because an older version is a fact about history, not about the state being handed over.

The hash, and why the version does not move on its own

package_hash is the SHA-256 of the package's canonical JSON — sorted keys, no whitespace, generated_at left out. The compiler is deterministic and reads every collection with an explicit order, so compiling the same work state twice produces the same hash. That is what makes "I reviewed version 2" answerable a week later: two people holding the same hash are holding the same document.

package_version counts packages, not compilations (D-051). Recompiling a handoff whose work has not moved answers with the version and hash it already had, writes nothing and emits no event. The version goes up only when the hash changes — when the document is a different one. Two consequences follow, and they are the point:

  • one version never names two documents, so a version number is a name you can rely on;
  • a flow that asserts "the amendment produced a new version" is asserting that the amendment reached the package. Under a counter that ticked on every call, the same assertion would pass against a compiler that had silently ignored the amendment.

kernel handoff compile says which of the two happened: package unchanged (v3, 9f2c1a8b…) or package recompiled (v4, …). A command that hands back the version you already had is giving you the answer — the work has not moved — not failing quietly.

Rejection is an attempt that ended, not the handoff

The destination — or the person about to accept on its behalf — turns a package down with a typed reason: missing_context, wrong_runtime, superseded or other, plus a note in words. The work item is untouched; a rejection is a statement about the package. The handoff lands in the Needs You inbox under rejected handoffs, where it stays until somebody acts.

Acting means amending the work — recording the decision that was missing, attaching the artifact — and compiling the same handoff again. That offers it at a new version, with the rejection still on the record: a compiled handoff carrying a reject_reason reads as "refused once, amended, offered again", which is exactly what happened. Clearing the reason would leave nothing to say the first attempt was ever refused. Compiling is legal from requested, compiled and rejected; it is not legal once a session has accepted, because that session is working from that exact document.

One consequence worth knowing: state.unresolved lists the work's rejected handoffs, including the one being recompiled. So the first package after a rejection carries "handoff rejected (missing_context): …" — which is what the next session needs to be told — and the next recompile does not, because the row stopped being rejected the moment it was offered again. Two genuinely different documents, two versions, stable from there.

The briefing, and what it gives up first

A person reads the package as rendered Markdown (the package page, kernel handoff show). The destination runtime is handed a plain-text briefing instead — every adapter's compile_provider_context(package) — that opens with the work, the goal and the next objective, lists the constraints and acceptance criteria, then what was completed, decided, produced, blocked and left open, then the relevant files, and closes with an instruction to record decisions, artifacts and blockers in Kernel and to complete the work item when the criteria are met.

The briefing is bounded at 8 KiB (D-050). Every runtime has a context window, and a briefing the runtime cuts itself is cut from the end, mid-sentence, with nothing said — which is how a session ends up working without the acceptance criteria and never knowing they existed. So Kernel shortens it first, in a fixed order, and says in the text that it did.

The order things are given up, first to go first:

  1. relevant_files — the workspace is in front of the session; it can look
  2. completed — history; nothing here has to be done
  3. artifacts — pointers to things Kernel still holds
  4. decisions — expensive to rediscover, still retrievable; the first cut that costs something
  5. blockers — the section only; every open blocker is restated under unresolved
  6. unresolved
  7. constraints — a violated constraint is the most expensive omission on this list
  8. acceptance_criteria — reached only when the criteria alone exceed the budget

The goal, the next objective and the closing instruction are never dropped. When even those do not fit they are clipped, marked [cut], rather than removed: a briefing that names no goal is not a briefing, and one that loses its last paragraph reads as complete while the session does the work and records none of it.

Two different notes can appear in a briefing, worded so neither reads as the other. "The package itself leaves out …" is the compiler's bound — the newest 20 decisions, 20 artifacts, 100 files — and describes what Kernel's stored package does not carry. "This briefing was shortened to fit a 8192-byte runtime budget …" describes what this rendering left out of a package Kernel still has whole. "What did the destination session actually see?" is the question a handoff exists to answer, and merging the two would answer it wrongly.

What a daemon-started session is told, beyond the briefing

A session the daemon launches for a handoff is briefed from that handoff's package: the start_session command carries the compiled document and its hash, and the daemon renders it through the adapter for that runtime. A session launched with no handoff is briefed from the work item alone — its goal, constraints and acceptance criteria, with the title standing in for an objective. The daemon logs which of the two it did (session … briefed from handoff … (package v1, 9f2c1a8b…)).

After the briefing, the daemon appends one paragraph of its own: where Kernel's API is (KERNEL_SERVER_URL), which environment variable holds the session's credential (KERNEL_SESSION_TOKEN), the session's and the work item's ids, and the requests that record a decision, an artifact or a blocker and complete the work item. It names the variables and never their values — the prompt goes to a model provider. Without this paragraph the closing instruction asks the session to record what it did and gives it no way to; the live rehearsal below is where that gap showed.

The lifecycle

POST /api/v1/work/{id}/handoffs         -> requested   (target runtime, purpose, source session)
POST /api/v1/handoffs/{id}/compile      -> compiled    (again after the work moved, or after a rejection)
GET  /api/v1/handoffs/{id}                              (a person reads the package)
POST /api/v1/handoffs/{id}/accept       -> accepted    ({to_session_id}; the lineage is written here)
POST /api/v1/handoffs/{id}/complete     -> completed
POST /api/v1/handoffs/{id}/reject       -> rejected    ({reason, note}; recoverable: compile again)
GET  /api/v1/work/{id}/handoffs                         (the work item's handoffs, without packages)

Requesting and compiling are two calls on purpose. Requesting names a destination and a purpose; compiling gathers the state. Keeping them apart is what makes recompiling meaningful: the package a person reviewed is a version of one handoff, not a second row.

Compiling reads the source session's workspace, so it needs session.read as well as handoff.create. Quietly compiling relevant_files: [] for a caller who may not read sessions would make the package — and its hash — depend on who compiled it, which is the one thing the hash exists to prevent.

Accepting names a session that must belong to the same work item, must still be active, must not be the one handing off, and must not already continue another handoff. It writes the lineage onto the receiving session: handoff_id, and parent_session_id set to the session the package came from unless whoever registered the session named a parent themselves — a stated fact is kept. A handoff is accepted once; two sessions can never both believe they own the continuation. Every state-changing route takes an Idempotency-Key, so a client that lost the answer to an accept gets that answer back rather than a correct but useless 409.

Completing closes an accepted handoff once the destination has done what it took on. The lineage stays on the session afterwards; it is history, not a lease.

Cancelling ends a handoff nobody took on — requested or compiled, never accepted, where the end is complete, and never rejected, which is answered by compiling again. It is terminal, and the package it offered stays on the record as what was offered. There is no POST /api/v1/handoffs/{id}/cancel and no kernel handoff cancel: the package page is the only surface that cancels, which is where a person cleaning up an abandoned handoff is.

On the command line

kernel handoff has seven commands where the plan named five (D-052):

$ kernel handoff create AUTH-1 --to claude --purpose review \
    --objective "review the migration and say whether it is reversible" --from 0d9c…
$ kernel handoff compile 7b31… --objective "…"      # after the work moved, or after a rejection
$ kernel handoff list AUTH-1
$ kernel handoff show 7b31…                          # the rendered package; --json for the document
$ kernel handoff accept 7b31… --session 4f0a…
$ kernel handoff reject 7b31… --reason missing_context --note "no test plan"
$ kernel handoff complete 7b31…

create is request and compile, because a requested handoff with an empty package is of no use to anybody and "hand this off" is one act to the person typing it. compile exists separately because the same handoff is compiled more than once, and without it the CLI could start a handoff but never rescue one. list exists because every other command takes an id and nothing else could produce one after the terminal scrolled. Every command takes --json. The rest is in the CLI reference.

In the browser

The work item's Handoffs tab lists each handoff with its state, target runtime, package version and hash prefix, and the lineage from source session to destination. Hand off opens a dialog naming the target runtime, the purpose, the next objective and the source session; it requests and compiles in one act and says so (compiled (v1, 9f2c1a8b…)).

Target runtime offers what your fleet can actually run: a runtime your organization has enabled and that at least one machine, online, reports installed and signed in — the same verdict the Runtimes page draws. A runtime nothing serves is not offered, because a handoff to it is a request nobody could accept; with nothing served the dialog says so and sends you to the registry. Asking over the API is not narrowed this way: an orchestrator that registers its own external sessions needs no machine of yours.

The package page shows the rendered Markdown with the raw JSON in a collapsed <details> underneath — the document the hash is computed over — and the whole hash, because this is the page you open when you want to compare one. Accept lists the active sessions of the work that can take it, or says where to start one. A session is named by its purpose, runtime and status, then by when it started and the first eight characters of its id, so two attempts at the same work on the same runtime are told apart before you choose between them. Reject takes the typed reason and a note, and the rejection then sits on the page as a callout that stays after a recompile. Recompile reports package unchanged or package recompiled with the version. Complete closes an accepted handoff, for the case where the destination session finished without saying so; Cancel handoff calls off one nobody took on, behind a confirmation dialog, because it is terminal. Each button is offered exactly when the state machine would accept that command, so nothing on the page leads to a 409 (DF-005).

The Sessions tab shows the chain from the other end: a session that continues a handoff links to the package it was briefed from and to the session it descends from.

The live rehearsal

tests/live/test_handoff_rehearsal.py is dogfood Run E — the cross-runtime run spec/12_dogfood/02_acceptance_runs.md asks for — with both real CLIs on this machine and nothing faked: a real server, a real kernel-daemon subprocess, a real Codex session that implements a small concrete goal (slugify in util.py, with a test), a handoff compiled from what it left behind, a real Claude Code session briefed with that package that reviews the work and records its verdict through the API, the handoff accepted into it, and both the handoff and the work item completed. It asserts the state of the record afterwards — the lineage on both sessions, the package and its hash stable across a recompile, the events in order — and writes what it saw to artifacts/dogfood/run-e.json: session ids, provider session ids, the model each init event named, the package hash and version, durations and event counts. That file is the evidence RUN_STATUS.md cites.

Two things in it are done by the test in the orchestrator's role and are named so the evidence is read correctly. codex exec runs under a workspace-write sandbox with no network, so the Codex session cannot reach Kernel's API; it is asked to leave a Decision: line in NOTES.md, and the test records that line as a decision and the file as an artifact against the Codex session's id — as any orchestrator driving Kernel through the API would. The Claude Code session has the network and records its own verdict. Codex is used once (D-049). The test also asserts the Codex session left no Blocker: line about not reaching Kernel: the note it is given says its sandbox has no network (D-086 / DF-008), and this is the only place that wording is checked against a real model reading it.

It runs only with KERNEL_LIVE_TESTS=1 and both CLIs installed and signed in (make verify-full), skips with the reason when a provider is unavailable, and bounds every wait so a provider that stops answering fails the test naming the wait rather than owning the run.

Its first run is worth knowing about, because it is what the rehearsal is for. The Codex half went through; the Claude Code review session completed having recorded nothing. Two things were missing, and neither was the model's fault: the session had never been told where Kernel's API was or which variable held its credential (D-054, the paragraph above), and under --permission-mode acceptEdits in print mode the CLI denies every command that expands an environment variable, every interpreter and every test runner — the session tried eleven ways in and was refused each time. Managed Claude Code sessions now run under auto (D-053, measured in the runtime's page). The second run is the one the evidence file records: Codex 0.147.0 implemented slugify, its test and NOTES.md in 85 s (thread 01a0695f…, three files and 32 insertions reported); the package compiled at v1, hash e72415fa…, carrying the decision, the artifact and the three paths, and the same hash again on recompile; Claude Code 2.1.258 (claude-fable-5-1) reviewed in 46 s, ran the four tests, recorded "Review verdict: the slugify implementation in util.py is correct against the specification and its tests pass" through the API and completed the work item itself; the destination session carries handoff_id and parent_session_id, and the work's history reads requested → compiled → accepted → completed. One more thing it showed: the Codex session, told where the API was, tried to reach it, could not through its sandbox, and left a Blocker: line in NOTES.md saying so — which the review session then read and set aside. The note was honest about the API and silent about the sandbox, and that gap is now closed: the daemon writes the note from what the session can actually reach (D-086 / DF-008). A Codex workspace-write session is told its sandbox has no network, told not to raise a blocker about it, and told that recording goes through the daemon — its tools, its final message, and the files it leaves. Both notes are stored under tests/fixtures/access_note/, so a reworded one arrives as a diff somebody reads.

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