Skip to content

M2-34: Claude Code runner core

Status: ready | Depends on: M2-03, M2-12, M2-29 | Milestone: M2

Goal

After this ticket plugins/claude-code-runner is a working reference adapter: a Python process on the M2-29 SDK that serves dev forums with a project binding (M2-03). It resolves each binding's opaque workspace_ref under runner.projects_root (symbolic links resolved, nothing outside) and reports the readiness of every bound forum it serves in its heartbeat; it accepts an agent.invocation only with a valid invocation token and a configured tool class not above the token's tool_class; it proves the actual billing mode for every run with claude auth status in the run environment right before startRun and checks it again with system/init.apiKeySource, so a run in organization without a key never falls back to a subscription; it starts exactly one claude -p turn per run, in its own process group, with an environment built as an allow list and a command line that equals the table of 09 "Process contract" for chat, scoped and host, and reports the configured class as tool_class_used; it hands the server's context to the runtime with trust markers, unverified content only as marked data and files only as data (ADR 0054); it reads the stream into the live box, answers in the thread, proposes the title once per new session and reports the cost of each run as the difference of the cumulative session values. Why: the runner is the reference for dev forums (01 "Dev forum with project binding and runner agent"), and every guarantee of the adapter contract (I3, I5, SR2, ADR 0054) only holds if the adapter enforces it by construction instead of assuming it (D-R-RUNNER). Tests run against Fake-Claude, an executable that plays stream-json fixtures, and the SDK FakeServer; the installed CLI runs only behind an explicit switch because a real turn costs money. This is the runner side of AG-20, AG-30, AG-11, AG-08, BR-12, BR-05, BR-13 and AZ-P6; M2-35 adds follow-ups over stdin, the stop, compaction and the error paths of 09 "Error paths", M3-12 answer attachments.

