Skip to main content

ClaudeSdkEngine Integration

ClaudeSdkEngine is an IEngine wrapper around @anthropic-ai/claude-agent-sdk. It handles provider proxy setup, API Key resolution, multi-key load balancing, and automatic Git/Bash configuration on Windows.

Architecture Diagram

graph TB
Router["AgentRouter / MagiService"] --> Engine["ClaudeSdkEngine"]

Engine --> EnvBuild["buildProviderEnvWithProxy()"]
Engine --> AgentSvc["AgentService"]
Engine --> GitRt["GitRuntimeService<br/>(Windows)"]

EnvBuild --> Decision{"Route?"}
Decision -->|"cliBackend='claude-code'<br/>(Subscription OAuth)"| PassThrough["AgentProxyServer<br/>passThrough mode"]
Decision -->|"id='anthropic'<br/>and no streaming callback"| Direct["Direct API Key pass<br/>env: ANTHROPIC_API_KEY"]
Decision -->|"Other providers"| ProxySvc["AgentProxyServer<br/>transformer chain / direct pass"]

PassThrough --> Anthropic["api.anthropic.com<br/>passthrough SDK native Bearer<br/>collect 5h/7d quota headers"]
ProxySvc --> URLNorm["URL normalization<br/>remove duplicate /v1"]
ProxySvc --> AuthH["Auth header conversion<br/>x-api-key → Bearer"]
ProxySvc --> FmtConv["Format conversion<br/>OpenAI ↔ Anthropic"]
ProxySvc --> Retry["Retry callback<br/>429/529 → IPC notify"]
ProxySvc --> BgModel["Background model routing<br/>background task model replacement"]

Direct --> AgentSvc
ProxySvc -->|"env: ANTHROPIC_BASE_URL=localhost:PORT"| AgentSvc
PassThrough -->|"env: ANTHROPIC_BASE_URL=localhost:PORT"| AgentSvc

AgentSvc --> SDK["Claude Agent SDK<br/>subprocess"]

Core Logic: buildProviderEnvWithProxy

This function prepares environment variables for the Claude Agent SDK. It adopts different strategies based on flat fields in the chat_sessions row (providerId, cliBackend, useExtendedContext, and raw model).

Algorithm Steps

  1. No providerId → return empty object; SDK uses process.env
  2. code-cli + cliBackend='claude-code'(Subscription OAuth)buildClaudeCodePassThroughResult:
    • Launch AgentProxyServer with passThrough: true
    • Passthrough the SDK's native Bearer header to api.anthropic.com
    • Collect response headers anthropic-ratelimit-unified-{5h,7d}-*, triggering SubscriptionUsageService to update frontend 5h/7d badges
    • Optional injection of Elftia-managed OAuth Token (TokensService.getValidClaudeAccessToken()) to override system credentials
    • Mark isOfficialProvider: false
  3. Other pathsllmConfig.getProvider(providerId) + resolveApiFormat(provider)
  4. Official Anthropic providerprovider.id === 'anthropic'):
    • Fetch Key from ApiKeyPool or provider.api_key
    • Set ANTHROPIC_API_KEY
    • If custom base URL exists, set ANTHROPIC_BASE_URL (strip trailing /v1)
    • Mark isOfficialProvider: true
  5. Other providers:
    • Fetch Key from ApiKeyPool or provider.api_key
    • Launch AgentProxyServer (local HTTP proxy)
    • Set ANTHROPIC_BASE_URL = proxy.getBaseUrl()
    • For Anthropic-format providers, pass real Key (enables server-side tools)
    • For non-Anthropic formats, pass 'proxy-mode' (Key handled by proxy)
    • Return onSessionEnd cleanup callback (stop proxy)

Function signature supplemented with schemaFields?: { cliBackend, useExtendedContext } parameter (v89+), passed through from chat_sessions row by caller. cliBackend === 'claude-code' triggers the passthrough branch; useExtendedContext === true triggers 1M-context beta injection (see below).

1M-context beta Injection (v89+)

When chat_sessions.useExtendedContext = 1 and model is in the 1M-capable whitelist (claude-opus-4-7 / claude-opus-4-6 / claude-sonnet-4-6), injectExtendedContextBeta(headers, model, useExtendedContext) merges 'context-1m-2025-08-07' to the outbound request's anthropic-beta HTTP header (comma-separated list).

