Files
VolcanicAgenticCore/docs/claude-code-provider.md

8.1 KiB

Claude Code provider

Volcanic.AgenticCore.Extensions.ClaudeCode lets the core use a Claude subscription through the locally installed claude CLI (Claude Code) instead of an Anthropic API key. It is a C# port of chem/pi-claude-code-provider; read that project's DESIGN.md for the full reasoning behind each decision.

Requirements

  • claude (Claude Code) 2.1.x on PATH (or VOLCANIC_CLAUDE_CODE_PATH), logged in with a claude.ai subscription (claude auth status must report loggedIn: true, authMethod: claude.ai, apiProvider: firstParty).
  • The .NET runtime (dotnet) on PATH or Environment.ProcessPath: claude spawns the bridge as dotnet Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll.
  • None of ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX in the host environment: the extension refuses to run with them, because it is designed for subscription auth only.

Register it and pick a model:

o.AddExtension<ClaudeCodeProviderExtension>();
o.DefaultModel = "claude-code/sonnet";      // or opus, haiku, fable, or the pinned claude-opus-5-5

Run /claude-code-doctor in a session to check the installation, auth, required CLI flags and the bridge handshake. It costs no quota. If it reports "not logged in", run /claude-code-login (see below) or claude auth login on the host.

How it works

Every model call spawns a fresh claude -p process. Nothing is resumed on Claude's side: the whole conversation is replayed each time.

  1. The active tools are written to a JSON catalogue; each tool becomes an MCP tool mcp__volcanic__<alias>.
  2. claude is started with --tools "" (all built-in tools off), --permission-mode dontAsk, --strict-mcp-config --mcp-config <bridge>, --input-format stream-json --output-format stream-json --include-partial-messages --verbose, --system-prompt-file <tmp>, --model <id>, --effort <level>, plus settings that disable hooks, auto-memory, plugins, slash commands and the token reminder (which would defeat prompt caching). Only an allow-list of environment variables is passed through, plus TZ set to the session's time zone (the chat's zone on Telegram, read per call so a /timezone change applies from the next model call; never the host's own TZ, and not on Windows, which ignores it), and compaction inside Claude is disabled: the core owns compaction. claude and its login/doctor processes don't go through the host's process spawn policy (AgenticCoreOptions.ProcessEnvironment): they already get a replaced, allow-listed environment. The Bridge inherits claude's environment, and the core tools it advertises (such as bash) run in the core, so they go through the policy like any other tool call.
  3. The bridge (a tiny console app) is the MCP server. It answers initialize and tools/list with the catalogue and touches a "ready" marker file. It never executes anything: tools/call returns an error and touches a "violation" marker that fails the request.
  4. The transcript is sent as one stream-json user message: a header record, then one JSON record per message (user, assistant with text/thinking/toolCall, toolResult). @ characters are escaped because Claude expands @path references. A cache breakpoint is placed on the last record so Claude's prompt cache keeps hitting as the conversation grows. Images are written to a per-session content-addressed store and referenced as @"path" attachments.
  5. Claude's stream-json output is mapped to the core's streaming events. When the model asks for a tool (message_delta.stop_reason == "tool_use"), Claude would deny it itself (dontAsk mode), so the provider terminates the process immediately and publishes Done(ToolUse). The core executes the tool and the next call replays the transcript including the result.
  6. The system/init record is validated strictly: the exposed tools, permissionMode, empty slash commands, apiKeySource == "none" (proof of subscription auth) and a connected volcanic MCP server. Anything else fails closed.

Session expiry and re-login

A claude subscription session can expire mid-use (a 401, or the CLI's own "Please run /login" / "Invalid API key" wording). The provider detects this from the stream and, given somewhere to send the owner a link, logs back in without failing the request outright:

  1. The failing request pauses instead of erroring, as long as nothing has reached the caller yet (no partial reply, no tool call).
  2. If a ClaudeCodeProviderOptions.LoginUi is configured, its IUserInterface gets one input prompt whose title carries the steps and the claude auth login --claudeai link and asks for the code shown on the page ("Paste the Claude login code"). Link and prompt are one message, so they can't arrive out of order.
  3. ClaudeLoginCoordinator keeps this to one flow at a time: concurrent expired requests all wait on the same login instead of racing the CLI. A request whose claude run started before a login completed, but whose expiry is only reported afterwards, replays straight away instead of starting a second login (the coordinator counts successful logins in Generation).
  4. A wrong code gets a fresh link, up to 3 attempts; the owner has 10 minutes to answer each prompt (ClaudeLoginOptions.CodeTimeout), and resolving where the owner is has 30 seconds (ClaudeLoginOptions.OwnerTimeout) before they count as unreachable.
  5. On success the paused request replays exactly once; a failure there (including expiring again immediately) is published as-is, so there is no retry loop.
  6. LoginUi is null (the default, e.g. headless hosts): no login flow runs. The request fails with [auth] Claude Code session expired; run "claude auth login" on the host (or /claude-code-login from a chat UI).

Run /claude-code-login in any session with a UI to sign in (or switch accounts) through that session's own dialog, with no owner redirect — useful even before the session has expired. If a login is already running elsewhere (another chat's expired request), the command waits for it and reports its outcome. Without a UI it explains that it needs one instead of failing silently.

ClaudeCodeProviderOptions carries the pieces a host wires up: LoginUi (where the link goes), Coordinator (defaults to the process-wide ClaudeLoginCoordinator.Shared), and LoginOptions (the timeouts and attempt budget above). The Telegram host points LoginUi at the first AllowedUserIds entry's private chat.

Limitations (inherited from the design)

  • Full-history replay costs more context than the Messages API and cannot replay thinking signatures.
  • Claude adds its own identity line and an environment block to every request; a Claude Code upgrade invalidates the prompt cache.
  • Claude still runs git ls-files / rg --files in the working directory at startup (slow in huge repos) and reads .claude/settings*.json there.
  • Mid-stream API recovery (api_retry, message id changes) fails the request as stream_interrupted; the core's retry policy retries it.
  • Cost is always reported as 0. Cancelling may still consume quota.
  • Windows: process-tree termination is best effort.

Configuration

Setting Where Default
CLI path VOLCANIC_CLAUDE_CODE_PATH or ClaudeCodeProviderOptions.ClaudePath claude on PATH
Idle timeout VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS 5 min
Total timeout VOLCANIC_CLAUDE_CODE_TOTAL_TIMEOUT_MS 30 min
Thinking session thinking level → --effort (low…max), not sent for haiku model default

Errors

Provider failures surface as assistant error messages with a code prefix, for example [executable_missing] ..., [auth] ..., [mcp_startup] ..., [protocol_init] .... Two are special: stream_interrupted messages contain stream ended before message_stop (retried automatically), and context overflow messages start with context_length_exceeded: (the core compacts and retries once).