Claude Code executor and private continuity
The Claude Code executor runs each agent turn as a detached claude -p segment on the Work’s session, resumes or hands off sessions from continuity it keeps private to itself, and gives the agent its toolbelt over MCP. It lives in extensions/claudecode and implements the B03 executor contract. os mcp, the toolbelt’s MCP transport, lives in internal/cli. Like the shell executor, the package imports only public contracts. Its process identity helpers are copied from extensions/shell, because B01 forbids one executor importing another. Its package, extensions/claudecode/pkg, serves it as executor claude-code.
The executor advertises interrupt, inject, resume, workspace, sandbox, container and toolbelt: mcp. It runs as a package bundled with the release, served by open-conduit extension-run claude-code.
A segment
Section titled “A segment”One segment is claude -p on the Work’s session:
claude -p --model M --permission-mode P --input-format stream-json --output-format stream-json --verbose --replay-user-messages [--plugin-dir D] [--json-schema STATUS] (--resume S | --session-id S) [--mcp-config <segment>/mcp.json] [extra_args…]The segment runs detached, like the shell executor’s:
- Recovery intent first. It persists
open-conduit.claudecode.recovery.v1(naming the segment directory) throughPersistRecoverybefore it touches anything else. - Process group. A
/bin/shwrapper runs as a new session and process-group leader. Its$0carries an unguessable nonce, so identity is checked beyond the PID. The wrapper records its PID and exit status, and it signals the remaining group members (including the MCP server) when claude exits. - Files. stdout goes to
out.jsonland stderr toerr.log. - Prompt on stdin. The first user message is the prompt. stdin stays open while this daemon instance holds the process. The follower closes it when the
resultline arrives, so claude exits.
Recover follows the shell executor:
- It adopts a verified live leader at a higher epoch and replaces the MCP credential file first.
- It returns a recorded result identically.
- It refuses lower epochs, tampered metadata, and a dead process with no recorded exit or stop (
ErrNotAdoptable). - It never launches.
An adopted segment keeps running and finishes its turn. Its stdin went with the daemon that started it, so it takes no injected input. Stop, Interrupt (SIGTERM, then SIGKILL after the grace period) and Forget behave as they do in the shell executor.
Prompt, inputs and injection
Section titled “Prompt, inputs and injection”A turn starts on the playbook skill /open-conduit:<playbook> <args>, or on the arguments and body alone when the binding names no playbook (-). The body is:
| Turn | Arguments | Body |
|---|---|---|
| A session’s first | <args> |
## Already waiting + the rendered inputs |
| Another turn, or a resumed wake | <args> events |
## Since your last turn + inputs |
| A handoff to a fresh session | <args> handoff |
## Handoff from your last session + note + ## Since your last turn + inputs |
args and branch are templates. {key} is the Work’s first correlation key, {number} is the first number in it (the key itself when it has none) and {work} is the Work ID. The inputs are the acceptance-ordered RunRequest.Inputs. The default renderer knows no service schema: it shows each Event’s type, the common payload fields (author, kind, number, path, line, state, title, URL) and the body or text. A tracker extension may supply a richer Renderer; GitHub’s adds reply hints (see playbooks and rendering).
Inject writes the Event as a stream-json user message (## Arrived during this turn …). claude takes it in before its next model call and echoes it back (--replay-user-messages). The echo is recorded as consumption. The result data reports injected: {accepted, consumed}, and the echoed Event IDs are returned as Result.ConsumedInputs, so core consumes them with the turn and starts no further attempt for them. Inject success alone still means accepted for delivery: an input that was never echoed stays eligible for the next attempt. A segment that is adopted or already has its result, or an execution this instance does not hold, returns extension.ErrNotAccepting. Core then records the intent as not delivered and keeps the input for the next attempt or wake. A retried dispatch of an Event already on stdin is not written twice. The echo proof applies on waiting and failed turns too, and also when the turn ends before core commits the dispatch acknowledgement (generic inject acknowledgement). Input accepted just before the result, and so never echoed, is presented again in the same way. The executor passes conformance.CheckInjectExecutor.
Outcomes
Section titled “Outcomes”The executor adds no core state. The structured status uses --json-schema (state, summary, handoff, report). Its descriptions say only what each state means to Open Conduit: finished completes the Work; building, review, help and done keep it open for the next input, with review, help and done putting something to the owner. They name no code host, issue, PR, check or preview: what a state means for a kind of Work (that building follows a push, say) is its playbook’s to say, as work-issue’s “Ending a turn” does. A test (TestNoDomainTerms, with a negative control) keeps such terms out of the executor’s sources. It maps as follows:
| Turn ended | Outcome | Work afterwards |
|---|---|---|
finished |
completed | completed |
building, review, help, done |
handoff (completed for a RunOnce Work kind) |
ready: the next accepted input starts attempt N+1 |
The agent called wait through MCP |
waiting (core converts the result and keeps its data) | parked; the wake resumes the same attempt as a new --resume segment |
| Error, no result, no status, interrupt | failed | retry policy |
| Usage limit | failed with Result.RetryHint{ResetAt} |
retried at the reset plus two minutes (or after 30 minutes if unknown) |
The outcome data schema is the binding’s optional status_schema. The default is core.turn.v1, which every projector reads and from which the host publishes the turn-ended Event; a source-named <source>.status.v1 that earlier setups wrote in their config is read as core.turn.v1. The five states are this executor’s default vocabulary, not the core’s: the host publishes a turn’s end for any status. A config that cannot be read fails under open-conduit.claudecode.result.v1. The projector maps help, review and done to labels. building and finished project no status label. The other data fields are:
reasonandkindreport, the status’s optional write-up for the operator (at most 20,000 bytes), which the web view shows under the turn’s summarymodels,model_calls,tool_callsandtokens(input, output, cache read, cache creation)limitedandresets_atexit_codeandinjected- the
transcriptandlogartifact IDs workspace{ref, schema, data}
The data never carries the session or the handoff note. Usage maps to extension.Usage, with cost in USD and CachedTokens holding the cache-read count. A resumed session reports cumulative totals, so the per-turn delta subtracts the session’s previous totals, which are kept in continuity. Totals that went backwards (a fork) are left as reported.
Usage limits. The executor detects a limit from api_error: usage_limit_reached, the rate_limit_event/api_error_params reset, or claude’s wording (“resets 9:40am (Europe/London)”). A limit records a hold for the whole executor until the reset plus the grace period. While the hold lasts, Run returns a failed result with the same hint and launches nothing. extension.Result.RetryHint lets a structured failed result carry the hint. Core treats it like a RetryHintProvider error, but the observed result also proves the attempt finished, so no uncertain hold remains. A hint on any other outcome is invalid. A hinted failure still uses the bounded failure budget.
Private continuity
Section titled “Private continuity”Continuity lives in namespaced coordination KV (the package’s own claude-code, key work/<work id>). Core never interprets it. It holds:
- the session and past sessions
- the count of turns that ended with a status
- the handoff note and the last state
- the last turn’s end time
- the session’s context when the last turn ended
- the session’s cumulative totals
- the in-flight decision
- the attach command
Before launch, Run decides between resume and fresh and records that decision in a transaction fenced by the attempt’s lease. A crash after the record is written, but before launch, leaves an attempt that cannot be adopted. Its retry takes the recorded decision over (same session and kind) instead of deciding again. Finishing a segment applies the turn exactly once per segment (the same per-turn delta every time), before the result is recorded. Applying it again after a crash changes nothing.
The resume and handoff rules are:
- A session whose transcript exists in the workspace (
<config>/projects/<cwd with non-alphanumerics as ->/<session>.jsonl) resumes, even when its first turn never ended. - A session with a handoff note that was never written starts afresh from the note.
- A session is large when its last turn ended with more than
handoff_tokensof context (default 400000; 0 turns this off). A large session starts a fresh session from the note, even while its cache is warm: one cache write of the small new context soon costs less than reading the large one on every call. The context is the last main-thread model call’s input (cached or not) and output, from that message’susagein the stream-json output; a subagent’s calls don’t count. - A session is cold when its last turn ended more than
handoff_minutesago (default 55). A cold session followscold_turns:handoff(the default, and what the playbook tells agents to expect) starts a fresh session from the note,resumeresumes it. Attachreturnsclaude --resume <session>in the workspace and clears the note, so the next turn resumes even when cold. It implements the publicextension.AttachExecutor; Work without a session returnsErrNoSession, which wrapsextension.ErrNothingToAttach.
Release. Maintain releases terminal Work’s continuity. Records written while the handoff experiment ran may still carry a trial and feedback questions; they are ignored.
Telemetry goes to Options.Report. The executor’s extension (claudecode.NewExtension, served as a package through inproc.Executing) writes it to its own log, logs/claude-code.jsonl in its package’s state directory (<state>/extensions/claude-code, through inproc.Host.OpenLog, rotated by size like the daemon’s own logs, sdk.RotatingLog), outside core, as one agent_turn record per turn. A record carries context_tokens (the session’s context when the turn ended) and, for a handoff turn, handoff_reason: size, cold or missing (the session’s transcript was gone). Its housekeeping (Maintain) is the extension’s maintain task, every 30 seconds.
Like core telemetry, it is at most once across a kill.
Toolbelt over MCP
Section titled “Toolbelt over MCP”When the attempt has a toolbelt token and the os binary is available, the segment’s mcp.json defines the stdio server open-conduit: <toolbelt dir>/os mcp, with OS_ENDPOINT and OS_TOKEN_FILE (a private 0600 file: the host-kept ToolbeltTokenFile in the execution’s run directory when the host gives one, see execution endpoints, otherwise one in the segment directory). The token itself never appears in the file or in claude’s environment. Adoption replaces the token file, and os mcp re-reads it on every call.
os mcp speaks newline-delimited JSON-RPC 2.0 (initialize with version negotiation, ping, tools/list and tools/call). Its initialize answer’s instructions are a fixed sentence, then an ## Operations list of each operation the execution is granted with its one-line summary and input fields (body, number? for an optional one), then the guidance of each, all fetched from GET /v1/toolbelt/guidance; an extension supplies the summary, input and guidance for its own operations (extension.Operation.Summary, Input and Guidance), so a playbook need not restate them. When the fetch fails (an older daemon) the sentence stands alone. It offers these tools:
read_contextemitcreate_workwaitdeclare_outcomecall(registered extension operations)
Their inputs are the toolbelt wire inputs, and every call goes through POST /v1/toolbelt/call, the CLI’s authority path. Refusals come back as isError tool results carrying only the safe error code. A call that fails without the daemon’s answer says why, also as an isError result: no answer within the call’s 60 seconds (the operation may still finish, and a retry with the same request_id never repeats it), the management endpoint unreachable (nothing listening on the socket, with the cause), or the connection breaking mid-call. Only the second reads “unreachable”, so a slow operation, such as one that reads an issue and every page of its comments, is not mistaken for a daemon that is down. claude’s own environment gets the execution identity (OS_SCOPE_ID, OS_WORK_ID, OS_EXECUTION_ID, OS_ATTEMPT) but no toolbelt credential.
Playbooks and rendering
Section titled “Playbooks and rendering”The playbooks, waits and GitHub tools themselves are described in engineering tools, waits and playbooks. The executor’s side is:
- Playbooks. Bootstrap writes the playbook plugin to
<state>/playbooks/plugin, the defaultOptions.PluginDirfor bindings withoutplugin_dir. - Project additions. Read from the turn’s workspace (
.open-conduit/playbooks/<playbook>.md) and appended to the turn body. - Rendering. The host renders each input through the extension that owns its family (
inproc.Host.Render,event.render_owned); GitHub’s adds reply hints naming toolbelt calls. - Tool calls. The MCP
calltool sends itsinputobject as payload data, with thefile_path/save_tofile conventions.
A call github.wait parks the attempt exactly like wait, because Complete applies the declaration.
Operator controls
Section titled “Operator controls”Operator setup covers attach through POST /work/{id}/attach under an operator hold, transcripts reachable as run artifacts, Usage (opt-in, doctor), Version (doctor), Options.DryRun (a stub turn on haiku) and the init --with claude setup command, which starts through the executor’s interactive launch (below). The executor also renders each turn’s activity as a run artifact, shown by the TUI and runs log (installed integrations).
Handed-back Work sits ready with no pending input, and the host loop skips it quietly.
Interactive launch
Section titled “Interactive launch”The executor offers an interactive launch (extension.InteractiveLauncher), which init --with claude (or --with claude-code) starts the setup playbook through (operator setup). It writes the playbook as the one skill of its own plugin, named open-conduit, into the files directory the host gives it (<state>/playbooks/setup), removing any other skill an earlier launch left there, and answers claude /open-conduit:<name> --plugin-dir <dir> --allowedTools Bash(<command>:*)…, one entry for each command the host allows, so nothing else goes without asking. The prompt comes first because --allowedTools takes every argument after it. It declares claude as its launch name, and uses claude as the program unless the request names another.
Workspace and environment
Section titled “Workspace and environment”With a binding resource, a new segment calls Acquire({Mode: workspace_mode (default "work"; "clone" for a git clone that shares nothing writable with the checkout), Params: {"branch": …} when branch is set}). The executor reports that mode to configuration validation (config.ResourceModer), so a sandboxed binding can be checked against it (sandboxed bindings). That request always wins over the git resource’s own mode, which applies only when the executor requests none, so config check warns when the resource names a different mode (modes). The default stays work: the session transcript is tied to the working directory, and a fresh folder per execution would break resume across a Work’s attempts. A resumed segment calls Lookup on the parked segment’s lease reference, which never creates anything. claude then runs in Resource.Dir. The reference is kept with the segment and reported in the result. A setup failure or an unavailable workspace is a structured failure with no process. Without a resource, the session lives in the binding’s dir, used as-is and shared by every Work on the binding, or else in a private directory that stays the same for the Work. A dir that is gone at turn start is a structured workspace_unavailable failure, and a binding with both a resource and dir fails its turns as invalid_config. An invalid_config result, or a workspace the provider refused as extension.ErrInvalid, is marked Permanent and not retried. The executor never removes workspaces; the host reconciles them (B07).
A binding with a sandbox (B09) wraps claude through extension.WrapLaunch, needing its workspace, its own config directory (CLAUDE_CONFIG_DIR in the binding’s env, apart from the operator’s ~/.claude and ~/.claude.json, which its needs deny; B09), its MCP config, the playbooks, its token directory, the host’s toolbelt directory (<state>/toolbelt, daemon) and toolbelt socket, and api.anthropic.com. Sandboxed, TMPDIR and CLAUDE_CODE_TMPDIR are the segment’s own tmp directory, while srt itself keeps /tmp for its sockets. A refusal is a structured sandbox_denied or sandbox_unavailable failure with no process, Recover adopts a live segment only once extension.AdoptSandboxed proves it is in its sandbox, and an interrupted turn’s result says whether it got term or kill. Claude Code’s own sandbox.* settings are executor config, not a provider; they can be added inside an srt sandbox with extra_args as defence in depth.
A binding with a container (containers) launches through ExecutionContext.Container and advertises Capabilities.Container. Its claude keeps HOME and CLAUDE_CONFIG_DIR in the Work’s own home (ExecutionContext.ContainerHome), seeded from home_seed on first use, so a session resumes across segments. Its sign-in, the binding’s selected secrets and any variable named as a credential (ending _TOKEN, _API_KEY, _SECRET or _PASSWORD) go in the launch’s Secrets, which the provider keeps out of the container’s environment; segments set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1. The executor sets no credential helper for any one host: a run layer’s helpers (the GitHub extension’s, say) come first, then the toolbelt’s !os git-credential as git’s general credential.helper unless the run already sets one. Recover adopts through the broker as shell does. Attach for such a session returns open-conduit container attach <work>, and ContainerAttach gives the interactive claude --resume the daemon launches in an attach container (attach in a container).
The environment follows B05:
- Sanitized baseline. The shell baseline of the executor’s own process, plus
CLAUDE_CONFIG_DIRand the executor config’spass_envnames from that process. Out of process that environment is sanitized, so the daemon’s own variables (a proxy, the daemon’sCLAUDE_CONFIG_DIR) come from the binding’spass_env: the operator names them, the host validates them with the configuration and passes its values asExecutionContext.HostEnv, which the executor adds here. Validating an executor config that setspass_envwarns that it reaches only the executor’s own process.ANTHROPIC_API_KEY,CLAUDECODE,CLAUDE_CODE_*and other credentials are dropped. - Provider layer. Any
OS_*names are dropped from it. - Run layer. The extensions’ run environment (
ExecutionContext.Env, extension protocol), applied withextension.ApplyEnv: itsGIT_CONFIG_*entries go after the provider layer’s, andOS_*names are dropped. A host run with a run layer and a toolbelt also gets the toolbelt client (OS_ENDPOINT,OS_TOKEN_FILEand the toolbelt directory first onPATH, neverOS_TOKEN), so a layer can callos; a container run always has it. Other host runs keep the toolbelt out of claude’s environment, and only the MCP server has it. - Binding
env. - Selected secrets. An API key comes only from a secret mapped with
secret_env. - Execution identity.
Binding config
Section titled “Binding config”The config is strict and validated at load. Every field is optional:
| Field | Meaning |
|---|---|
binary |
The Claude Code CLI (default claude). |
model |
The model (default opus). |
permission_mode |
Default bypassPermissions. |
playbook |
The skill turns start on. -, or leaving it out, means none: turns start on the rendered Work alone. init github writes it. |
plugin_dir |
The playbook skills directory. |
args |
Default {number}. |
structured |
Default true. |
status_schema |
The outcome data schema (default core.turn.v1). |
branch, workspace_mode |
The workspace request. |
dir |
An absolute folder a workspace-free binding’s sessions run in, used as-is. A folder that has gone fails only that binding’s turns, as workspace_unavailable, never the config. In an included folder, dir defaults to the folder (unless the binding has a resource), and a relative dir or plugin_dir resolves against it. |
handoff_minutes |
How long after a turn the session counts as cold (default 55). |
cold_turns |
What a cold turn does: handoff (default) or resume. handoff_share, the removed experiment’s setting, is ignored with a config check warning from the executor (ConfigWarnings) for one release, then rejected. |
handoff_tokens |
How much context a session may end a turn with before the next turn starts a fresh one from the note (default 400000; 0 turns it off). |
auto_compact_tokens |
Where Claude Code compacts a session in the middle of a turn, set as CLAUDE_CODE_AUTO_COMPACT_WINDOW: it compacts a little before this, or before the model’s window if that is smaller (default 600000; 0 leaves Claude Code’s own threshold). env setting the variable overrides it, and so does pass_env naming it when the variable is set. |
env, pass_env, secret_env |
The environment. pass_env here admits variables of the executor’s own process only; name the daemon’s variables in the binding’s pass_env. |
extra_args |
Extra arguments. These may not override -p, the session or the MCP flags, nor --tools when builtin_tools is set. |
builtin_tools |
The only built-in tools Claude Code offers, passed as --tools. An empty list turns them all off, so the agent has only its MCP toolbelt: no shell, file access or web. Unset keeps Claude Code’s default set. Attaching to the session keeps the same limit. |
strict_mcp |
true passes --strict-mcp-config, so the toolbelt is the agent’s only MCP server: none of the operator’s own, a project’s or claude.ai’s servers and connectors load. Attaching keeps it. Default false. |
usage |
true lets doctor’s claude-code check read the subscription’s 5-hour and 7-day usage windows with this binding’s Claude login (its CLAUDE_CONFIG_DIR, else the default), warning at 90%. Off by default, because it reads the login token. |
home_seed |
For a binding with a container: an absolute directory (a settings.json, plugins) copied into the Work’s home on its first use, its .credentials.json left out. A copy, never a mount. Use plain files: a symbolic link is copied as a link, and one to a host path dangles inside the container. |
models |
Models a Work may pick for its turns through model in its context, for example one an operator chose when starting it. A Work that names none, or one not listed, uses model. |
Setup probe
Section titled “Setup probe”claude-code.status is the executor’s probe for setup sessions (extension.SetupProbes). It says whether claude is on the daemon’s PATH, its version, and whether it is signed in and how: subscription when a Claude login is found (the macOS Keychain item or ~/.claude/.credentials.json), api_key when ANTHROPIC_API_KEY is set in the daemon’s environment. It never returns the token or account detail, and it starts no session.
Verification
Section titled “Verification”- Unit tests run a fake
claude, which records its invocation, echoes stream-json replays, writes the transcript and reports cumulative totals. They cover:- the shared executor conformance case
- the invocation, the MCP config and the sanitized environment
- resumed and fresh sessions with per-turn usage deltas
- the port of
TestColdSessionHandsOffToAFreshOne: warm resume, a cold handoff, a cold resume undercold_turns: resume, attach forcing resume and release of terminal Work - a warm but large session handing off by size, a smaller one resuming and
handoff_tokens: 0turning it off (TestLargeSessionHandsOff), andauto_compact_tokensreaching claude’s environment (TestAutoCompactTokens) - the cold decision across executor restart: adopting the running turn and taking over an undone decision
- continuity left by the handoff experiment loading and releasing without error
- interjection consumption
- usage-limit delay and the executor-wide hold
- failures, interrupt and missing binary
- detached adoption with credential replacement and lower-epoch and superseded refusals
- work-workspace acquisition and resume by lookup
- config validation, and the playbook a binding that names none starts on
- no code-host or issue terms in the executor’s sources
- the ported parser tests (parse, limits, reset text, call counts,
perTurn)
TestProductionSchedulerResultRetryHintchecks that a result hint delays the retry and leaves no uncertain hold.TestDaemonClaudeCodeExecutionruns against the composed daemon:- a fake claude drives the real
os mcp(initialize, tools/list, read_context, wait) and parks - the wake resumes the same session
- an event mid-turn is injected and consumed
- a turn running at a daemon SIGKILL is adopted with one launch
- no runtime API shows the session
- a fake claude drives the real
TestDaemonClaudeCodeContinuityKilluses thekvfault boundary to SIGKILL the daemon before and after the decision commit. The retry launches once and resumes the Work’s session.
Limitations
Section titled “Limitations”- No “in flight” list. The cross-issue “in flight” list has no Open Conduit equivalent; the playbook instead tells the agent to read overlapping issues and their PRs.
History
Section titled “History”- 03-03 delivered the executor, private continuity, the handoff experiment and
os mcp. No real Claude Code session ran; all evidence used fake binaries and fixture transcripts. Real-agent turns, real limits, real resume and real handoff telemetry were left to 03-06. At that point reply-command hints, the playbooks, plugin preparation and the engineering tools were deferred to 03-04, and operator controls to 03-05. Handed-backreadyWork was reported on every host pass. - 03-04 delivered the playbooks and plugin, project additions, the GitHub renderer with reply hints and the MCP
callpayload and file conventions. - 03-05 delivered attach, transcripts as run artifacts,
Usage,Version, dry run andinit --with claude, and made the host loop skip idlereadyWork quietly. A rendered transcript and activity view was not ported then; it arrived later as the activity artifact. - 03-06 made an echoed injection consumed with its turn through
Result.ConsumedInputs, after a real turn showed the extra attempt it otherwise caused. - 04-02 extended that proof to every outcome and to the in-flight acknowledgement race, and stopped a retried dispatch writing an Event to stdin twice.
- At 03-03 the refusal was documented as
ErrNotAccepting; it is nowextension.ErrNotAccepting. - 07-01 made
Attachimplement the publicextension.AttachExecutor. extension.Result.RetryHintwas a contract clarification made for this executor; the bounded failure budget it still uses dates from 02-04.- #73 removed the handoff experiment (the draw, trials, the blinded feedback question and
handoff_share); a cold turn now follows the binding’scold_turns. - #164 added the sandbox wrap (B09).
- #428 moved it out of process. It runs as a package bundled with the release (
extensions/claudecode/pkg,open-conduit extension-run claude-code), the executor itself served throughinprocsdk, with its live output and container attach crossing the wire (executor.live,executor.container_attach). A run takes the toolbelt and playbook directories the host names for it (ExecutionContext.ToolbeltDir,PlaybookDir). Its continuity moved fromexecutor.claudecodeto the package’s ownclaude-codenamespace; the daemon moved existing records once at start until #497 dropped that step, so continuity an older release left inexecutor.claudecodeis no longer carried over. Its doctor check is its own (claude-code, formerlyclaude_codein bootstrap), and usage reading moved fromserve --claude-usageto the binding’susage. Certification runs it against a fakeclaudethe fixture command writes.