Not a body field. Early implementations put the flag in body.anthropic_beta array, rejected by /v1/messages endpoint with 400 "anthropic_beta: Extra inputs are not permitted". The canonical path is HTTP header.

Injection points (three locations share the same idempotent + case-insensitive helper):

RouteLocationHeaders object injected into
passthrough mode (claude-code OAuth)AgentProxyServer.handlePassThroughRequest before fetchupstreamHeaders (copied + normalized from req.headers)
isOfficialProvider direct pass branchAgentProxyServer before fetchWithRetryobject returned by getProviderHeaders(provider, apiKey)
Transformer chain routeTransformerChainExecutor.executeRequestChain exitconfig.headers (merged into fetch headers)

The helper preserves other betas already written by SDK (e.g., prompt-caching-2024-07-31), and is compatible with case variants (Anthropic-Beta is also recognized + rewritten as canonical lowercase anthropic-beta).

Claude Agent SDK itself does not automatically add 1M-context beta (0 occurrences of context-1m in source). If the [1m] UI marker is not injected into HTTP headers via this helper, 1M context will not take effect and requests will still use 200K mode.

Proxy Server Role

All non-built-in Anthropic providers route through the proxy for these reasons:

  1. URL normalizationapi_base_url may contain /v1, SDK will duplicate it to /v1/v1/messages
  2. Auth header handling — different providers use different auth header formats (x-api-key vs Bearer)
  3. Format conversion — non-Anthropic providers need request/response format transformation
  4. Retry callbacks — 429/529 errors notify frontend via callback
  5. Background model routing — SDK's background task requests can route to different models

API Key Resolution

function resolveApiKey(apiKey: string): string {
if (apiKey.startsWith('$')) {
return process.env[apiKey.slice(1)] || '';
}
return apiKey;
}

Supports referencing environment variables via $ prefix, e.g., $ANTHROPIC_API_KEY.

Multi-Key Load Balancing

Implemented via ApiKeyPoolService:

setApiKeyPool(pool: ApiKeyPoolService): void;

Once set, buildProviderEnvWithProxy prioritizes fetching Keys from the Pool:

let apiKey = '';
if (apiKeyPool && sessionId) {
apiKey = await apiKeyPool.getKeyForSession(provider.id, sessionId);
}
if (!apiKey) {
apiKey = resolveApiKey(provider.api_key);
}

The Pool uses weighted round-robin + session affinity strategy to distribute Keys, with automatic cooldown triggered on 429/529 errors.

Windows Git Configuration

setGitRuntime(runtime: GitRuntimeService): void;

The Claude Agent SDK subprocess requires Git and Bash. On Windows, GitRuntimeService is responsible for:

  • Detecting Git for Windows installation path
  • Adding git-bash path to PATH
  • Ensuring SDK subprocess can run normally

Automatically calls ensureGitForSdk() before each startSession and resumeSession.

Two Invocation Routes

Self-service route (AgentRouter)

AgentRouter calls ClaudeSdkEngine directly without passing providerEnv:

// ctx.providerEnv is empty
engine.startSession(ctx);
// → internally calls buildProviderEnvWithProxy() to construct itself

Magi pre-built route

MagiService pre-constructs providerEnv and passes it in:

// ctx.providerEnv already built by MagiService
engine.startSession(ctx);
// → directly uses ctx.providerEnv, skips self-construction
// → uses startWithExistingSession (DB session already exists)

Decision Logic

if (ctx.providerEnv && dbSessionId) {
await this.agent.startWithExistingSession(sender, dbSessionId, sessionOpts);
} else {
await this.agent.createSession(sender, sessionOpts);
}

API Key Validation

Validates keys before starting a session to prevent CLI subprocess failures due to missing keys:

if (!env?.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_API_KEY) {
throw new Error(`API key not configured for provider "${providerId}"`);
}

IPC Events

Beyond standard agent:event events, ClaudeSdkEngine also sends retry notifications:

EventPayloadDescription
agent:event type=retry{ attempt, maxAttempts, delayMs, error }API request retry notification

SessionStore (SDK 0.3.x, 2026-05-19+)

SDK 0.3.x provides official SessionStore interface, mirroring session transcripts to any external storage. AgentService.runSession injects it via sdkOptions.sessionStore = new SqliteSessionStore(db):

