Route Cambium LLM calls to the Claude subscription
Date: 2026-06-15 Status: Approved (design) Author: Joshua + Claude
Problem
Workflow run 58f786ff-96e1-4eb8-a773-ef54e9863d19 (intent: “Gather AI headlines
every day at 6am…”) failed at task process_0 (the summarize step) with:
LLM call failed: fetch failed
Root cause: the summarize step runs the hardcoded Ollama LlmCallExecutor
(packages/hypha/src/task-runner.ts), which fetches http://localhost:11434/api/generate.
Ollama is not running on this machine (nothing listening on :11434), so the
connection is refused and the whole run fails. The fetches and merge before it
all succeeded.
Two structural facts make this more than a “start Ollama” fix:
- The workflow’s
llm_callstrategy is served by the hardcoded Ollama executor. - The auth-aware
AuthenticatedLlmCaller(packages/providers) — which already has anAnthropicApiAdapterand a working Codex/ChatGPT subscription integration — is constructed inapps/loom/src/lib/services.tsbut is never wired into the workflowllm_call. It is only used by cortex (heartbeat/personas/intent decomposition).
Goal
Move Cambium’s workflow LLM calls off Ollama and onto the user’s Claude
subscription (Claude Code OAuth credentials at ~/.claude/.credentials.json),
replacing Ollama as the default llm_call provider.
Decisions (confirmed with user)
- Auth: Subscription OAuth — read/refresh
~/.claude/.credentials.json. No API key. - Fallback: Claude only. Replace Ollama as the
llm_calldefault (global, incl. cortex paths). - Model:
claude-sonnet-4-6.
Design
The Codex/ChatGPT subscription is already integrated through a repeatable pattern. This work mirrors that pattern 1:1 for Claude, then closes the gap that prevents the workflow task runner from using it.
A. Claude subscription provider (mirror of Codex)
| Piece | Codex (exists) | Claude (new) |
|---|---|---|
| Local-creds resolver | packages/providers/src/codex-auth-resolver.ts reads ~/.codex/auth.json |
packages/providers/src/claude-auth-resolver.ts reads ~/.claude/.credentials.json (claudeAiOauth.{accessToken,refreshToken,expiresAt}) |
| Refresh | OpenAI OAuth token endpoint, writes back to file | Anthropic OAuth token endpoint (https://console.anthropic.com/v1/oauth/token, Claude Code client_id), writes accessToken/refreshToken/expiresAt back to file |
| Exports | getCodexAccessToken, isCodexAuthAvailable |
getClaudeAccessToken, isClaudeAuthAvailable (export from packages/providers/src/index.ts) |
| Caller hook | resolveCredentialAsync: if profile.id==='codex-oauth' → getCodexAccessToken() |
add if profile.id==='claude-oauth' → getClaudeAccessToken() |
| Seed | seed.ts registers “OpenAI (Codex)” provider + codex-oauth auth profile |
register “Claude (subscription)” provider + claude-oauth auth profile, gated on isClaudeAuthAvailable() |
Claude provider seed config:
{
name: 'Claude (subscription)',
kind: 'llm',
endpoint: 'https://api.anthropic.com',
capabilities: ['text_generation', 'reasoning', 'analysis', 'summarize_text'],
config: {
default_model: 'claude-sonnet-4-6',
api_format: 'anthropic',
auth_header: { header: 'Authorization', value_template: 'Bearer {{credential}}' },
},
cost_per_call: 0, // subscription, not per-token billing
}
Note: the stored token is currently expired (expiresAt ≈ Apr 2026), so the
refresh path must work on the first call — it is not an edge case. Resolver
caches the access token in-process and refreshes on expiry (with a small buffer),
exactly like codex-auth-resolver.
B. Wire the workflow llm_call to Claude
Register an llm_call executor in apps/loom/src/lib/services.ts (which already
constructs authenticatedCaller) that routes through AuthenticatedLlmCaller
targeting the Claude provider. This replaces the default hardcoded Ollama
LlmCallExecutor for workflow LLM tasks via taskRunner.registerExecutor('llm_call', …).
The executor lives in the loom app wiring (not hypha) because hypha must not
depend on @cambium/providers (package boundary). It:
- Resolves the Claude provider +
claude-oauthprofile + fresh credential. - Builds the prompt from task input (
content/text/JSON of input), honoring an optionalprompt_templatewith{{input}}, matching the existingLlmCallExecutorcontract. - Calls
authenticatedCaller.call(provider, profile, credential, prompt, {model, …}). - Returns
{ output: { content }, metadata: { provider_used, tokens_used } }.
Two OAuth-specific requirements (load-bearing)
-
Headers. Subscription OAuth uses
Authorization: Bearer <token>+anthropic-version: 2023-06-01+anthropic-beta: oauth-2025-04-20— notx-api-key.buildAuthHeaders(and/or the anthropic branch ofAuthenticatedLlmCaller.call) must emit the version + oauth-beta headers for this provider. The provider’sauth_headertemplate supplies the Bearer line; the version + beta headers are added forapi_format === 'anthropic'. -
System prompt. OAuth tokens minted for Claude Code are rejected (401) unless the first system block is the Claude Code identity:
"You are Claude Code, Anthropic's official CLI for Claude."The anthropic branch ofAuthenticatedLlmCaller.callcurrently sends nosystem. It must inject the identity block as the first system block when the provider is the OAuth Claude provider; the actual task instruction stays in the user message. This is the primary implementation risk and will be validated with one live call before the rest is built.
Why claude-sonnet-4-6 specifically
The anthropic branch of AuthenticatedLlmCaller.call sends temperature.
claude-sonnet-4-6 accepts it; claude-opus-4-8/4-7 would return 400 on
temperature. The chosen model needs no change to the temperature handling.
Components & boundaries
packages/providers/src/claude-auth-resolver.ts— local-file token resolver + refresh. Pure, file-scoped, mirrors codex resolver. No deps on other packages.packages/providers/src/authenticated-caller.ts— extendresolveCredentialAsync(claude-oauth hook),buildAuthHeaders/anthropic branch (OAuth headers + identity system block).apps/loom/src/lib/seed.ts— seed Claude provider + auth profile when creds present.apps/loom/src/lib/services.ts— register Claude-backedllm_callexecutor.
Error handling
- Missing/unreadable
~/.claude/.credentials.json→isClaudeAuthAvailable()false → Claude provider not seeded; surfaced clearly (no silent Ollama fallback, per “Claude only”). - Refresh failure (revoked/invalid refresh token) → resolver throws; auth profile marked failed via existing
recordFailurecooldown logic. - 401 from missing identity system block → caught during the pre-build validation call; identity block is mandatory in the executor.
- API/network error → propagates as
ExecutorError(LLM call failed: …), same surface as today, so failures remain visible in the run’stask_executions.error_payload.
Testing
- Unit: resolver refresh (mock token endpoint + file I/O, expired token path);
buildAuthHeadersOAuth/anthropic case emits Bearer + version + oauth-beta. - Integration: one live
llm_callthrough the Claude provider returns a real completion (validates headers + identity system block end-to-end). - Verification: re-trigger the headlines intent and confirm
process_0succeeds andtask_executionsshowsprovider_used= Claude.
Out of scope
- Ollama fallback / multi-provider chains (explicitly declined).
- Changing other workflows or the cortex prompts.
- API-key auth (declined in favor of subscription OAuth).