Read first

  • docs/PLAN.md: section 3 "Way of working per ticket" (point 5: real model calls only behind an explicit switch); section 5 "Test strategy" (row "Test doubles": Fake-Claude); section 8 "M2 Agent platform, extensions and terminal" (Goal; Scope "reference adapters for Hermes Agent and the Claude Code runner"; Out of scope "Files in agent invocations" (M3) and "SR3 and dev environments (V2)"; Exit criteria 2, 5 and 10; Test strategy "fake Claude with recorded stream fixtures"; Red team "forging or reusing the invocation token; faking the reported mode", T-AI-01 to T-AI-09; Risks "the Claude Code CLI changes stream messages"); block "agents and extensions" rows M2-03 and M2-29 (what exists), M2-34 (this ticket), M2-35 (next), M2-09, M2-10, M2-12, M2-13, M2-14, M2-18, M2-19, M2-20 and M2-22 (server side of the operations the runner calls), M2-31 and M2-33; section 9 rows M3-05 and M3-12; section 16 row V2-03; section 18 rows B6, I3, I5 and "Dev forum with project binding and runner agent"; section 19 (P-01, P-09, P-10, P-11, P-16).
  • docs/spec/09-agents.md: 1 "Principles"; 2.2 "Features" (title, followups); 2.4 "Tool class"; 4.3 "Connection and cursor" (acceptance into the inbox before ack); 4.4 "Events to the adapter"; 4.5 "Invocation token" (points 1 to 10 of the adapter check, the table of reason values) and 4.5.1; 4.6 "Run context" (trust markers, duty of every adapter, files always data, transcripts); 4.7 "Adapter operations"; 4.8 "Heartbeat and reported state"; 4.9 "Order in the adapter: commitment first, then runtime"; 4.10 "Usage at the end of a run"; 5.1 to 5.4; 9.1 "Lifecycle"; 12 "Auto-title" point 5; 15 "Reliability"; 16.1 "Test doubles" (row Fake Claude) and 16.2 (AG-08, AG-11, AG-19, AG-20, AG-30); 18 "Reference adapter: Claude-Code-Runner (development forums)" completely; 19 (R-AG-06, R-AG-07, R-AG-17, R-AG-20, R-AG-21, R-AG-23, R-AG-24); 21 "Configuration values of this chapter".
  • docs/spec/10-backend-policy.md: 3 "Invariants" (I3, I5); 7 "Adapter check (I3)"; 8 "Expected and reported"; 10.2 "Claude-Code-Runner"; 12.1 "Recording" and 12.3 (cache writes without a stated duration count as 5 minutes); 15 "Tests" (BR-05, BR-12, BR-13).
  • docs/spec/11-extensions.md: 11 "SDK design" (11.2 modules auth, gateway, invocation, runs, outbox, retry, heartbeat, testing; 11.3).
  • docs/spec/07-permissions.md: 2.11 "Tool class of an agent"; "SR2 Tool class"; "SR3 Dev isolation"; "SR9 Acting-for binding (confused deputy)"; 11.2 AZ-P6.
  • docs/spec/19-security.md: 9 "Logs without secrets and without content" (SEC-LOG-2); 11 "Controls for agents" (SEC-AI-1, SEC-AI-2). docs/spec/21-dev-isolation.md: status paragraph and 1 "Starting point". docs/spec/16-operations.md 5.2 "Secret provider (ADR 0011)" (secret_ref, schemes file and env).
  • docs/spec/23-quality.md: 3 "Component contract" (row "Python project") and "Documentation in code"; 11. docs/spec/02-architecture.md 6 (list "Places that carry the product name"). docs/spec/06-api.md 2 "Basic rules" (no null), 5 "Errors", 7 "Idempotency".
  • docs/spec/03-data-model.md (core contract, read only): project_bindings (runner_config "within agents.tool_class"), agent_sessions (started, version), runs (billing_mode_used, billing_evidence).
  • docs/api/openapi.yaml: operations getDiscovery, getMe, listServedChannels, getServedChannelConfig, sendHeartbeat, getRunContext, renewInvocationToken, startRun, patchRun, finishRun, createMessage, getThread, setThreadTitle, reportAgentSession, downloadFile; schemas AgentInvocationPayload, AgentSession, AgentConfigChangedPayload, RunCancelRequestedPayload, AgentChannelConfig, AgentChannelConfigList, ChannelBehavior, ProjectBinding, ProjectRunnerConfig (added by M2-03), Heartbeat, HeartbeatAck, ProjectReadiness, BillingEvidence, BillingMode, ToolClass, RunStart, InvocationTokenRenew, InvocationTokenGrant, RunPatch, RunStep, RunMetrics, RunApiRetry, RunFinish, CostReport, MessageCreate, ThreadTitlePut, AgentSessionReport, RunContext, RunContextMessage, RunContextAttachment, Problem (x-problem-codes). docs/api/asyncapi.yaml: messages event.agent.invocation, event.agent.config_changed, event.run.cancel_requested, event.agent.followup.
  • ADRs 0022 (agents outside the server), 0023 (signed invocation token, check in the adapter), 0024, 0026 (SDK: generated core plus hand layer; "the hand-written layer defines no types of its own for objects that the core already generates"), 0046 (multi-user dev environment only with isolation), 0053 (no JSON null), 0054 (complete, including "Confirmation").
  • docs/DECISIONS.md: D-R-RUNNER, D-L8, D-AG-05, D-AG-06, D-AG-20, D-AG-21, D-AG-22, D-W-16, D-W-100; section 10 for the format of D-M2-34-<n> rows.
  • docs/security/threat-model.md: T-AI-01, T-AI-03, T-AI-08, T-DEV-01, T-TK-05. docs/RISKS.md: RK-01, RK-13, RK-14, RK-16. docs/OPEN-QUESTIONS.md: OQ-17 (package names stay unpublished) and OQ-18 (no subscription in V2 dev environments); no other item concerns this ticket.
  • docs/operations/configuration.md: the runner keys of 09 21, adapter.invocation_token_leeway, adapter.jwks_refresh_min_interval, sdk.http_timeout, sdk.retry_max_attempts, agent.run_update_min_interval, message.max_content_bytes. docs/observability/signals.md names no runner metric; this ticket adds none.
  • Briefs: docs/briefs/M2-29-python-sdk.md (notes 2, 8 to 11 and 13 to 15: Client.bind_run, AgentHandler with actual_billing, actual_tool_class, prepare and execute, Adapter, RunHandle and RunPatcher, SqliteInboxStore, render_context, the outbox's send_message, build_heartbeat, FakeServer with its faults and scenario helpers), M2-35-claude-code-runner-paths.md (what the next ticket expects from this one: files instead of pipes, the turn record, the stored input line, Fake-Claude and its fixture metadata), docs/briefs/M2-03-project-binding.md (notes 4, 7 and 11: format rules of workspace_ref and runner_config, readiness only from the effective operator, project_runner_config.v1 and "M2-34 builds that"), M2-23-generated-clients.md (notes 1, 8, 10 and 11: package bothy_sdk, gate scope, quality.coverage.python_min_percent, tool versions), M2-09-run-lifecycle.md (notes 3 and 6: start answers, adapter reasons that finishRun stores), M2-10-run-document-live-box.md (notes 3 to 5: patch paths, set semantics, cutting), M2-12-agent-sessions.md (notes 9 to 11: session mode, attach at start, reportAgentSession with If-Match and started), M2-13-run-context.md (shape of the context), M2-14-acting-for.md (note 1: header Invocation-Token), M2-18-auto-title.md (note 8), M2-20-heartbeat-presence.md (note 3: closed key set of details), M2-22-costs.md (notes 3 and 6: costs is the consumption of this run).
  • Code on the base branch: sdk/python (README, src/bothy_sdk/ hand layer and generated/, testing FakeServer, the context formatting helper of the M2-29 row), plugins/README.md, plugins/claude-code-runner/README.md, tools/ci/components.py (release component claude-code-runner already listed), tools/docs/tests/test_config_values.py, the root Makefile (Python component lines), sonar-project.properties (plugins/*/coverage.xml). Read the merged state with git log before you start.
  • Implied dependencies: the header names M2-03, M2-12 and M2-29. Their closure brings M2-23, M2-14, M2-10, M2-09, M2-08, M2-07, M2-06, M2-05, M2-04, M2-02 and the M1 tickets below them. The header names M2-12 because the runner reports started with If-Match (AgentSessionReport.started, IfMatchOptional and 412 on reportAgentSession, M2-12 notes 11 and 17).

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
  4. Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. Limits, windows and thresholds only through named configuration keys listed in docs/operations/configuration.md; code comments and docs name the key, never the number.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/api/ in the same change. The core contract (docs/spec/00-glossary.md, 03-data-model.md, 04-sync-protocol.md, 05-actions.md) is not edited by this ticket.

Note on rule 3: the runner is a program, not a library; its public Python symbols carry docstrings checked by ruff D with convention google (23 "Documentation in code"), and its operator interface (command, configuration keys) is documented in its README and in 09 21, from which make config-reference generates the reference. No mkdocstrings page: 23 names generated API docs only for the SDKs (ruling D-M2-34-1, Design note 18).

Product requirements implemented here:

  1. A turn is one claude -p process with the base command line of 09 18.2 and the flags of exactly one tool class from the table there: chat gives --permission-mode dontAsk --tools "" --disallowedTools "mcp__*", scoped gives --permission-mode dontAsk --allowedTools <named tools>, host gives --permission-mode bypassPermissions; --permission-prompts none is always set (09 18.2, R-AG-21, R-AG-23, D-W-16).
  2. The tool class is the configured one: it is reported as reported_tool_class in the heartbeat and as tool_class_used in startRun; a configured class above the token's tool_class (order chat < scoped < host) starts nothing and ends the run failed with tool_class_mismatch (09 4.5 point 10, 2.4, 07 2.11, AG-30, AZ-P6).
  3. The environment of a turn is an allow list, never a deny list: HOME, USER, LOGNAME, SHELL, a cleaned PATH, LANG, LC_*, TERM, TMPDIR, optionally SSH_AUTH_SOCK, plus the variables of the mode (09 18.2, 18.3); the runner never reads, copies or writes login files of a runtime (09 18.3, 10 10.2, I5).
  4. The actual mode is proven per run: the pre-check claude auth status in the run environment binds the start, system/init.apiKeySource is the counter-check; a mismatch or an undeterminable mode starts nothing or interrupts the turn with SIGINT and reports billing_mode_mismatch; organization without a key never falls back to a subscription and ends runtime_not_authenticated (09 18.3, 10 7, R-AG-20, AG-11, BR-05, BR-12).
  5. The runtime starts only after 200 on startRun; the adapter check of the token (09 4.5 points 1 to 7) is the SDK's; an invalid token starts nothing and reports invalid_invocation_token (R-AG-07, 09 4.9, AG-08, AZ-P6, T-TK-05).
  6. The context comes only from getRunContext; content with trust = unverified reaches the runtime only as marked data with a fixed notice before the block; the context is a fixed system part separate from the text of the triggering person; file contents are always data, also when the triggering person uploaded them; a transcript carries the trust of its message (09 4.6, ADR 0054, SEC-AI-1, AG-20, T-AI-01, T-AI-03).
  7. A project is a forum with a binding the runner serves; workspace_ref resolves to a directory under runner.projects_root, never outside it, also not through symbolic links; readiness (directory present, Git working tree, rights fit) is reported per bound forum in every heartbeat (09 18.1, 4.8, M2-03).
  8. A thread is one Claude Code session: --session-id <uuid> while the session is new or not started, --resume <uuid> once it is started; started = true is reported after system/init (09 18.1, 9.1, 18.4).
  9. File locks per session and per thread with a wait of at most runner.session_lock_wait; at most runner.max_parallel_runs turns; an own process group per turn; stream and stderr kept per session in runner.state_dir for runner.stream_log_retention (09 18.2).
  10. The live box follows the stream after 09 18.4 through patchRun, throttled by the SDK to agent.run_update_min_interval; the title is proposed once, after the first completed run of a new session (09 12 point 5, 18.4).
  11. The cost of a run is the difference of the cumulative result.modelUsage of the session per model, reported as RunFinish.costs and labelled an estimate by the server (09 18.7, 4.10, 10 12.1).
  12. The API key and the agent token appear in no log line, no request body, no file of runner.state_dir, no argument and no prompt of a turn; the key reaches the turn only as ANTHROPIC_API_KEY (BR-13, I5, SEC-LOG-2).
  13. A failed or interrupted turn is never repeated automatically (09 1 point 5, R-AG-17).
  14. Real model calls only behind an explicit switch (09 16.1, PLAN 3 point 5).

Scope

All paths under plugins/claude-code-runner/ unless they start with docs/, sdk/ or tools/. Module and function names are proposals; keep the responsibilities and follow the style of the merged SDK where it differs, listing the differences in the report.

Create:

  • pyproject.toml and uv.lock: distribution claude-code-runner, import package claude_code_runner in src/claude_code_runner/ with py.typed, version = "0.0.0", license = "Apache-2.0", classifier Private :: Do Not Upload, console script claude-code-runner = "claude_code_runner.__main__:main", requires-python equal to the lower bound of sdk/python/pyproject.toml and at least the line that has tomllib; runtime dependency bothy-sdk from ../../sdk/python through [tool.uv.sources], no further runtime dependency (standard library for TOML, processes, locks, signals); dev group with the same ruff, mypy, pytest, pytest-cov and async test plugin versions as sdk/python; [tool.ruff] with rules D and convention google (tests excluded), [tool.mypy] strict, [tool.pytest.ini_options] with testpaths = ["tests"], [tool.coverage.report] fail_under equal to quality.coverage.python_min_percent (M2-23).
  • VERSION (0.0.0), CHANGELOG.md (Keep a Changelog, English, like sdk/python/CHANGELOG.md), config.example.toml (every key of Design note 2 with https://chat.example.com and /srv/projects, /var/lib/claude-code-runner as example paths).
  • src/claude_code_runner/: __main__.py (commands run --config <path> and check-config --config <path>), config.py, secrets.py, toolclass.py, commandline.py, environment.py, workspace.py, authcheck.py, prompt.py, attachments.py, locks.py, turn.py, streamreader.py, livebox.py, usage.py, title.py, heartbeat.py, channels.py (cache of served channels and bindings), app.py (the handler of the SDK run loop: acceptance answers, prepare, execute; start and stop of the runner), prompts/runner_prompt.md (fixed runner instructions, English, Design note 10).
  • tests/ (pytest, run by the root make test): unit tests per module, tests/scenarios/ (Fake-Claude plus FakeServer), tests/fake_claude/fake_claude.py with tests/fake_claude/fixtures/*.jsonl, tests/real_cli/ (Design note 17).

Change:

  • docs/spec/09-agents.md (English, like the chapter; Design note 19): 18.1, 18.2, 18.3, 18.4, 18.7 and 21 with the thirteen keys of Design note 2. Then make config-reference regenerates docs/operations/configuration.md.
  • plugins/claude-code-runner/README.md (English, D-AG-22): status "Core (M2-34)", layout, configuration, how to run, what M2-35 and M3-12 add, the answers to "To check before the build" as far as they are known (both still open, see Design note 17). plugins/README.md: row claude-code-runner with the new status.
  • tools/docs/tests/test_config_values.py: the runner's fail_under equals quality.coverage.python_min_percent, unless the M2-23 test already checks every tracked Python project under plugins/.
  • sdk/python only where Design notes 5, 13, 15 and 17 require it (a way for a handler to end a run without a start, a heartbeat hook for members that change at run time, FakeServer routes the scenarios need; and, unless the merged SDK already does it, the run loop also runs prepare inside bind_run(run_id, newest stored token), because SR9 binds a queued run (M2-14 note 3d), with an SDK test that getRunContext and downloadFile called from prepare carry Invocation-Token): each with its test in sdk/python/tests/ and a line in sdk/python/CHANGELOG.md.
  • docs/DECISIONS.md section 10: rows D-M2-34-1 to D-M2-34-12 (Design note 18).

This ticket creates no migration, no server code and no new top-level make target.

Out of scope

  • Follow-ups over stdin, runner.followup_grace, the capability followups, the echo isReplay of a follow-up and its states delivered and absorbed (M2-35). This ticket reports no followups capability, so the server sends no agent.followup.
  • The stop: reacting to run.cancel_requested with SIGINT and closed stdin, the escalation, run.finish with cancelled and the reason, and their scenarios (M2-35). This ticket builds the termination primitive (Design note 11) and makes execute safe against cancellation by the SDK (Design note 14).
  • The error paths of 09 18.9: context full and one compaction, session missing in Claude Code (fail open, bind_failed), session already existing at the first turn, takeover or termination of turns after a runner crash, the table of recognized CLI error texts (M2-35). This ticket ends a turn whose result reports an error as failed with runtime_error (Design note 14) and writes the turn record M2-35 reads.
  • Answer attachments with ATTACH: lines, runner.attach_allowed_roots, runner.attach_denied_patterns (M3-12); dev environments, the claim dev_environment_id and V2-03.
  • Commands, components, interactions, voice transcription (file.transcript.submit), files out; the runner registers none and reports none of these capabilities.
  • The server side of every operation the runner calls (M2-09, M2-10, M2-12, M2-13, M2-14, M2-18, M2-19, M2-20, M2-22, M3-05); the conformance suite with the Fake-Agent (M2-31); the Hermes adapter (M2-33); the TypeScript SDK (M2-30).
  • Telemetry export from the runner (signals.md names no runner signal); the release build of the component (release-build, release chain of M7); publishing the package (OQ-17).
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-34-claude-code-runner-core; one writer per worktree.
  • Conventional Commits with the component as scope (for example feat(claude-code-runner): ..., docs(spec): ..., test(sdk-python): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md. Never publish a package and never reserve a name (OQ-17).
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0 (P-10): the SDK FakeServer listens there. No PostgreSQL and no container are needed; no server of this product and no third-party service is contacted.
  • Stop only processes you started, by PID or by the process group of a turn the test started. Temporary directories only via mktemp -d (or the test framework's temporary directory), removed at the end.
  • Never read or print secrets. No network calls except package downloads for pinned tools and Python packages, and the package of the pinned CLI version for level one of Design note 17.
  • Never start a claude CLI installed on this machine and never read the developer's Claude Code configuration or login: every test uses Fake-Claude through runner.claude_bin and a fake HOME in a temporary directory. The only real CLI you run is level one of Design note 17, installed into a temporary directory; level two (logins, model calls, money) is the owner's decision.
  • Never lower a threshold, disable a gate or delete a test. Do not extend quality/licenses/allowed.txt.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.
  • M2-29 and M2-35 were briefed in parallel with this ticket; this brief uses the names of the M2-29 brief (bothy_sdk.auth, client, gateway, invocation, inbox, adapter with AgentHandler and Adapter, runs, context, outbox, retry, heartbeat, testing with FakeServer). Where the merged SDK names or splits them differently, follow the code and list each difference; never rebuild in the runner what the SDK provides (token check, JWKS cache, jti memory, inbox, run loop, renewal, patch throttle, outbox, retries, context rendering), and never define types the generated core already has (ADR 0026).

Design notes

  1. Start check. Run make check on the fresh worktree; red gives Verdict: BLOCKED base branch red. Confirm: M2-03 is merged (docs/api/openapi.yaml has ProjectRunnerConfig and ChannelBehavior.project_binding); M2-29 is merged (sdk/python/src/bothy_sdk/ has the hand layer and the FakeServer). Otherwise Verdict: BLOCKED dependency <ticket> missing.

  2. Process model. One Python process per agent token, asyncio throughout (the SDK is asynchronous first, 11 11.3), POSIX only (Linux, macOS): process groups, flock and the signals of 18.6 have no equivalent the spec names for Windows, so run refuses other platforms with a start error. Names avoid the product name (02 6 lists the only places for it; the runner is listed nowhere), so the package is claude_code_runner, the heartbeat names claude-code-runner and claude-code, and the only product-name strings in the component are the SDK's distribution and import names, which M2-23 listed as a kind of 02 6. Ruling for docs/DECISIONS.md.

  3. Configuration. One TOML file given by --config, read with tomllib; keys nested by their dots ([runner], [adapter], [sdk]). Unknown keys, wrong types, missing required keys and invalid durations (form of configuration.md 2: number plus ms, s, m, h, d) abort the start with a message that names key and file, never a value (configuration.md 3). secret_ref values follow 16 5.2 with the schemes file and env; values are read fresh for each use and never cached in an object that can be printed (a wrapper type whose repr and str write [redacted]). New keys of this ticket, rows for 09 21 (Place Runner; meaning in English as given):

    Name Default Meaning
    runner.server_url no default, required base URL of the server; only https, http only for the host 127.0.0.1
    runner.instance_id no default, required instance ID of the server; iss of every invocation token must be equal to it (4.5 item 4), the discovery is compared with it at start
    runner.agent_token_ref no default, required secret_ref of the agent token in the secret provider of the runner
    runner.dpop_key_ref empty secret_ref of the DPoP key; empty means: token without dpop_jkt
    runner.consumer default consumer name at the gateway
    runner.tool_class chat loaded tool class; determines flags (18.2), reported_tool_class and tool_class_used
    runner.scoped_allowed_tools empty named tools of the class scoped (--allowedTools)
    runner.default_model no default, required model when the project binding names none
    runner.anthropic_api_key_ref empty secret_ref of the API key for organization; empty means: organization cannot be proven
    runner.allow_personal false runs in mode personal with the login of the runner user allowed
    runner.forward_ssh_agent false pass SSH_AUTH_SOCK on to the turn
    runner.cli_probe_timeout 30s time limit for claude auth status and claude --version
    runner.poll_interval 200ms interval at which the runner checks the recording for new lines and a held lock again

    The existing keys of 09 21 with Place Runner, adapter.invocation_token_leeway, adapter.jwks_refresh_min_interval, agent.run_update_min_interval (the adapter's own throttle) and the sdk.* keys are read from the same file and passed to the SDK. Checks at load: runner.projects_root and runner.state_dir absolute and existing; runner.state_dir created with mode 0700 when missing and refused when group or others have any permission; runner.scoped_allowed_tools entries and runner.default_model not empty, not starting with -, without control or bidi characters (they become process arguments, M2-03 note 4); adapter.invocation_token_leeway at most the server's auth.clock_leeway cannot be checked by the runner and is stated in the README. check-config runs these checks, confirms that every set secret_ref resolves to a non-empty value without printing it, probes claude --version (note 15) and prints one code per check; it contacts no server. Ruling for docs/DECISIONS.md: the thirteen keys and TOML.

  4. Start. Read the agent token, build the SDK client for runner.server_url (DPoP key if set); read getMe and require a principal of kind agent, whose id is the expected aud; build the SDK's adapter configuration with runner.instance_id as the expected iss (the SDK compares it with getDiscovery and stops on a difference, M2-29: 09 4.5 point 4 says "the configured instance"), the agent id, runner.consumer, runner.state_dir and the names and versions of note 15; probe claude --version (Design note 15); load all served channels with listServedChannels into the channel cache; compute readiness (Design note 6); connect the SDK gateway with runner.consumer; send the first heartbeat after the connection is ready. Existing turn records in runner.state_dir/turns/ are logged with the code orphan_turn_found and left for M2-35; their locks are held by their processes (Design note 11), so no second turn of that session starts.

  5. Acceptance of agent.invocation (09 4.3, 4.5, 4.9). The runner is a handler of the SDK's run loop (expected bothy_sdk.adapter: acceptance into the inbox, ack, a prepare step before the start, renewal and startRun, an execute step after 200); it never rebuilds that loop. Acceptance, first failure wins; a refusal is finishRun with state = failed and the reason, then ack (09 4.3: a run refused with run.finish counts as accepted):

    a. The SDK verifier, points 1 to 7 of 09 4.5 with adapter.invocation_token_leeway and adapter.jwks_refresh_min_interval: invalid gives invalid_invocation_token; a missing kid after the reload is neither acknowledged nor answered (4.5); an expired but otherwise valid token is accepted (renewal before the start). A repeated jti is ignored (point 7). The optional allow list of point 9 stays off: I1 and I2 are decided by the server and carried by the token. b. Point 10: the actual tool class the runner gives the SDK is runner.tool_class; above the claim tool_class the SDK refuses with tool_class_mismatch. Nothing else runs, Fake-Claude is never called (AG-30). c. Point 8: the desired mode is the claim billing_mode (never the payload member). The actual mode the runner gives the SDK at acceptance is the desired one when it may run it (organization always, because the key is read and proven right before the start; personal only with runner.allow_personal), else unknown, which the SDK refuses with billing_mode_mismatch (the runner never uses a subscription it was not allowed to, RK-13). The binding evidence comes in prepare (note 8), right before the start, because it must describe the environment the turn will use (10 7: "binding for the start"); when the SDK asks again at the start, the runner answers the mode of that evidence, or unknown. d. Otherwise the SDK stores event and token in its durable inbox in runner.state_dir (expected SqliteInboxStore, mode 0600) and acknowledges; a redelivery replaces the stored token (09 4.3).

    Ruling for docs/DECISIONS.md: b and c, and no allow list.

  6. Prepare, start and execute (09 4.9, 18.11). Prepare runs, in this order:

    a. trigger_kind other than message gives adapter_error: the runner registers no commands and builds no components. b. The channel must be a forum the runner serves with a binding in the channel cache; when the payload's settings_version is newer than the cached one, reload it first with getServedChannelConfig. No binding gives adapter_error with the log code project_not_bound. c. While degraded with runtime_unreachable or runtime_version_unsupported (note 15): runtime_error. d. Resolve the project (note 6); failure gives adapter_error. e. Take the thread lock, then the session lock (note 11), waiting at most runner.session_lock_wait; a timeout gives adapter_error with the log code session_busy. Then a slot of runner.max_parallel_runs. f. Load the context (getRunContext; a 404, or 403 invocation_token_invalid with reason run_inactive because prepare runs inside the run binding (note 13, M2-14 note 4c), means the run is no longer active: end without a start and without finishRun). g. Download the attachments of the triggering message (note 10). h. Build the run environment (note 7) and run the pre-check (note 8); a non-start outcome ends the run with its reason. i. Return the start options: model and external_session_ref (note 9) and billing_evidence (note 8).

    The SDK then renews an expired token (4.5.1), calls startRun with billing_mode_used, tool_class_used = runner.tool_class and the start options, and handles every answer as the reason table of 09 4.5 says (expired renews once and starts once more; superseded keeps the entry; invalid finishes invalid_invocation_token; 409 already_started, 409 billing_mode_mismatch, 409 tool_class_mismatch and 403 agent_invocation_denied start nothing). Only after 200 does execute run: write the turn record and start the turn (notes 9 to 14). After a restart the SDK closes entries of started runs without a call, so such a run ends interrupted (4.9). An end without a start in prepare (a to h) must finish the run with exactly the reason named there; if the merged SDK has no way for a handler to end a run with a given reason without a start (M2-29 maps only exceptions of execute to runtime_error and adapter_error), add one to its run loop, for example an exception RunRefused(reason) that prepare raises and the loop answers with finishRun failed and that reason and a closed entry, with a test and a line in sdk/python/CHANGELOG.md, instead of calling finishRun beside the SDK. In execute the runner ends every run itself with the run handle's finish (state, reason, metrics, costs). Every path releases slot and locks (one per-run object opened in prepare and closed when execute ends or the SDK reports that the run did not start). Ruling for docs/DECISIONS.md: this order.

  7. Workspace and readiness (09 18.1). resolve(workspace_ref): refuse a value that is empty, absolute, starts with ~ or -, contains \, a drive letter with colon or a .. segment (defence in depth after M2-03 note 4); join with the real path of runner.projects_root, take os.path.realpath, and require a strict descendant of the root (os.path.commonpath). The resolved real path is used as working directory, never the joined one. Ready means: resolved, a directory, .git exists in it (directory or file, so linked worktrees count), and the runner user has read, write and search rights (os.access). Not ready carries a log code: outside_root, workspace_missing, not_a_git_worktree, not_writable. Readiness is computed at start, after every channel cache change and before every heartbeat. Ruling for docs/DECISIONS.md.

  8. Run environment (09 18.2, 18.3). Built from scratch, never by removing from os.environ:

    • HOME, USER, LOGNAME, SHELL, LANG, TERM, TMPDIR and every LC_* from the runner's environment when present; HOME missing aborts the start.
    • PATH cleaned: entries kept in order only when absolute, existing directories, not writable by group or others, without duplicates; empty entries and . dropped. runner.claude_bin is looked up in this PATH and its absolute path is used. Reason: a writable directory on the path lets another account plant a program.
    • SSH_AUTH_SOCK only with runner.forward_ssh_agent (T-DEV-01: an agent socket gives the turn the runner user's keys).
    • organization: ANTHROPIC_API_KEY read from runner.anthropic_api_key_ref for this run (missing or empty gives runtime_not_authenticated without any CLI call, BR-12) and CLAUDE_CONFIG_DIR = <runner.state_dir>/claude-config/organization, created empty with mode 0700 and never written by the runner. personal: neither variable, and HOME stays the runner user's own, whose login is the person's (10 10.2); the README tells the operator to enable runner.allow_personal only when the runner runs as the account of the agent's credential_holder, because I1 and I2 then guarantee that only this person triggers (21 1). The variables ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_PROFILE, CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX and CLAUDE_CODE_USE_FOUNDRY are never set, because they are not on the list.

    Consequence to document in the README: sessions of organization live in the runner's own configuration directory and sessions of personal under HOME, so after a switch of the mode (I4) --resume does not find the earlier session; M2-35 handles a missing session (fail open). Ruling for docs/DECISIONS.md.

  9. Pre-check and evidence (09 18.3, 10 7). Run <claude_bin> auth status with the run environment, working directory the project, stdin closed, time limit runner.cli_probe_timeout. Parse stdout as one JSON object and read authMethod; anything else (no JSON, member missing, unknown value, start failure, time limit) is unknown. The exit code is ignored, because none may come with a non-zero exit. Outcome, first match wins:

    Desired authMethod Outcome
    organization api_key start
    personal claude.ai or oauth_token start
    any none runtime_not_authenticated
    any every other value, and unknown billing_mode_mismatch

    billing_evidence.key_type follows authMethod alone: api_key and api_key_helper give api_key, claude.ai and oauth_token give oauth, third_party gives cloud, none gives none, everything else unknown (09 4.8, 10 7: an unknown mode counts as different from every desired mode). billing_evidence in startRun is {provider: anthropic, key_type, method: claude_auth_status}; never a value or prefix. A non-start ends the run with finishRun failed and the reason, without startRun and without a turn (AG-11, BR-05). Ruling for docs/DECISIONS.md: the table, including that none is runtime_not_authenticated (09 5.2) and that the CLI is never called for organization without a key.

  10. Command line (09 18.2), an exact argv list, built by a pure function:

    <claude_bin> -p --input-format stream-json --output-format stream-json --verbose
      --replay-user-messages --model <model> (--session-id <uuid> | --resume <uuid>)
      <class flags> --permission-prompts none [--add-dir <thread inbox>]
      --append-system-prompt-file <system prompt file>
    
    • Class flags are exactly the cell of the class in the table of 18.2, split like a shell would: chat gives --permission-mode dontAsk --tools with an empty argument and --disallowedTools mcp__*; scoped gives --permission-mode dontAsk --allowedTools followed by one argument per tool (variadic, no joining), and --allowedTools is omitted when the list is empty; host gives --permission-mode bypassPermissions. A pure function class_flags(tool_class, tools) returns them, so the test of the table compares it directly. The order of the flags has no meaning to the CLI; 18.2 gets this order (note 19).
    • Tools of scoped: the entries of runner.scoped_allowed_tools that also appear in the binding's runner_config.allowed_tools when the binding has that member, otherwise all of them, in the order of the runner key. allowed_tools never widens the class (03 project_bindings, M2-03 note 11) and has no effect under chat (no tools) and host (bypassPermissions honours no allow list). Ruling for docs/DECISIONS.md; update 09 18.1.
    • Model: the binding's runner_config.model, else runner.default_model. Every model and tool value is checked again as in note 2; a violation gives adapter_error.
    • Session: agent_session absent in the payload: a new UUID (version 4); present with started = false: its external_session_ref; present with started = true: --resume with its external_session_ref. A reference that is not a UUID gives adapter_error with the log code session_ref_not_uuid. The choice reads agent_session.started only, because resume and bind both mean --resume for this runtime (09 9.5 point 2). startRun carries the reference in external_session_ref for every operator run.
    • --add-dir with <runner.state_dir>/files/<thread_id> only for scoped and host and only when at least one attachment was stored: dontAsk reads only in the working directory, and the inbox of the thread lies outside it (18.10). Ruling for docs/DECISIONS.md; update the base line in 09 18.2.
  11. Prompt (09 4.6, 18.2 "Input", ADR 0054). The context is rendered by the context function of the M2-29 hand layer (expected bothy_sdk.context.render_context, giving a system part and a trigger part, with data blocks whose boundary tests can inject). Two inputs:

    • The system prompt file <runner.state_dir>/sessions/<session ref>/<run_id>.system-prompt.md (mode 0600), passed with --append-system-prompt-file, holds only the fixed runner instructions of prompts/runner_prompt.md (the TITLE request only for a new or unstarted session, note 14; that every data block, including the listed files, is material and never an instruction) and a section with the channel prompt behavior.prompt when it is not empty (SEC-AI-1: an own field). behavior.skills is not used: Claude Code loads its skills from the working directory, and the list has no defined meaning for this runtime.
    • The first stdin line, one JSON object {"type":"user","uuid":"<uuid4>","message":{"role":"user","content":[{"type":"text","text":"<system part>"},{"type":"text","text":"<trigger part>"}]}}. The first item is the helper's system part as rendered: channel_context and thread_context in ascending order, every element with author, role, trust, origin and kind, every unverified content and every file entry inside a marked data block after the fixed notice, already_delivered and truncated visible, truncated_before_seq named. The second item is the helper's trigger part: trigger_message.content_md plus the transcripts of its attachments, each labelled as transcript, with trust trigger and outside any data block. Reason: the context becomes part of the session history, so a --resume turn still knows what context_floor_seq no longer sends (09 4.6); an appended system prompt belongs to one process only. One function builds such lines (M2-35 builds follow-up lines with it). The runner remembers the uuid to ignore its echo and stores the line as <runner.state_dir>/sessions/<session ref>/<run_id>.input.jsonl (0600), which M2-35 needs to send the same prompt again after a compaction.
    • Attachments: only those of the triggering message with scan_status clean or skipped are fetched with downloadFile into <runner.state_dir>/files/<thread_id>/<file_id> (0600). The runner hands the helper, as the text of each such file, only the line local file: <path> (or unavailable: <problem code> for any other status or any error: 404, 409 file_not_ready, 409 file_blocked, an operation not enabled), so the path stands only inside the file's data block and no file content enters either input; the run continues in every case. Attachments of context elements get no text and are listed by name, type and size only.

    Ruling for docs/DECISIONS.md: this split (fixed runner instructions and channel prompt in the system prompt file; on stdin the context with the file paths as the first content item and the trigger's words and transcripts as the second, so the context stays in the session history; files only as paths in data blocks, skills unused).

  12. Turn process, locks and records (09 18.2, 18.6). Start with subprocess.Popen(argv, cwd=<resolved project>, env=<note 7>, stdin=PIPE, stdout=<stream file>, stderr=<stderr file>, start_new_session=True, pass_fds=<lock fds>, close_fds=True); never a shell. Stream and stderr go to <runner.state_dir>/sessions/<session ref>/<run_id>.stream.jsonl and .stderr.log (0600), not to pipes, so a turn survives a restart of the runner (18.2 "Detachment") and the files are the transcript. Locks are fcntl.flock on <runner.state_dir>/locks/thread-<thread_id>.lock and session-<ref>.lock, taken in this order with non-blocking attempts every runner.poll_interval until runner.session_lock_wait; their descriptors are inherited by the turn, so a lock lives as long as the turn even when the runner dies (no second turn of the session, 18.2 "Locks"). The turn record <runner.state_dir>/turns/<run_id>.json holds run, thread, channel, session reference, pid, process group, start time and the file paths, never a token. The termination primitive terminate(group): SIGINT to the group, wait runner.stop_grace, SIGTERM, wait runner.kill_grace, SIGKILL (18.6); M2-35 reuses it for the stop. The # nosec markers that bandit needs on the subprocess import and the Popen call carry a comment with the reason (argument list, no shell, program from the cleaned PATH). Files older than runner.stream_log_retention under sessions/ and files/ are deleted at start and after every turn. Ruling for docs/DECISIONS.md: files instead of pipes, polling the stream file every runner.poll_interval, locks inherited by the turn, retention of downloaded attachments.

  13. Stream reader and live box (09 18.4). The reader tails the stream file line by line, keeps a partial last line until it is complete, ignores lines that are not JSON objects and messages of unknown type or without the fields below (counted per type in a debug log line without content), and stops after result or when the process has exited and the file is read to its end. Fields read, [derived] from the Agent SDK reference that 18.4 cites; write this table into 18.4:

    Message Fields Live box
    system init session_id, model, apiKeySource replace /model; counter-check (note 13); session report
    assistant message.content[] with text items and tool_use items (id, name, input); message.usage; parent_tool_use_id /last_text_excerpt from the text items of the newest message; add on /steps/<id> with kind tool_call, name, title, status running, parent_step_id of the step of parent_tool_use_id
    user message.content[] with tool_result items (tool_use_id, is_error); isReplay, uuid step completed or failed with finished_at; the echo of the own prompt is ignored
    tool_progress none no change
    system task_started, task_progress, task_updated, task_notification task_id, description, task_type, ambient, status step kind subagent with detail {"type":"local_agent"} when task_type is local_agent, else background_job; ambient tasks get no step; status completed, failed, stopped end the step as completed, failed, cancelled
    system background_tasks_changed tasks[] with task_id, description open background steps outside the set end completed; /metrics/background_jobs is the size of the set
    system status status compacting sets /status_line to compacting, its absence removes it
    system api_retry attempt, max_retries, retry_delay_ms, error /metrics/api_retries and /metrics/last_api_retry
    result subtype, is_error, result, modelUsage (inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens, costUSD, contextWindow per model) end of the turn (note 14)

    Step title: the first non-empty line of the first string member of input in the order command, file_path, path, pattern, url, query, description, prompt, else of the first string member, else empty; no step detail (data economy). Step ids are UUIDv7 (09 4.7), one per tool_use and task, from one step-id helper (the SDK's if it has one), which M2-35 reuses. /metrics/context_pct after an assistant message with usage: input plus cache tokens divided by the contextWindow of the model stored from the last result of the session, omitted while unknown. /metrics/subagents counts subagent steps. A leading TITLE: line is removed from the excerpt when the TITLE request was given. All ops go through the SDK's patch helper (throttle, base_version, rebase after 409 base_version_conflict).

  14. Counter-check and session (09 18.3, 18.4, 9.1). At system/init: apiKeySource must be ANTHROPIC_API_KEY for organization and none for personal; anything else calls terminate, sends no reply and ends the run with finishRun failed billing_mode_mismatch (BR-05: change during the run). When session_id equals the reference of the turn and the payload's agent_session was absent or not started, report once with reportAgentSession (external_session_ref, model, started: true) and If-Match of agent_session.version, or "1" when startRun created the row (M2-12 note 10); on 412 version_conflict whose current has the same reference, retry once with its version; other failures are logged with their code and change nothing. A different session_id is left to M2-35 (no report). Every request of the runner for a run, in prepare and in execute, goes through the SDK client inside the SDK's run binding (expected Client.bind_run; M2-29 wraps execute, and the run loop wraps prepare too, which this ticket adds when the merged SDK does not, Scope), so it carries the run's newest token in the header Invocation-Token (07 SR9, M2-14 notes 1 and 3d); the runner never calls the server beside that client.

  15. End of the turn (09 18.4, 18.7, 12, 4.10).

    • After result the runner closes stdin and waits runner.stop_grace for the exit, then terminate. A turn that exits without result, or a result with is_error or a subtype other than success, ends failed with runtime_error: first one step of kind status with name runtime_error, an empty title, status failed, finished_at and detail {"exit": "<exit code or signal name>"} (no error text and no stderr, because both can echo content; M2-35 adds the recognised texts of 18.9), then finishRun; no reply and no title.
    • Success: the reply is result.result without a leading TITLE: <title> line when the TITLE request was given; an empty reply sends nothing. One createMessage in the thread with client_msg_id from the SDK outbox (stored before sending), content_md and run_id; a 400 validation_failed on it ends the run failed with adapter_error (03 sizes message.max_content_bytes for long answers without splitting).
    • Title: only after a completed first run of a new or unstarted session and only when the line was present and not empty: getThread, and when title_source is auto, setThreadTitle with If-Match; 409 title_manual, 412 version_conflict and any other failure are logged by code and never retried; the run still completes (AG-19).
    • Cost (18.7): per model the difference of inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens and costUSD against the last stored values of the session (zero for a new session); a negative difference of any value means the runtime started counting again, and the current values count as this run's. CostReport{provider: anthropic, model, tokens_in, tokens_out, tokens_cache_read, tokens_cache_write, amount_micros, currency: USD}, cache writes as 5 minutes (10 12.3), amount_micros from the decimal value of costUSD with half-even rounding. Before finishRun the runner writes <runner.state_dir>/sessions/<ref>/usage.json atomically with the new values and a pending record (run_id, the reports); a retried finishRun of the same run (a retry of the SDK, or M2-35's takeover after a restart) sends the stored reports (a repeated final state answers 200, M2-09 note 6), so a delta is reported at most once and never twice. Ruling for docs/DECISIONS.md.
    • finishRun completed with costs and metrics (tokens_in, tokens_out, tokens_cache_read, tokens_cache_write as sums of the run, context_pct, subagents, background_jobs, api_retries). 409 invalid_state_transition means the server ended the run already (for example cancel_timeout): log it, never retry. Then remove the turn record and release the locks.
    • Cancellation safety: if the SDK cancels execute (for example on run.cancel_requested, M2-29), the runner calls terminate on the turn's group and waits for it before the cancellation propagates, then releases the locks; a runtime never outlives the end of its run (09 1 point 4, 8). M2-35 builds the stop path, its reasons and its scenarios on this.
  16. Heartbeat (09 4.8, M2-20 note 3), built with the SDK's heartbeat building block and sent on the SDK's heartbeat path (the member agent of the gateway heartbeat or sendHeartbeat, as M2-29 builds it). The members that change at run time (state, degraded_reasons, runtime_version, project_readiness) come from the runner at every send; if the merged SDK takes heartbeat members only once from its adapter configuration, add a handler hook to its heartbeat path (test, line in sdk/python/CHANGELOG.md). After a channel cache change that altered a readiness, the runner asks the SDK for an immediate heartbeat when it offers one, else the next regular one carries it. Body: state (online, or degraded with degraded_reasons runtime_unreachable when claude --version fails or exceeds runner.cli_probe_timeout, runtime_version_unsupported when its version is below MIN_CLAUDE_CODE_VERSION or unreadable, runtime_version_untested when it differs from PINNED_CLAUDE_CODE_VERSION); runtime_version; no reported_billing_mode (the runner follows the desired mode per run, D-L8); billing_modes_supported with organization when runner.anthropic_api_key_ref is set and personal when runner.allow_personal is true, omitted when both are off; no billing_evidence in the heartbeat; reported_tool_class = runner.tool_class; details with capabilities {title: true, components: false, voice_input: false, files_out: false} (no followups until M2-35), active_runs (runs with a live turn), degraded_reasons, adapter {name: claude-code-runner, version}, runtime {name: claude-code}, protocol_version from the SDK; project_readiness with {channel_id, ready} for every bound forum in the cache. claude --version is probed at start and before each heartbeat. While degraded with runtime_unreachable or runtime_version_unsupported, prepare ends a run with runtime_error (note 5c: no lock, no CLI call besides the probe); runtime_version_untested only warns the owner, and turns run, because the pre-check and the counter-check stay the binding evidence. MIN_CLAUDE_CODE_VERSION is the version 09 18.2 names for --permission-prompts none; PINNED_CLAUDE_CODE_VERSION is the version the fixtures and the field table of note 12 were written for (RK-14: "Pin the CLI version"), set to the same value in this ticket because 09 names no other; the README tells the operator to install exactly the pinned version and to keep automatic updates of the CLI off. A change of the pin is a change with refreshed fixtures and a run of the real-CLI suite. Ruling for docs/DECISIONS.md.

  17. Secrets and logs (BR-13, SEC-LOG-2, I5). Logs are JSON lines on stderr with event code, run, thread and channel ids; never message content, prompt text, tool input, titles, file names, tokens, keys or the values of secret_ref. The API key exists only in the environment passed to auth status and to the turn. Every file under runner.state_dir is created with mode 0600 (directories 0700); the inbox stores invocation tokens by design (4.3) and nothing else secret. agent.config_changed reloads the forum's configuration (getServedChannelConfig; 404 removes it from the cache) and never logs its content. run.cancel_requested is handled by the SDK's run loop as M2-29 builds it; the runner's part here is the cancellation safety of note 14, and M2-35 owns the stop. agent.followup, interaction.created and command.invoked reach the handler's other entry points, which acknowledge and log them by type only (the runner reports no capability that leads to them).

  18. Fake-Claude, FakeServer and the real CLI.

    • Fake-Claude (tests/fake_claude/fake_claude.py, standard library only) runs through a wrapper that a test helper writes into a temporary directory with the test interpreter as its absolute shebang and the scenario and capture paths baked in (the allow-list environment would drop a control variable). --version prints the configured version. auth status prints {"loggedIn": ..., "authMethod": ...}: api_key when ANTHROPIC_API_KEY is set; otherwise the content of <CLAUDE_CONFIG_DIR>/fake-login when CLAUDE_CONFIG_DIR is set, else of <HOME>/.claude/fake-login, else none; a scenario can force raw output and exit code. -p records argv, working directory, environment, the content of the file after --append-system-prompt-file and every stdin line into the capture file, emits system/init (session_id from the argv, model, apiKeySource from the environment unless forced), echoes the first stdin line with isReplay: true, plays the scenario lines (placeholders for session and model), emits result unless told not to, and exits after stdin EOF. SIGINT ends it with a result of subtype error_during_execution unless the scenario ignores it; it can ignore SIGTERM too. Fixtures are written from the message shapes of note 12; the first line of each is {"type":"fixture_meta","origin":"constructed","cli_version":"<PINNED_CLAUDE_CODE_VERSION>","source":"<URL cited in 09 18.4>"}, which Fake-Claude skips and which M2-35 and later recordings fill with recorded. They are not recordings (no real turn may run here).
    • FakeServer: bothy_sdk.testing in process on 127.0.0.1 port 0, signing invocation tokens with its own key. If it lacks a route the scenarios need (listServedChannels, getServedChannelConfig, downloadFile, getThread, setThreadTitle, reportAgentSession, sendHeartbeat), add it there with a test that validates its answers against docs/api/openapi.yaml, because the FakeServer is the shared adapter test double (09 16.1) and M2-33 needs the same.
    • Real CLI (09 16.1, 18.9 last paragraph, RK-14): tests/real_cli/ is not collected unless CLAUDE_CODE_RUNNER_REAL_CLI=1 (collect_ignore_glob in tests/conftest.py); without it pytest tests/real_cli collects nothing, with it a missing CLI fails and never skips. Level one, free and without any credential: the version output of runner.claude_bin equals PINNED_CLAUDE_CODE_VERSION and is at least MIN_CLAUDE_CODE_VERSION, and --help lists every flag of note 9. Level two, only with CLAUDE_CODE_RUNNER_REAL_TURN=1 in addition, because it touches the operator's key or login and costs money: auth status in the run environment of each mode reports authMethod as the table of note 8 expects, and one short real turn and one --resume turn produce streams with every message type of note 12 and a modelUsage that grows across --resume (the claim of 18.7). Nothing in make check depends on either. Run level one once with the pinned CLI installed into a temporary directory (a package download of the pinned version only, a temporary HOME, no login, no key) and report the result; if the installation needs more than a package download, do not run it and say so. Never run level two. Ruling for docs/DECISIONS.md. Report item 4 names the level-two command, its cost (two short turns on the operator's key or login), what it proves (auth status shape, stream shapes, cumulative usage, the open checks of the README "To check before the build") and what can go wrong (real cost, use of the operator's login), as the owner's decision.
  19. Rulings for docs/DECISIONS.md (one row each with date, decision, reason and place): D-M2-34-1 Python, POSIX, package names without the product name, no mkdocstrings page (notes 1 and the note on rule 3); D-M2-34-2 TOML and the thirteen runner keys (note 2); D-M2-34-3 actual class and mode at acceptance, no allow list (note 4); D-M2-34-4 prepare order and the ends without a start (note 5); D-M2-34-5 workspace resolution and readiness codes (note 6); D-M2-34-6 environment, cleaned PATH, SSH agent off, own configuration directory for organization (note 7); D-M2-34-7 the pre-check table (note 8); D-M2-34-8 command line, allowed_tools only for scoped, --add-dir, model and session choice (note 9); D-M2-34-9 prompt split (system prompt file with the fixed instructions and the channel prompt, context and trigger part as two content items of the stdin line), attachments as paths in data blocks, skills unused (note 10); D-M2-34-10 files instead of pipes, polling, inherited locks, retention (note 11); D-M2-34-11 stream field table, step titles, reply, title and cost delta rules (notes 12 and 14); D-M2-34-12 the version pin, the real-CLI suite in two levels and the FakeServer routes (notes 15 and 17).

  20. Specification text (English, 09; spec first): 18.1 readiness and the allowed_tools rule of note 9; 18.2 the base line in the order of note 9 with [--add-dir <incoming folder of the thread>], the cleaned PATH, SSH_AUTH_SOCK only with runner.forward_ssh_agent, model and session choice, stream and stderr in files, inherited locks, the stdin line with the context and the trigger part as two content items and the system prompt file with the fixed instructions and the channel prompt of note 10 (the row "Input" names the context with the attachment paths in the prompt), the version pin and the degraded reasons runtime_version_unsupported and runtime_version_untested of note 15; 18.3 the table of note 8 and the rule for organization without a key; 18.4 the field table of note 12 marked [derived] and the title rule; 18.7 the delta rules of note 14; 21 the thirteen rows of note 2. Keep every [verified] and its source as it is.

Tests (write them first)

Test names carry the specification ID where one exists, so pytest -k AG_20 finds them. Configuration values in tests are set through a test configuration file; production code never hard-codes a window.

Unit (tests/unit/):

  • test_commandline.py: test_argv_chat_matches_spec_table, test_argv_scoped_matches_spec_table (with and without allowed_tools, intersection order, empty result omits the flag), test_argv_host_matches_spec_table; test_class_flags_equal_09_18_2_table reads the table "Class | Flags | Effect" from docs/spec/09-agents.md, splits each cell with shlex and compares with the builder (placeholder of scoped replaced by the configured tools); test_session_flag (absent gives a new UUID with --session-id, unstarted reuses the reference, started gives --resume, a non-UUID reference fails); test_add_dir_only_scoped_and_host_with_files; test_model_precedence; test_values_starting_with_dash_rejected.
  • test_toolclass.py: order and exceeds for all nine pairs.
  • test_environment.py: exact key set per mode from a parent environment that also holds AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, the six forbidden variables of note 7 and an unrelated BOTHY_ variable (none passes); LC_ALL and LC_CTYPE pass; SSH_AUTH_SOCK only with the switch; organization has ANTHROPIC_API_KEY and the own CLAUDE_CONFIG_DIR, personal has neither; test_clean_path drops empty, ., relative, missing and group or world writable entries and keeps order.
  • test_authcheck.py: every row of the table of note 8 and its key_type; non-JSON, missing member, unknown value, time limit and start failure give unknown; none with exit code 1 gives runtime_not_authenticated.
  • test_workspace.py: website and team/website resolve; absolute, ~x, -x, a\b, C:x, team/../x, a symbolic link pointing outside the root and the root itself are refused with outside_root; readiness codes workspace_missing, not_a_git_worktree, not_writable; a .git file counts.
  • test_usage.py: first run of a session reports the full values; a resumed run reports the difference per model; a new model appears; a negative difference resets; costUSD to micros with half-even rounding; the pending record is reused for the same run and replaced by the next run.
  • test_title.py, test_streamreader.py (partial lines, invalid lines, unknown types, end after result and after exit), test_livebox.py (one case per row of note 12, nested parent_step_id, ambient task hidden, own prompt echo ignored, TITLE: stripped from the excerpt), test_prompt.py (system file sections without any context element, TITLE request only for new sessions, stdin line shape with the system part as first and the trigger part as second content item, transcript of the trigger outside data blocks, attachment list inside one), test_config.py (required keys, unknown key, bad duration, error text names key and file but no value, state_dir permissions, tool and model values), test_locks.py (exclusive, timeout after runner.session_lock_wait, a child that inherited the descriptor keeps the lock after the parent closed it), test_turn.py (own process group, working directory, stream and stderr in files, terminate sends SIGINT, then SIGTERM after runner.stop_grace, then SIGKILL after runner.kill_grace to a child that ignores both), test_heartbeat.py (body of note 15 for each configuration, no reported_billing_mode, no secret or path), test_versions.py (pyproject.toml version equals VERSION; MIN_CLAUDE_CODE_VERSION equals the version named in 09 18.2; every fixture's fixture_meta.cli_version equals PINNED_CLAUDE_CODE_VERSION).

Scenarios (tests/scenarios/, Fake-Claude plus FakeServer; world: space Engineering with the forums Projekt-A (binding website, a Git working tree under the test's projects root) and Projekt-B (binding docs, directory missing) and the text channel Werkzeuge without binding, all served by the agent Runner; Alex triggers, Robin is an owner with trigger, Sam has no trigger, Lotse is another agent; the FakeServer serves the RunContext of each test as a fixture):

  • test_happy_path_organization: token organization and scoped, runner scoped with two tools, the triggering message with one clean file attachment: startRun body with billing_mode_used = organization, tool_class_used = scoped, a UUID external_session_ref, model, billing_evidence {provider: anthropic, key_type: api_key, method: claude_auth_status}; Fake-Claude argv equals note 9; environment equals note 7; patches for model, a tool_call step running then completed, excerpt; reportAgentSession with started: true and If-Match: "1"; one reply with run_id and a stored client_msg_id; the title from the TITLE: line set once; finishRun completed with costs and metrics; the turn was started only after the 200 of startRun; the FakeServer asserts Invocation-Token with the run's token on getRunContext, downloadFile, getThread, setThreadTitle, reportAgentSession, patchRun and createMessage, and none on listServedChannels, sendHeartbeat and getServedChannelConfig outside a run.
  • test_tool_class_used_is_configured_class: for each configured class with a token class at least as high, startRun carries the configured class and the argv the class flags.
  • test_AG_30_runner_side_tool_class_mismatch: runner host, token scoped: finishRun failed tool_class_mismatch, no startRun, Fake-Claude never called, the event acknowledged; runner chat with token scoped starts with tool_class_used = chat.
  • test_BR_12_organization_without_key_no_fallback: Fake-Claude's fake HOME holds a subscription login; with the key reference empty, pointing to a missing file and pointing to an empty file: finishRun failed runtime_not_authenticated, no startRun, Fake-Claude never called; with a key whose auth status the scenario forces to none: same reason, no -p; with a valid key: the -p environment has the runner's own CLAUDE_CONFIG_DIR and none of the six forbidden variables, even when the runner's own environment sets them.
  • test_AG_11_BR_05_billing_mode_mismatch: organization with auth status forced to claude.ai; auth status forced to non-JSON; personal with runner.allow_personal false (no CLI call): each finishRun failed billing_mode_mismatch, no startRun, no -p; personal allowed with claude.ai: starts with billing_mode_used = personal, no key and no CLAUDE_CONFIG_DIR in the environment.
  • test_BR_05_counter_check_aborts_turn: pre-check api_key, but system/init.apiKeySource forced to none: the process group gets SIGINT, no reply, finishRun failed billing_mode_mismatch; with Fake-Claude ignoring SIGINT and SIGTERM the escalation ends it.
  • test_AZ_P6_runner_never_starts_runtime: missing token, wrong signature, aud of Lotse, expired token whose renewal the FakeServer refuses with 403 agent_invocation_denied, a repeated jti, a deviating billing_mode, a deviating tool_class: Fake-Claude records no -p call in any case; the invalid ones end failed invalid_invocation_token (AG-08 runner side); the repeated jti gives exactly one turn.
  • test_expired_token_renewed_before_start: startRun answers 403 invocation_token_invalid with reason: expired once; the runner renews, starts once more and runs one turn; with reason: superseded it starts nothing and keeps the entry until a redelivery brings a newer token.
  • test_AG_20_trust_marks_in_prompt (AG-20 adapter test, with an injected data-block boundary): the context holds messages of Sam (unverified), Lotse (unverified, role other), an import (unverified), Robin (authorized) and Runner (self), each with a distinct canary text; the triggering message of Alex has a text, a file attachment with canary content and a voice attachment with a transcript. Every unverified canary and every local file path appears only inside data blocks of the first content item of the stdin line, between the helper's markers after its notice, never in the trigger item and never in the system prompt file; the file's canary content appears in no input; its local path appears only inside the file's marked data block and the file lies under files/<thread_id>/; the transcript appears in the trigger item of the stdin line outside any data block, labelled with trust trigger; the channel prompt has its own section in the system prompt file.
  • test_session_new_then_resume_and_cost_difference: first run --session-id U and report; second invocation with agent_session.started = true gives --resume U, no report, no title, and costs equal to the difference of the cumulative fixture values; the context canaries of the first run reach Fake-Claude only in the first run's stdin line, and the second run's input holds only the context elements of its own RunContext (those above context_floor_seq), so nothing is sent twice and the delivery sits in the session history; a FakeServer fault "stored, then 502" on the first finishRun makes the retry send the same reports.
  • test_cancelled_execute_terminates_group: the FakeServer cancels the run while its turn runs: when the SDK's handling of the cancellation ends, no process of the turn's group is left and both locks are free; a Fake-Claude that ignores SIGINT and SIGTERM is ended by the escalation of terminate.
  • test_AG_19_title_manual_keeps_run_completed: setThreadTitle answers 409 title_manual: no retry, finishRun completed.
  • test_error_result_and_exit_without_result: result with is_error and a process exit without result end failed runtime_error with a status step and no reply.
  • test_project_readiness_in_heartbeat: Projekt-A ready, Projekt-B not ready (workspace_missing); after agent.config_changed for Projekt-B and creating its Git working tree, the next heartbeat (sent at once) reports it ready; the body matches note 15.
  • test_unbound_channel_and_other_trigger_kinds_refused: an invocation in Werkzeuge ends failed adapter_error; trigger_kind command ends failed adapter_error; no CLI call.
  • test_session_lock_and_parallel_runs: two runs of one thread run one after the other; a wait over runner.session_lock_wait ends adapter_error; with runner.max_parallel_runs at two, three runs in three threads never have more than two Fake-Claude processes at once.
  • test_inbox_survives_restart: a runner stopped after ack and before startRun leaves the entry; a new runner on the same runner.state_dir starts the run exactly once.
  • test_BR_13_no_secret_values_leak: a canary API key and a canary agent token appear in no FakeServer request body or header other than Authorization, in no captured log record, in no file under runner.state_dir, and in no argv, stdin line or system prompt file of Fake-Claude; the key appears only in the environment of auth status and -p calls.
  • test_state_files_private_and_retention: modes 0700 and 0600; transcripts, prompt files and downloaded attachments older than runner.stream_log_retention are deleted after the next turn.

Real CLI (tests/real_cli/, only with the switches of note 17): level one test_installed_version_equals_pin and test_help_lists_contract_flags; level two test_auth_status_per_mode and test_real_turn_streams_mapped_messages. Run level one once as note 17 says; never run level two.

Commands that must pass, from the repository root:

  • cd plugins/claude-code-runner && uv run --locked pytest
  • make fmt-check, make lint, make typecheck, make test, make coverage, make licenses, make osv
  • make config-reference then make docs, make linkcheck; make api-clients when sdk/python changed
  • make check

Acceptance criteria

  • plugins/claude-code-runner is a Python component that every gate of make check covers (format, ruff with D, bandit, mypy strict, pytest with coverage at quality.coverage.python_min_percent, licenses, OSV, Sonar).
  • The Fake-Claude scenarios of this brief are green in make check; Fake-Claude plays stream-json fixtures, reads stdin and reacts to SIGINT (09 16.1).
  • The command line of each tool class equals the table in 09 "Process contract" (chat, scoped, host), checked against the spec text; tool_class_used in startRun is the configured class.
  • The tests against the installed CLI run only with CLAUDE_CODE_RUNNER_REAL_CLI=1 (logins and the paid turns also need CLAUDE_CODE_RUNNER_REAL_TURN=1); make check collects none of them; level one ran once against the pinned CLI in a temporary directory (or the report says why not), and the report hands level two to the owner.
  • AG-30 (runner side): a configured class above the token's starts nothing and ends failed with tool_class_mismatch.
  • BR-12: organization without a key never falls back to a subscription and ends failed with runtime_not_authenticated; the turn environment is an allow list with the runner's own CLAUDE_CONFIG_DIR.
  • AG-11 and BR-05 (runner side): a pre-check mismatch or an unknown mode starts nothing, a counter-check mismatch interrupts the turn with SIGINT, both end billing_mode_mismatch.
  • AZ-P6 and AG-08 (runner side): no case of an invalid, expired and unrenewable, repeated or mismatching token starts the runtime.
  • AG-20 (adapter test): unverified content only inside marked data blocks, file contents in no input and their paths only as data, transcripts with the trust of their message.
  • Readiness of every bound forum is reported in the heartbeat; workspace_ref never resolves outside runner.projects_root.
  • Sessions, live box, reply, title once per new session and the cost difference per run follow notes 9 to 14; locks and runner.max_parallel_runs hold.
  • BR-13 (runner part): the key and the agent token appear in no log, request body, state file, argument or prompt.
  • 09 18.1 to 18.4, 18.7 and 21 and the configuration reference describe the built behaviour; rows D-M2-34-1 to D-M2-34-12 are in docs/DECISIONS.md.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • READMEs of plugins/claude-code-runner and plugins updated (status, layout, configuration, operation, what M2-35 and M3-12 add, the mode switch consequence of note 7).
  • Rulings of note 18 recorded in docs/DECISIONS.md (one row each with reason); the changes to 09 and the regenerated configuration reference made in the same change.
  • plugins/claude-code-runner/CHANGELOG.md entry under 'Unreleased' (and sdk/python/CHANGELOG.md if the FakeServer changed).
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven.
  5. Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.