7.9 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 onPATH(orVOLCANIC_CLAUDE_CODE_PATH), logged in with a claude.ai subscription (claude auth statusmust reportloggedIn: true,authMethod: claude.ai,apiProvider: firstParty).- The .NET runtime (
dotnet) onPATHorEnvironment.ProcessPath:claudespawns the bridge asdotnet 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_VERTEXin 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.
- The active tools are written to a JSON catalogue; each tool becomes an MCP tool
mcp__volcanic__<alias>. claudeis 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, and compaction inside Claude is disabled: the core owns compaction.claudeand 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 inheritsclaude's environment, and the core tools it advertises (such asbash) run in the core, so they go through the policy like any other tool call.- The bridge (a tiny console app) is the MCP server. It answers
initializeandtools/listwith the catalogue and touches a "ready" marker file. It never executes anything:tools/callreturns an error and touches a "violation" marker that fails the request. - 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@pathreferences. 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. - 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 publishesDone(ToolUse). The core executes the tool and the next call replays the transcript including the result. - The
system/initrecord is validated strictly: the exposed tools,permissionMode, empty slash commands,apiKeySource == "none"(proof of subscription auth) and a connectedvolcanicMCP 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:
- The failing request pauses instead of erroring, as long as nothing has reached the caller yet (no partial reply, no tool call).
- If a
ClaudeCodeProviderOptions.LoginUiis configured, itsIUserInterfacegets one input prompt whose title carries the steps and theclaude auth login --claudeailink 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. ClaudeLoginCoordinatorkeeps this to one flow at a time: concurrent expired requests all wait on the same login instead of racing the CLI. A request whoseclauderun 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 inGeneration).- 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. - 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.
LoginUiis 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 --filesin the working directory at startup (slow in huge repos) and reads.claude/settings*.jsonthere. - Mid-stream API recovery (
api_retry, message id changes) fails the request asstream_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).