SDK subprocess writes disk JSONL → SDK also calls sessionStore.append(key, entries)
→ SqliteSessionStore → sdkSessionStore:append IPC → DB worker
→ INSERT OR IGNORE to sdk_session_store table (idempotent by entryUuid)

On Resume: SDK calls sessionStore.load(key) → returns entries[] | null
→ SDK materializes entries to temp JSONL → subprocess --resume
→ can recover even if disk JSONL is lost (as long as sdk_session_store has data)

Resume safety checksAgentService.resumeSession): before dispatch, check both hasResumableSdkJsonl(projectPath, sdkSessionId) (disk file exists + contains type:'user' record) and sdkSessionStore:countBySessionId > 0; if both are empty → clear sdkSessionId and let SDK start new conversation (DB chat history preserved).

Deprecated path: old sdk_records table + JsonlBuilder.reconstruct() path (IPC schema ≠ disk schema, SDK rejects) fully sunset in migration v97. See docs/dev/66_sdk_records/01_schema_divergence_investigation.md.

Key Files

FilePathDescription
ClaudeSdkEngineagent-core/engine/ClaudeSdkEngine.tsIEngine implementation + buildProviderEnvWithProxy (including passthrough branch + extendedContext passthrough)
AgentServiceagent-core/agent/AgentService.tsSDK session lifecycle; AgentSessionOptions holds cliBackend + useExtendedContext, DB model column stores raw SDK id; runSession injects sdkOptions.sessionStore; resumeSession walks fallback checks
AgentProxyServeragent-core/agent/AgentProxyServer.tsHTTP proxy server with three modes: passThrough / isOfficialProvider direct / default transformer chain
1M-context beta injection helperagent-core/agent/anthropicBetaInject.tsinjectExtendedContextBeta(body, model, useExtendedContext), shared by three exit points
SqliteSessionStoreagent-core/agent/SqliteSessionStore.tsSQLite implementation of SDK 0.3.x SessionStore interface (append/load/delete/listSubkeys)
sdkPathEncoding helperagent-core/agent/sdkPathEncoding.tsencodeProjectPath (= replace(/[^A-Za-z0-9]/g, '-')) + hasResumableSdkJsonl (file exists + contains user record)
sdkSessionStore worker DAOworkers/db/sdkSessionStore.tsCRUD on sdk_session_store table, 6 IPCs (append/load/delete/listSubkeys/listSessions/countBySessionId)
Code CLI types + split protocolshared/contracts/code-cli-types.tssplitModelReference() is the sole string protocol parser at IPC entry point
ApiKeyPoolServicecapabilities/llm/completion/ApiKeyPoolService.tsMulti-key load balancing
GitRuntimeServiceplatform/runtime/GitRuntimeService.tsWindows Git configuration

All paths relative to packages/desktop/app/main/services/; shared/contracts/code-cli-types.ts is in packages/desktop/app/, and workers/db/sdkSessionStore.ts is in packages/desktop/app/main/.

User Interaction Channels (permission + ask_user_question)

Claude SDK engine has two symmetric dialog channels from backend to renderer, both maintained by AgentService with pendingXxx: Map<requestId, resolver> + 5-minute timeout + IPC push via active.sender.send.

ChannelTriggered byMain → Renderer IPCRenderer → Main IPC
PermissionSDK canUseTool callback (tool usage authorization)agent:permissionRequestagent:respondPermission
AskUserQuestionAgent actively calls mcp__elftia-ask__ask toolagent:askUserQuestionagent:respondAskUserQuestion

Permission: Fallback callback in bypassPermissions mode

AgentService.runSession always registers onPermissionRequest, and lib/claude-sdk.ts mapCliOptionsToSDK always bridges it to SDK's canUseTool. In bypass mode, the callback internally applies behavior: 'allow' and prints diagnostic fields agentID / blockedPath / decisionReason.

Previous code short-circuited the callback at both layers with if (skipPermissions) skip, causing a silent deny bug: when the main agent is bypassPermissions but a subagent uses tools: [Read, Glob] to narrow the tool whitelist, SDK still runs canUseTool gate on subagent out-of-bounds calls, but no callback is registered → silent deny → user sees "permission denied" but elftia shows no dialog.

Key fix locations:

  • packages/desktop/app/main/lib/claude-sdk.ts:262-339 (both callback layers always registered)
  • packages/desktop/app/main/services/agent-core/agent/AgentService.ts:1064-1086 (removed isSkipPermissions short-circuit)

