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
- No providerId → return empty object; SDK uses
process.env code-cli+cliBackend='claude-code'(Subscription OAuth) →buildClaudeCodePassThroughResult:- Launch
AgentProxyServerwithpassThrough: true - Passthrough the SDK's native Bearer header to
api.anthropic.com - Collect response headers
anthropic-ratelimit-unified-{5h,7d}-*, triggeringSubscriptionUsageServiceto update frontend 5h/7d badges - Optional injection of Elftia-managed OAuth Token (
TokensService.getValidClaudeAccessToken()) to override system credentials - Mark
isOfficialProvider: false
- Launch
- Other paths →
llmConfig.getProvider(providerId)+resolveApiFormat(provider) - Official Anthropic provider(
provider.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
- 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
onSessionEndcleanup callback (stop proxy)
Function signature supplemented with
schemaFields?: { cliBackend, useExtendedContext }parameter (v89+), passed through fromchat_sessionsrow by caller.cliBackend === 'claude-code'triggers the passthrough branch;useExtendedContext === truetriggers 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_betaarray, rejected by/v1/messagesendpoint 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):
| Route | Location | Headers object injected into |
|---|---|---|
| passthrough mode (claude-code OAuth) | AgentProxyServer.handlePassThroughRequest before fetch | upstreamHeaders (copied + normalized from req.headers) |
| isOfficialProvider direct pass branch | AgentProxyServer before fetchWithRetry | object returned by getProviderHeaders(provider, apiKey) |
| Transformer chain route | TransformerChainExecutor.executeRequestChain exit | config.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-1min 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:
- URL normalization —
api_base_urlmay contain/v1, SDK will duplicate it to/v1/v1/messages - Auth header handling — different providers use different auth header formats (x-api-key vs Bearer)
- Format conversion — non-Anthropic providers need request/response format transformation
- Retry callbacks — 429/529 errors notify frontend via callback
- 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:
| Event | Payload | Description |
|---|---|---|
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 checks(AgentService.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
| File | Path | Description |
|---|---|---|
| ClaudeSdkEngine | agent-core/engine/ClaudeSdkEngine.ts | IEngine implementation + buildProviderEnvWithProxy (including passthrough branch + extendedContext passthrough) |
| AgentService | agent-core/agent/AgentService.ts | SDK session lifecycle; AgentSessionOptions holds cliBackend + useExtendedContext, DB model column stores raw SDK id; runSession injects sdkOptions.sessionStore; resumeSession walks fallback checks |
| AgentProxyServer | agent-core/agent/AgentProxyServer.ts | HTTP proxy server with three modes: passThrough / isOfficialProvider direct / default transformer chain |
| 1M-context beta injection helper | agent-core/agent/anthropicBetaInject.ts | injectExtendedContextBeta(body, model, useExtendedContext), shared by three exit points |
| SqliteSessionStore | agent-core/agent/SqliteSessionStore.ts | SQLite implementation of SDK 0.3.x SessionStore interface (append/load/delete/listSubkeys) |
| sdkPathEncoding helper | agent-core/agent/sdkPathEncoding.ts | encodeProjectPath (= replace(/[^A-Za-z0-9]/g, '-')) + hasResumableSdkJsonl (file exists + contains user record) |
| sdkSessionStore worker DAO | workers/db/sdkSessionStore.ts | CRUD on sdk_session_store table, 6 IPCs (append/load/delete/listSubkeys/listSessions/countBySessionId) |
| Code CLI types + split protocol | shared/contracts/code-cli-types.ts | splitModelReference() is the sole string protocol parser at IPC entry point |
| ApiKeyPoolService | capabilities/llm/completion/ApiKeyPoolService.ts | Multi-key load balancing |
| GitRuntimeService | platform/runtime/GitRuntimeService.ts | Windows 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.
| Channel | Triggered by | Main → Renderer IPC | Renderer → Main IPC |
|---|---|---|---|
| Permission | SDK canUseTool callback (tool usage authorization) | agent:permissionRequest | agent:respondPermission |
| AskUserQuestion | Agent actively calls mcp__elftia-ask__ask tool | agent:askUserQuestion | agent: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(removedisSkipPermissionsshort-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):
- Disable builtin: append
'AskUserQuestion'tosdkOptions.toolsSettings.disallowedTools - Register replacement MCP:
AskUserQuestionMcp.tsusescreateSdkMcpServer+tool()to exposemcp__elftia-ask__ask, with schema identical to builtin (questions: Array<{question, header(≤12 chars), multiSelect, options[2-4]}>) - Guide the model:
appendSystemPromptadds guidance to callmcp__elftia-ask__askinstead of builtin when encountering question scenarios - Handler flow: call
AgentService.askUserQuestion(dbSessionId, questions)→ create Promise + store resolver inpendingQuestionsMap → IPCagent:askUserQuestion→ rendererAskUserQuestionDialogdisplays stepper UI → user Submit → IPCagent:respondAskUserQuestion→ resolver serializes answers as JSON for tool_result. Cancel walksisError: truebranch 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(pendingQuestionsMap +askUserQuestion()+respondToAskUserQuestion(), injection logic at end ofrunSession)packages/desktop/app/main/services/routers/AgentRouter.ts(agent:respondAskUserQuestionIPC + 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
RetryCallbackparameter - Background model config: set model for background tasks via
agentDefaults.background
Related Modules
| Module | Path | Relationship |
|---|---|---|
| EngineDispatcher | agent-core/engine/EngineDispatcher.ts | engine registration |
| LLMConfigService | capabilities/llm/config-service/ | provider configuration |
| MagiService | agent-core/magi/MagiService.ts | high-level orchestration (includes assembleMagiMcps walking registry + Object.assign merge to customAgentOptions.additionalMcpServers; buildTinyElfDirectMcpServers is TinyElf entry) |
| MagiSdkOptionsBuilder | agent-core/magi/MagiSdkOptionsBuilder.ts | Prompt 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 |
| McpProviderRegistry | capabilities/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 Registry | capabilities/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 injection | agent-core/agent/AgentService.ts | mergeMcpAssembly(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 |