Claude Runner
Below is a concrete implementation spec for the Anthropic Claude Code (“claude” CLI / Agent SDK runtime) runner shipped in Untether (v0.3.0).
Below is a concrete implementation spec for the Anthropic Claude Code (“claude” CLI / Agent SDK runtime) runner shipped in Untether (v0.3.0).
Scope
Goal
Provide the claude engine backend so Untether can:
- Run Claude Code non-interactively via the Agent SDK CLI (
claude -p). (Claude Code) - Run Claude Code interactively via permission mode (
--permission-mode plan --permission-prompt-tool stdio) with a bidirectional control channel. - Stream progress in Telegram by parsing
--output-format stream-json --input-format stream-json --verbose(newline-delimited JSON). (Claude Code) - Support resumable sessions via
--resume <session_id>(Untether emits a canonical resume line the user can reply with). (Claude Code)
UX and behavior
Engine selection
- Default:
untether(auto-router usesdefault_enginefrom config) - Override:
untether claude
Untether runs in auto-router mode by default; untether claude or /claude selects
Claude Code for new threads.
Resume UX (canonical line)
Untether appends a single backticked resume line at the end of the message, like:
`claude --resume 8b2d2b30-...`
Rationale:
- Claude Code supports resuming a specific conversation by session ID with
--resume. (Claude Code) - The CLI reference also documents
--resume/-ras the resume mechanism.
Untether should parse either:
claude --resume <id>claude -r <id>(short form from docs)
Note: Claude Code session IDs should be treated as opaque strings. Do not assume UUID format.
Permissions
Untether supports two modes:
Non-interactive (-p mode): Claude Code can require tool approvals but Untether cannot answer interactive prompts. Users must preconfigure permissions via --allowedTools or Claude Code settings. (Claude Code)
Interactive (permission mode): When permission_mode is set (e.g. plan or auto), Untether uses --permission-mode <mode> --permission-prompt-tool stdio to establish a bidirectional control channel over stdin/stdout. Claude Code emits control_request events for tool approvals and plan mode exits; Untether responds with control_response (approve/deny with optional denial_message). This uses a PTY (pty.openpty()) to prevent stdin deadlock.
Key control channel features:
- Session registries (
_SESSION_STDIN,_REQUEST_TO_SESSION) for concurrent session support - Auto-approve for routine tools (Grep, Glob, Read, Bash, etc.)
ExitPlanModerequests shown as Telegram inline buttons (Approve / Deny / Pause & Outline Plan) inplanmodeExitPlanModerequests silently auto-approved inautomode (no buttons shown)- Progressive cooldown on rapid ExitPlanMode retries (30s → 60s → 90s → 120s) — only applies in
planmode
Safety note: -p/--print skips the workspace trust dialog; only use this flag in trusted directories.
Config additions
Untether config lives at ~/.untether/untether.toml.
Add a new optional [claude] section.
Recommended v1 schema:
=== “untether config”
```sh
untether config set default_engine "claude"
untether config set claude.model "claude-sonnet-4-5-20250929"
untether config set claude.allowed_tools '["Bash", "Read", "Edit", "Write"]'
untether config set claude.dangerously_skip_permissions false
untether config set claude.use_api_billing false
```
=== “toml”
```toml
# ~/.untether/untether.toml
default_engine = "claude"
[claude]
model = "claude-sonnet-4-5-20250929" # optional (Claude Code supports model override in settings too)
permission_mode = "auto" # optional: "plan", "auto", or "acceptEdits"
allowed_tools = ["Bash", "Read", "Edit", "Write"] # optional but strongly recommended for automation
dangerously_skip_permissions = false # optional (high risk; prefer sandbox use only)
use_api_billing = false # optional (keep ANTHROPIC_API_KEY for API billing)
```
Notes:
--allowedToolsexists specifically to auto-approve tools in programmatic runs. (Claude Code)- Claude Code tools (Bash/Edit/Write/WebSearch/etc.) and whether permission is required are documented. (Claude Code)
- If
allowed_toolsis omitted, Untether defaults to["Bash", "Read", "Edit", "Write"]. - Untether reads
model,permission_mode,allowed_tools,dangerously_skip_permissions, anduse_api_billingfrom[claude]. permission_mode = "auto"uses--permission-mode planon the CLI but auto-approves ExitPlanMode requests without showing Telegram buttons. Can also be set per chat via/planmode auto.- By default Untether strips
ANTHROPIC_API_KEYfrom the subprocess environment so Claude Code uses subscription billing. Setuse_api_billing = trueto keep the key.
Environment variables
The Claude runner modifies the subprocess environment before spawning claude:
| Variable | Behaviour |
|---|---|
UNTETHER_SESSION | Set to 1. Signals to Claude Code plugins (hooks, rules, agents) that the session is running via Untether/Telegram. Plugins can check [ -n "${UNTETHER_SESSION:-}" ] in shell hooks to adjust behaviour — e.g. skip blocking Stop hooks that would displace the user’s requested content in Telegram’s single-message output. See PitchDocs for a reference implementation. |
ANTHROPIC_API_KEY | Stripped from the environment by default so Claude Code uses subscription billing. Set use_api_billing = true in [claude] config to keep the key and use API billing instead. |
This is not a security mechanism — UNTETHER_SESSION is a simple presence flag. It carries no credentials and poses no risk if set outside Untether. See the environment variables reference for all variables.
Code changes (by file)
1) New file: src/untether/runners/claude.py
Backend export
Expose a module-level BACKEND = EngineBackend(...) (from untether.backends).
Untether auto-discovers runners by importing untether.runners.* and looking for
BACKEND.
BACKEND should provide:
-
Engine id:
"claude" -
install_cmd:-
Install command for
claude(used by onboarding when missing on PATH). -
Error message should include official install options and “run
claudeonce to authenticate”.- Install methods include install scripts, Homebrew, and npm. (Claude Code)
- Agent SDK / CLI can use Claude Code authentication from running
claude, or API key auth. (Claude Code)
-
-
build_runner()should parse[claude]config and instantiateClaudeRunner.
Runner implementation
Implement a new Runner:
Public API
engine: EngineId = "claude"format_resume(token) -> str: returns`claude --resume {token}`extract_resume(text) -> ResumeToken | None: parse last match of--resume/-ris_resume_line(line) -> bool: matches the above patternsrun(prompt, resume)async generator ofUntetherEvent
Subprocess invocation
Core invocation (non-interactive):
claude -p --output-format stream-json --input-format stream-json --verbose(Claude Code)--verboseoverrides config and is required for full stream-json output.--input-format stream-jsonenables JSON input on stdin.
Core invocation (permission mode):
claude --output-format stream-json --input-format stream-json --verbose --permission-mode <mode> --permission-prompt-tool stdio- No
-pflag — prompt is sent via stdin as a JSON user message. --permission-prompt-tool stdioenables the bidirectional control channel.
- No
Resume:
- add
--resume <session_id>if resuming. (Claude Code)
Model:
- add
--model <name>if configured. (Claude Code)
Effort (reasoning depth):
- add
--effort <level>if a reasoning override is set (low/medium/high).
Permissions:
- add
--allowedTools "<rules>"if configured. (Claude Code) - add
--dangerously-skip-permissionsonly if explicitly enabled (high risk; document clearly).
Prompt passing:
- Pass the prompt as the final positional argument after
--(CLI expectspromptas an argument). This also protects prompts that begin with-. (Claude Code)
Other flags:
- Claude Code exposes more CLI flags, but Untether does not surface them in config.
Stream parsing
In stream-json mode, Claude Code emits newline-delimited JSON objects. (Claude Code)
Per the official Agent SDK TypeScript reference, message types include:
-
systemwithsubtype: 'init'and fields likesession_id,cwd,tools,model,permissionMode,output_style. (Claude Code) -
assistant/usermessages with Anthropic SDK message objects. (Claude Code) -
final
resultmessage with:subtype: 'success'or'error',is_error,result(string on success),usage,total_cost_usd,duration_ms,duration_api_ms,num_turns,structured_output(optional). (Claude Code)
Note: upstream Claude Code CLI may also emit
error,permission_denials, andmodelUsagefields, but these are not captured by Untether’sStreamResultMessageschema (msgspec silently ignores unknown fields).
Untether should:
- Parse each line as JSON; on decode error emit a warning ActionEvent (like CodexRunner does) and continue.
- Prefer stdout for JSON; log stderr separately (do not merge).
- Treat unknown top-level fields (e.g.,
parent_tool_use_id) as optional metadata and ignore them unless needed.
Mapping to Untether events
StartedEvent
-
Emit upon first
system/initmessage:resume = ResumeToken(engine="claude", value=session_id)(treatsession_idas opaque; do not validate as UUID)title = model(or user-specified config title; default"claude")metashould includecwd,model,tools,permissionMode,output_stylefor debugging.modelandpermissionModeare used for the🏷footer line on final messages.
Action events (progress) The core useful progress comes from tool usage.
Claude Code tools list is documented (Bash/Edit/Write/WebSearch/WebFetch/TodoWrite/Task/etc.). (Claude Code)
Strategy:
-
When you see an assistant message with a content block
type: "tool_use":-
Emit
ActionEvent(phase="started")with:-
action.id = tool_use.id -
action.kindbased on tool name (complete mapping):Bash→commandEdit/Write/NotebookEdit→file_change(best-effort path extraction)Read→toolGlob/Grep→toolWebSearch/WebFetch→web_searchTodoWrite/TodoRead→noteAskUserQuestion→noteTask/Agent→toolKillShell→command- otherwise →
tool
-
action.title:- Bash: use
input.commandif present - Read/Write/Edit/NotebookEdit: use file path (best-effort; field may be
file_pathorpath) - Glob/Grep: use pattern
- WebSearch: use query
- WebFetch: use URL
- TodoWrite/TodoRead: short summary (e.g., “update todos”)
- AskUserQuestion: short summary (e.g., “ask user”)
- otherwise: tool name
- Bash: use
-
detailincludes a compacted copy of input (or a safe summary).
-
-
-
When you see a user message with a content block
type: "tool_result":- Emit
ActionEvent(phase="completed")fortool_use_id ok = not is_errorcontentmay be a string or an array of content blocks; normalize to a string for summariesdetailincludes a small summary (char count / first line / “(truncated)”)
- Emit
This mirrors CodexRunner’s “started → completed” item tracking and renders well in existing UntetherProgressRenderer.
CompletedEvent
-
Emit on
resultmessage:-
ok = (is_error == false)(treatis_erroras authoritative;subtypeis informational) -
answer = resulton success; on error, a concise message usingerrorsand/or denials -
usageattach:total_cost_usd,usage,modelUsage,duration_ms,duration_api_ms,num_turns(Claude Code)
-
Always include
resume(same session_id).
-
-
Emit exactly one completed event per run. After emitting it, ignore any trailing JSON lines (do not emit a second completion).
-
We do not use an idle-timeout completion; completion is driven by Claude Code’s
resultevent or process exit handling.
Permission denials
Because result includes permission_denials, optionally emit warning ActionEvent(s) before CompletedEvent (CompletedEvent must be final):
- kind:
warning - title: “permission denied: <tool_name>” This preserves the “warnings before started/completed” ordering principle Untether already tests for CodexRunner.
Session serialization / locks
Must match Untether runner contract:
-
Lock key:
claude:<session_id>(string) in aWeakValueDictionaryofanyio.Lock. -
When resuming:
- acquire lock before spawning subprocess.
-
When starting a new session:
-
you don’t know session_id until
system/init, so:- spawn process,
- wait until the first
system/init, - acquire lock for that session id before yielding StartedEvent,
- then continue yielding.
-
This mirrors CodexRunner’s correct behavior and ensures “new run + resume run” serialize once the session is known.
Assumption: Claude Code emits a single system/init per run. If multiple init
events arrive, ignore the subsequent ones (do not attempt to re-lock).
Cancellation / termination
Reuse the existing subprocess lifecycle pattern (like CodexRunner.manage_subprocess):
- Kill the process group on cancellation
- Drain stderr concurrently (log-only)
- Ensure locks release in
finally
Documentation updates
README
Add a “Claude Code engine” section that covers:
-
Installation (install script / brew / npm). (Claude Code)
-
Authentication:
- run
claudeonce and follow prompts, or use API key auth (Agent SDK docs mentionANTHROPIC_API_KEY). (Claude Code)
- run
-
Non-interactive permission caveat + how to configure:
- settings allow/deny rules,
- or
--allowedTools/[claude].allowed_tools. (Claude Code)
-
Resume format:
`claude --resume <id>`.
docs/developing.md
Extend “Adding a Runner” with:
- “ClaudeRunner parses Agent SDK stream-json output”
- Mention key message types and the init/result messages.
Test plan
Mirror the existing CodexRunner tests patterns.
New tests: tests/test_claude_runner.py
- Contract & locking
-
test_run_serializes_same_session(stubrun_impllike Codex tests) -
test_run_allows_parallel_new_sessions -
test_run_serializes_new_session_after_session_is_known:-
Provide a fake
claudeexecutable in tmp_path that:- prints system/init with session_id,
- then waits on a file gate,
- a second invocation with
--resumewrites a marker file and exits, - assert the resume invocation doesn’t run until gate opens.
-
- Resume parsing
format_resumereturnsclaude --resume <id>extract_resumehandles both--resumeand-r
- Translation / event ordering
-
Fake
claudeoutputs:- system/init
- assistant tool_use (Bash)
- user tool_result
- result success with
result: "ok"
-
Assert Untether yields:
- StartedEvent
- ActionEvent started
- ActionEvent completed
- CompletedEvent(ok=True, answer=“ok”)
- Failure modes
-
resultsubtype error witherrors: [...]:- CompletedEvent(ok=False)
-
permission_denials exist:
- warning ActionEvent(s) emitted before CompletedEvent
- Cancellation
- Stub
claudethat sleeps; ensure cancellation kills it (pattern already used for codex subprocess cancellation tests).
Implementation checklist (v0.3.0)
- Export
BACKEND = EngineBackend(...)fromsrc/untether/runners/claude.py. - Add
src/untether/runners/claude.pyimplementing theRunnerprotocol. - Add tests + stub executable fixtures.
- Update README and developing docs.
- Run full test suite before release.
If you want, I can also propose the exact event-to-action mapping table (tool → kind/title/detail rules) you should start with, based on Claude Code’s documented tool list (Bash/Edit/Write/WebSearch/etc.). (Claude Code)
Interactive enhancements (v0.4.0+)
AskUserQuestion support
When Claude Code calls AskUserQuestion, the control request is intercepted and shown in Telegram. The question text is extracted from the tool input (supports both {"question": "..."} and {"questions": [{"question": "..."}]} formats).
Flow:
- Claude Code emits
control_requestwithtool_name: "AskUserQuestion" - Runner registers in
_PENDING_ASK_REQUESTS[request_id] = question_text - Telegram shows the question with Approve/Deny buttons
- User replies with text →
telegram/loop.pyintercepts viaget_pending_ask_request() answer_ask_question()sendscontrol_response(approved=False, denial_message="The user answered...")— the answer is in the denial message so Claude Code reads it and continues
Diff preview in tool approvals
When a tool requiring approval (Edit/Write/Bash) goes through the control request path, _format_diff_preview() generates a compact preview:
- Edit: shows removed (
-) and added (+) lines (up to 4 each, truncated to 60 chars) - Write: shows first 8 lines of new content
- Bash: shows the command prefixed with
$
The preview is appended to the warning_text in the progress message. Only applies to tools that go through ControlRequest (not auto-approved tools).
Cost tracking and budget
runner_bridge.py calls _check_cost_budget() after each CompletedEvent to compare run cost against configured budgets ([cost_budget] in untether.toml). Budget alerts are shown in the progress footer.
cost_tracker.py provides:
CostBudget— per-run and daily budget thresholds with configurable warning percentageCostAlert— alert levels: info, warning, critical, exceededrecord_run_cost()/get_daily_cost()— daily accumulation with midnight reset
Session export
commands/export.py records session events during runs via record_session_event() and record_session_usage(). Up to 20 sessions are retained. /export outputs markdown; /export json outputs structured JSON.