# 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](https://github.com/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: ```csharp o.AddExtension(); 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__`. 2. `claude` is started with `--tools ""` (all built-in tools off), `--permission-mode dontAsk`, `--strict-mcp-config --mcp-config `, `--input-format stream-json --output-format stream-json --include-partial-messages --verbose`, `--system-prompt-file `, `--model `, `--effort `, 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, with one exception: built-in plugins (`@builtin`, such as `cc-plugin-*`) load whatever the setting sources say, and which ones load depends on the CLI version and the account's feature flags. The settings disable every known built-in (`BuiltinPlugins.Known`); when the init lists only built-ins that weren't disabled, the provider adds them to the process-wide `BuiltinPlugins.Shared` set and replays the request once. Nothing is published before the replay. A built-in that still loads, or any other plugin, fails the request. ## 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).