Claude Code CLI runtime for the owner
Overview
The owner has a Claude subscription and wants their own conversations to run on it, while the family stays on the API key. A subscription cannot sit behind hango’s provider interface: reportedly, Anthropic limits subscription OAuth tokens to Claude Code and Claude.ai, and making the Messages API accept one means disguising requests as Claude Code. This phase does what goclaw’s claude_cli provider does. It runs the real Claude Code CLI (claude -p) as a child process, logged in as the owner on the host, and exposes hango’s tools to it over a small MCP server on loopback.
Only the owner can use it. One configured hango user id may own agents with provider = "claude_cli". It is checked when the agent is saved, when a run is created and again when the run is claimed. Family members never reach the subscription, through the API or through Telegram.
The lesson is what you give up. Claude Code runs its own loop, builds its own prompt and decides its own compaction. hango still owns the session, the run lifecycle, streaming, tool execution, audit and the trace. But it only sees the model’s outputs and the tool calls, not the prompt that was sent. The learning doc compares one task run both ways.
Requirements
Functional
- Configuration:
HANGO_CLAUDE_CLI_ENABLED(default false),HANGO_CLAUDE_CLI_OWNER_USER_ID(required when enabled),HANGO_CLAUDE_CLI_PATH(defaultclaude),HANGO_CLAUDE_CLI_WORKDIR(parent directory for per-session working directories),HANGO_CLAUDE_CLI_BRIDGE_ADDR(default127.0.0.1:0). At startup, hango runsclaude --versionand fails with a clear message if the binary is missing. - Agent validation:
provider = "claude_cli"is accepted only when the feature is enabled, the agent’screated_byis the owner, and the request comes from the owner. Anyone else gets 422provider_not_allowed.modelis passed through to--model(for examplesonnetoropus). - Run creation for a
claude_cliagent by anyone other than the owner returns 404, matching the visibility rule. At claim time, the worker checks again that the run’s creator is the owner and fails the run withruntime_forbiddenotherwise, so a later change to config cannot leave an old queued run on the subscription. claude_cliruns go to a dedicated scheduler laneclaude_cliwith concurrencyHANGO_CLAUDE_CLI_MAX_CONCURRENT(default 1), so a burst of the owner’s messages cannot start several CLI processes against one subscription.- Execution: the worker hands a
claude_clirun to the CLI executor instead ofagent.Run. The executor starts one process per run:claude -p --output-format stream-json --verbose --include-partial-messages --model <model>--session-id <hango session id>on the session’s first CLI run, otherwise--resume <hango session id>. The hango session UUID is the Claude session id, so no mapping table is needed.- No built-in Claude Code tools (
--toolswith an empty set).--strict-mcp-configwith a generated--mcp-configthat points only at hango’s bridge, and--allowedToolsnaming only the bridge’s tools. --setting-sourcesset so the owner’s personal Claude Code settings, hooks and CLAUDE.md files do not leak into hango runs.--append-system-promptwith the agent’ssystem_prompt.- The user input is written to stdin, never put in argv, so it does not appear in
ps. - The working directory is an empty per-session directory under
HANGO_CLAUDE_CLI_WORKDIR. - The environment is the minimum the CLI needs (
HOME,PATH, locale).ANTHROPIC_API_KEYand everyHANGO_*variable are removed, so the CLI cannot silently fall back to API billing or read hango’s secrets. - Verify every flag against
claude --helpfor the installed version. The integration test pins the version it ran against.
- MCP bridge: an HTTP listener on loopback serving
POST /mcpwith a per-run bearer token (32 random bytes, valid only while the run is executing). It implements the subset of MCP that the CLI uses: JSON-RPC 2.0 over Streamable HTTP with JSON responses (initialize,notifications/initialized,ping,tools/list,tools/call).tools/listreturns the agent’s tools resolved the same way the loop resolves them. Everytools/callruns through the phase-7WrapToolCallchain in the run’s tenant scope, so audit, offload, idempotency and telemetry apply unchanged. The subagenttasktool is not exposed in this phase. - Stream mapping:
stream_eventtext deltas become hango text-delta run events. Each completeassistantmessage is persisted as a hango message row. Each tool result the bridge produced is persisted as a tool message row. Theresultevent sets the terminal state,stop_reason,iterations(fromnum_turns) and the run’susage. - Limits:
HANGO_MAX_ITERATIONSis enforced by counting assistant messages, and the process is stopped withstop_reason = max_iterationswhen it is exceeded.HANGO_RUN_DEADLINEapplies as for any run. - Cancel and drain: cancelling sends SIGTERM to the process group, then SIGKILL after 5 s. A requeued run (drain, or reclaim after a crash) resumes the CLI session with a fixed continuation prompt marked
[hango: resumed after interruption]. Bridge tool calls keep their phase-5 idempotency, so a repeated tool call returns the stored result. - A resume that fails because the CLI has no such session (for example, after moving hosts) fails the run with
cli_session_missing. Telegram shows it as “Start a new conversation with /new.” - Usage: one
usage_recordsrow per assistant message with providerclaude_cli, the CLI-reported tokens and cost zero, because the subscription is not billed per token. The CLI’stotal_cost_usdis kept in the run’susageJSON asapi_equivalent_cost_usd, for comparison only. The phase-13 tenant quota aggregates exclude providerclaude_cli, so the owner’s subscription use does not consume the family’s API quota.
Non-functional
- One new package,
internal/claudecli, holding the executor, the stream parser and the MCP bridge. Standard library only:os/exec,net/http,encoding/json. No MCP SDK, because the subset is small and reading the JSON-RPC is part of the lesson. internal/rundoes not importinternal/claudecli. The composition root injects the executor into the worker as a function value, keeping the one-way dependency rule inplan.md.- The bridge binds to loopback only and refuses requests without the current run’s token. Tokens are compared in constant time and never logged.
- Deployment: hango must run on the host where
claudeis installed and logged in as the owner. A container image with the CLI and a mounted~/.claudeis a follow-up. - Terms: this is for the subscriber’s own use only. If Anthropic’s policy changes, switching the owner’s agents to
provider = "anthropic"is a one-field change, and the feature flag turns this phase off without a code change.
Architecture
// internal/claudecli/executor.go
type Executor struct {
cfg Config
bridge *Bridge
tools ToolResolver // same resolution as the loop: registry + ToolProvider middleware
msgs store.MessageStore
usage store.UsageStore
}
// Execute matches the function type the run worker accepts for non-loop runtimes.
func (e *Executor) Execute(ctx context.Context, rc run.ExecContext) error
// internal/claudecli/bridge.go
type Bridge struct {
mu sync.Mutex
runs map[string]*bridgeRun // token -> run scope, tools, span context, sink
}
func (b *Bridge) Register(rc run.ExecContext, tools []tool.Tool) (url, token string, release func())worker claims run (lane claude_cli) ──▶ owner check ──▶ Executor.Execute
Bridge.Register ──▶ temp mcp-config.json (url + bearer token)
exec claude -p … --session-id|--resume <session uuid> (stdin = user input, env stripped)
stdout stream-json ──▶ parser
stream_event delta ──▶ run event (SSE, Telegram edits)
assistant message ──▶ message row, reconstructed chat span, usage row
result ──▶ terminal state, run usage
CLI ──POST /mcp tools/call──▶ Bridge ──▶ WrapToolCall chain (scope, audit, idempotency) ──▶ tool
release token, remove temp configInstrumentation
- The run’s
invoke_agentspan carrieshango.agent.runtime = claude_cli. A child spanclaudecli.processrecords the session mode (new|resume), CLI version, PID, exit code and duration. - Each assistant message produces a
chatspan with the model, token usage andhango.reconstructed = true, timed from the first delta to the message’s arrival. Langfuse shows these generations with outputs only, because hango never sees the prompt Claude Code sent. Bridge tool calls produce realexecute_toolspans parented underclaudecli.process, because the bridge looks up the run’s span context by token. - Metrics:
hango.claudecli.processesgauge,hango.claudecli.exitscounter (attributeoutcome=success|error|cancelled|killed|session_missing),hango.claudecli.bridge_callscounter (attributestool,status),hango.claudecli.unauthorizedcounter (bridge requests with a bad token),hango.claudecli.stream_parse_errorscounter. - The CLI’s stderr is captured, truncated to 8 KiB and logged at warn level on a non-zero exit, with
run_idandtrace_id.
Related Code Files
Create
internal/claudecli/config.go— config, startup version check.internal/claudecli/executor.go,internal/claudecli/executor_test.go— argv and environment construction, process lifecycle, cancel and kill, continuation on resume. Tests use a fake CLI binary built fromtestdata/fakecli/main.gothat replays recorded stream-json and calls the bridge.internal/claudecli/stream.go,internal/claudecli/stream_test.go— stream-json parsing into run events, message rows, spans and usage, against recorded fixtures intestdata/.internal/claudecli/bridge.go,internal/claudecli/bridge_test.go— the MCP subset, token registry, constant-time token check, tool dispatch through the chain.internal/claudecli/cli_integration_test.go— gated byHANGO_TEST_CLAUDE_CLI=1: one real run that calls one hango tool, asserting the tool span, the message rows and a clean exit.docs/learning/19-claude-cli-runtime.md.
Modify
plan.md— package cap 19 → 20 plusinternal/tool/builtin, addinginternal/claudecli; the one-way dependency rule gains the executor injection note.internal/run/worker.go— choose the executor by the agent’s provider, owner re-check at claim,runtime_forbiddenfailure.internal/run/create.go— laneclaude_cliand the owner check at creation.internal/server/handlers_agents.go(or the phase-12 validation it calls) —provider_not_allowed.internal/store/pg/usage.go— quota aggregates exclude providerclaude_cli.internal/config/config.go,internal/app/app.go— config, bridge listener, executor wiring, lane registration, drain order (bridge closes after the scheduler drains).internal/telemetry/metrics_names.go— the five metric names..env.example,README.md,docs/learning/README.md,docs/system-architecture.md,docs/deployment-guide.md(host requirement, logged-in CLI, terms note).
Implementation Steps
- Record fixtures: run
claude -p --output-format stream-json --verbose --include-partial-messagesby hand on three prompts (plain answer, one MCP tool call, an error) and save the output undertestdata/. Write down the CLI version. - Write
stream.goand its tests against the fixtures, including unknown event types (counted and skipped, never fatal). - Write
bridge.goand its tests: initialize handshake,tools/list,tools/callthrough a fake chain, a bad token returning 401, and a released token rejected. - Write the fake CLI in
testdata/fakeclithat replays a fixture and calls the bridge, then writeexecutor.goand its tests: argv contains no user input, environment has noANTHROPIC_API_KEYorHANGO_*,--session-idon first run and--resumeafter, cancel kills the process group, the iteration limit stops the process, and a requeued run sends the continuation prompt. - Change the worker, run creation and agent validation for the owner checks and the lane, with tests for each refusal path (non-owner agent save, non-owner run creation, a queued run whose creator is no longer the owner).
- Exclude
claude_clifrom the quota aggregates, with a test that owner CLI usage does not move the tenant’s monthly totals. - Wire config, the loopback listener, the executor and the drain order. Add spans, metrics and stderr logging.
- Run
HANGO_TEST_CLAUDE_CLI=1 go test ./internal/claudecli/ -run Integrationon the owner’s machine, then use the owner’s agent from Telegram. - Run one task twice, once on an
anthropicagent and once on aclaude_cliagent. Put both Langfuse traces side by side in the learning doc and list what each one shows. - Update docs and write the learning doc.
Learning Doc Outline
docs/learning/19-claude-cli-runtime.md must contain:
- Concept: a model provider answers one call; an agent runtime runs a whole loop. Claude Code is a runtime, so plugging it in replaces hango’s loop, not just its model.
- Design choices: CLI subprocess instead of a raw OAuth token; tools over a loopback MCP bridge so tool execution stays inside hango’s scope, audit and trace; hango’s session id reused as the Claude session id; owner-only checks at three points; a dedicated lane of one.
- What you give up, shown with the two traces from step 9: no prompt, no compaction or middleware visibility, reconstructed generation spans, and token counts reported by the CLI rather than by the API.
- Trade-offs: subscription cost vs observability; a hand-written MCP subset vs an SDK; one process per run vs a long-lived CLI process.
- goclaw comparison:
internal/providers/claude_cli*.go, its MCP bridge, hooks and deny patterns, and why goclaw makes it available to every agent while hango limits it to one user. - OpenClaw comparison: plugin harnesses (Codex app-server) as another way to hand the loop to an external runtime.
- How to debug this layer: start from
claudecli.process(exit outcome, session mode), read the captured stderr in Loki byrun_id, checkhango.claudecli.bridge_callsby status, and remember the prompt itself is not in Langfuse. - Follow-ups: a container image with the CLI, exposing the subagent tool through the bridge, and per-session working directories that persist files between runs.
- Exercises: kill the CLI process mid-run and watch the run resume with the continuation prompt; call the bridge with a stale token; switch one agent between
anthropicandclaude_cliand compare the eval scores from phase 17 once it exists. - Time budget: 12h implementation + 2h learning doc.
Success Criteria
-
go build ./... && go vet ./... && go test ./...pass, and the package list is exactly the 20 packages plusinternal/tool/builtinnamed inplan.md. -
internal/rundoes not importinternal/claudecli(test overgo list -deps). - A non-owner cannot save a
claude_cliagent (422), cannot create a run on one (404), and a queued run whose creator is not the owner fails withruntime_forbidden(tests). - The CLI argv never contains the user input, and the CLI environment contains no
ANTHROPIC_API_KEYand noHANGO_*variable (test). - The first run of a session uses
--session-idand later runs use--resumewith the same UUID (test). - The bridge rejects a missing, wrong or released token with 401 and serves
tools/listandtools/callfor the current run (tests). - A bridge tool call writes the same audit and telemetry as a loop tool call, and a repeated tool call id returns the stored result (test).
- Cancel stops the CLI process group within 5 s, and a requeued run resumes with the continuation prompt (tests with the fake CLI).
- Owner CLI usage writes
usage_recordswith providerclaude_cliand cost zero, and does not change the tenant’s quota aggregates (test). - The gated integration test passes against the real CLI on the owner’s machine, with the CLI version recorded in the learning doc.
- The learning doc shows the
anthropicandclaude_clitraces of the same task side by side.
Risk Assessment
| Risk | Likelihood x Impact | Signal | Pre-decided response |
|---|---|---|---|
| Anthropic changes its terms or blocks this use | Medium x Medium | CLI errors on auth, or a policy announcement | Turn the flag off and switch the owner’s agents to provider = "anthropic"; no code change |
| A CLI update changes flags or the stream-json format | High x Medium | hango.claudecli.stream_parse_errors rises or the process exits on bad flags | Unknown events are skipped and counted; fixtures and the integration test pin the version; re-record fixtures on upgrade |
| A family member reaches the subscription | Low x High | A claude_cli run whose creator is not the owner | Three owner checks plus the claim-time failure; audit rows show every run’s creator |
| The CLI falls back to API billing through an inherited key | Medium x Medium | Unexpected API charges | ANTHROPIC_API_KEY is stripped from the environment, covered by a test |
| Owner’s personal Claude Code settings change hango runs | Medium x Low | Runs behave differently from the CLI fixtures | --setting-sources excludes user and project settings; empty working directory |
| The resumed session after a crash repeats work | Medium x Medium | Duplicate tool effects | Bridge calls keep phase-5 tool idempotency; the continuation prompt is marked so it is visible in the transcript |
| Another local process calls the bridge | Low x Medium | hango.claudecli.unauthorized above zero | Loopback only, per-run random token, constant-time comparison |
Rollback
Set HANGO_CLAUDE_CLI_ENABLED=false: agent validation refuses claude_cli, and queued claude_cli runs fail with runtime_forbidden. Removing internal/claudecli and its wiring returns the binary to phase 18 behaviour. Existing usage_records rows with provider claude_cli stay as history.