AskUserQuestion: Replace builtin with SDK in-process MCP

The AskUserQuestion tool bundled with Claude Code preset pops up terminal prompts that the elftia main process cannot surface. Fix approach (option A):

  1. Disable builtin: append 'AskUserQuestion' to sdkOptions.toolsSettings.disallowedTools
  2. Register replacement MCP: AskUserQuestionMcp.ts uses createSdkMcpServer + tool() to expose mcp__elftia-ask__ask, with schema identical to builtin (questions: Array<{question, header(≤12 chars), multiSelect, options[2-4]}>)
  3. Guide the model: appendSystemPrompt adds guidance to call mcp__elftia-ask__ask instead of builtin when encountering question scenarios
  4. Handler flow: call AgentService.askUserQuestion(dbSessionId, questions) → create Promise + store resolver in pendingQuestions Map → IPC agent:askUserQuestion → renderer AskUserQuestionDialog displays stepper UI → user Submit → IPC agent:respondAskUserQuestion → resolver serializes answers as JSON for tool_result. Cancel walks isError: true branch to tell model user cancelled.

The MCP server instance is built per session (closure over dbSessionId), ensuring questions land on the correct renderer in multi-tab scenarios.

Related code:

  • packages/desktop/app/main/services/agent-core/agent/AskUserQuestionMcp.ts (MCP server constructor + ELFTIA_ASK_MCP_NAME)
  • packages/desktop/app/main/services/agent-core/agent/AgentService.ts (pendingQuestions Map + askUserQuestion() + respondToAskUserQuestion(), injection logic at end of runSession)
  • packages/desktop/app/main/services/routers/AgentRouter.ts (agent:respondAskUserQuestion IPC + zod schema)
  • packages/renderer/src/features/chat/components/agent/AskUserQuestionDialog.tsx (stepper UI: current question expanded + answered questions collapsed summary row clickable to revert + permanent "Other" text input + Submit enabled only after all answered)

Extension Points

  • New proxy format conversion: add new API format adapters in AgentProxyServer
  • Custom retry strategy: customize retry behavior via RetryCallback parameter
  • Background model config: set model for background tasks via agentDefaults.background
ModulePathRelationship
EngineDispatcheragent-core/engine/EngineDispatcher.tsengine registration
LLMConfigServicecapabilities/llm/config-service/provider configuration
MagiServiceagent-core/magi/MagiService.tshigh-level orchestration (includes assembleMagiMcps walking registry + Object.assign merge to customAgentOptions.additionalMcpServers; buildTinyElfDirectMcpServers is TinyElf entry)
MagiSdkOptionsBuilderagent-core/magi/MagiSdkOptionsBuilder.tsPrompt construction (V1-V4) + user MCP injection (setUserMcpServers / getMcpServers(allowedUserMcpNames)) + setChannelMcpProbe. Built-in MCP assembly migrated out from Phase 5.9 to services/capabilities/tools/mcp-builtin/; original name retained as it still handles user MCP portion
McpProviderRegistrycapabilities/tools/mcp-builtin/11 statically-registered built-in MCP Providers + dynamic ScriptPluginProviders factory; unified assembleMcpForSession(ctx) entry point. SDK route calls: MagiService.assembleMagiMcps (Clawia), AgentService.mergeMcpAssembly (others). Original media-tools MCP migrated out in Tier C pilot to elftia_toolkit inside as media toolkit (see 08_builtin_mcp_and_toolkits.md §3). See capabilities/tools/mcp-builtin/README.md
Skill Toolkit Registrycapabilities/tools/skill-toolkit/in-process function dispatcher inside elftia_toolkit MCP. Exposes 4 meta-tools: list_toolkits / read_toolkit / read_toolkit_reference / skill_invoke. Two built-in toolkits: chrome-use (28 CDP functions) + media (10 media generation/speech functions). Progressive disclosure: SKILL.md is index, per-provider / per-operation deep docs in toolkit's references map, pulled on-demand via read_toolkit_reference
AgentService MCP injectionagent-core/agent/AgentService.tsmergeMcpAssembly(sdkOptions, options, dbSessionId) is registry entry point for non-Clawia SDK sessions; injected in main/index.ts via setMcpAssembler. Per-session cleanup automatically chained to onSessionEnd