Skip to content
Claude Code CLI runtime for the owner

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 (default claude), HANGO_CLAUDE_CLI_WORKDIR (parent directory for per-session working directories), HANGO_CLAUDE_CLI_BRIDGE_ADDR (default 127.0.0.1:0). At startup, hango runs claude --version and 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’s created_by is the owner, and the request comes from the owner. Anyone else gets 422 provider_not_allowed. model is passed through to --model (for example sonnet or opus).
  • Run creation for a claude_cli agent 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 with runtime_forbidden otherwise, so a later change to config cannot leave an old queued run on the subscription.
  • claude_cli runs go to a dedicated scheduler lane claude_cli with concurrency HANGO_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_cli run to the CLI executor instead of agent.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 (--tools with an empty set). --strict-mcp-config with a generated --mcp-config that points only at hango’s bridge, and --allowedTools naming only the bridge’s tools.
    • --setting-sources set so the owner’s personal Claude Code settings, hooks and CLAUDE.md files do not leak into hango runs.
    • --append-system-prompt with the agent’s system_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_KEY and every HANGO_* variable are removed, so the CLI cannot silently fall back to API billing or read hango’s secrets.
    • Verify every flag against claude --help for the installed version. The integration test pins the version it ran against.
  • MCP bridge: an HTTP listener on loopback serving POST /mcp with 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/list returns the agent’s tools resolved the same way the loop resolves them. Every tools/call runs through the phase-7 WrapToolCall chain in the run’s tenant scope, so audit, offload, idempotency and telemetry apply unchanged. The subagent task tool is not exposed in this phase.
  • Stream mapping: stream_event text deltas become hango text-delta run events. Each complete assistant message is persisted as a hango message row. Each tool result the bridge produced is persisted as a tool message row. The result event sets the terminal state, stop_reason, iterations (from num_turns) and the run’s usage.
  • Limits: HANGO_MAX_ITERATIONS is enforced by counting assistant messages, and the process is stopped with stop_reason = max_iterations when it is exceeded. HANGO_RUN_DEADLINE applies 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_records row per assistant message with provider claude_cli, the CLI-reported tokens and cost zero, because the subscription is not billed per token. The CLI’s total_cost_usd is kept in the run’s usage JSON as api_equivalent_cost_usd, for comparison only. The phase-13 tenant quota aggregates exclude provider claude_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/run does not import internal/claudecli. The composition root injects the executor into the worker as a function value, keeping the one-way dependency rule in plan.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 claude is installed and logged in as the owner. A container image with the CLI and a mounted ~/.claude is 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 config

Instrumentation

  • The run’s invoke_agent span carries hango.agent.runtime = claude_cli. A child span claudecli.process records the session mode (new|resume), CLI version, PID, exit code and duration.
  • Each assistant message produces a chat span with the model, token usage and hango.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 real execute_tool spans parented under claudecli.process, because the bridge looks up the run’s span context by token.
  • Metrics: hango.claudecli.processes gauge, hango.claudecli.exits counter (attribute outcome = success|error|cancelled|killed|session_missing), hango.claudecli.bridge_calls counter (attributes tool, status), hango.claudecli.unauthorized counter (bridge requests with a bad token), hango.claudecli.stream_parse_errors counter.
  • The CLI’s stderr is captured, truncated to 8 KiB and logged at warn level on a non-zero exit, with run_id and trace_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 from testdata/fakecli/main.go that 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 in testdata/.
  • 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 by HANGO_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 plus internal/tool/builtin, adding internal/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_forbidden failure.
  • internal/run/create.go — lane claude_cli and 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 provider claude_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

  1. Record fixtures: run claude -p --output-format stream-json --verbose --include-partial-messages by hand on three prompts (plain answer, one MCP tool call, an error) and save the output under testdata/. Write down the CLI version.
  2. Write stream.go and its tests against the fixtures, including unknown event types (counted and skipped, never fatal).
  3. Write bridge.go and its tests: initialize handshake, tools/list, tools/call through a fake chain, a bad token returning 401, and a released token rejected.
  4. Write the fake CLI in testdata/fakecli that replays a fixture and calls the bridge, then write executor.go and its tests: argv contains no user input, environment has no ANTHROPIC_API_KEY or HANGO_*, --session-id on first run and --resume after, cancel kills the process group, the iteration limit stops the process, and a requeued run sends the continuation prompt.
  5. 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).
  6. Exclude claude_cli from the quota aggregates, with a test that owner CLI usage does not move the tenant’s monthly totals.
  7. Wire config, the loopback listener, the executor and the drain order. Add spans, metrics and stderr logging.
  8. Run HANGO_TEST_CLAUDE_CLI=1 go test ./internal/claudecli/ -run Integration on the owner’s machine, then use the owner’s agent from Telegram.
  9. Run one task twice, once on an anthropic agent and once on a claude_cli agent. Put both Langfuse traces side by side in the learning doc and list what each one shows.
  10. 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 by run_id, check hango.claudecli.bridge_calls by 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 anthropic and claude_cli and 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 plus internal/tool/builtin named in plan.md.
  • internal/run does not import internal/claudecli (test over go list -deps).
  • A non-owner cannot save a claude_cli agent (422), cannot create a run on one (404), and a queued run whose creator is not the owner fails with runtime_forbidden (tests).
  • The CLI argv never contains the user input, and the CLI environment contains no ANTHROPIC_API_KEY and no HANGO_* variable (test).
  • The first run of a session uses --session-id and later runs use --resume with the same UUID (test).
  • The bridge rejects a missing, wrong or released token with 401 and serves tools/list and tools/call for 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_records with provider claude_cli and 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 anthropic and claude_cli traces of the same task side by side.

Risk Assessment

RiskLikelihood x ImpactSignalPre-decided response
Anthropic changes its terms or blocks this useMedium x MediumCLI errors on auth, or a policy announcementTurn the flag off and switch the owner’s agents to provider = "anthropic"; no code change
A CLI update changes flags or the stream-json formatHigh x Mediumhango.claudecli.stream_parse_errors rises or the process exits on bad flagsUnknown events are skipped and counted; fixtures and the integration test pin the version; re-record fixtures on upgrade
A family member reaches the subscriptionLow x HighA claude_cli run whose creator is not the ownerThree owner checks plus the claim-time failure; audit rows show every run’s creator
The CLI falls back to API billing through an inherited keyMedium x MediumUnexpected API chargesANTHROPIC_API_KEY is stripped from the environment, covered by a test
Owner’s personal Claude Code settings change hango runsMedium x LowRuns behave differently from the CLI fixtures--setting-sources excludes user and project settings; empty working directory
The resumed session after a crash repeats workMedium x MediumDuplicate tool effectsBridge calls keep phase-5 tool idempotency; the continuation prompt is marked so it is visible in the transcript
Another local process calls the bridgeLow x Mediumhango.claudecli.unauthorized above zeroLoopback 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.