Claude Subscription LLM Provider — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Route Cambium’s workflow llm_call tasks to the user’s Claude subscription (Claude Code OAuth creds), replacing the hardcoded Ollama executor that caused run 58f786ff to fail.
Architecture: Mirror the existing Codex/ChatGPT subscription integration: a local-file token resolver (claude-auth-resolver.ts) with refresh, a credential hook + OAuth headers + identity system prompt in AuthenticatedLlmCaller, a seed step that registers a “Claude (subscription)” provider when creds are present, and a new llm_call executor (registered in services.ts) that routes workflow LLM tasks through the caller.
Tech Stack: TypeScript (strict, ESM, .js import suffixes), pnpm workspaces, Vitest, the Anthropic Messages API (/v1/messages) via subscription OAuth.
Spec: docs/superpowers/specs/2026-06-15-claude-subscription-llm-provider-design.md
Branch: feat/claude-subscription-llm (already created).
Reference facts (verified against the codebase)
- Claude creds file:
~/.claude/.credentials.json, shape:json { "claudeAiOauth": { "accessToken": "...", "refreshToken": "...", "expiresAt": 1776447303554, "subscriptionType": "max", "rateLimitTier": "..." } }expiresAtis epoch milliseconds (not a JWT). The stored token is currently expired, so refresh must work on first call. - Claude Code OAuth refresh:
POST https://console.anthropic.com/v1/oauth/token, JSON body{ grant_type: "refresh_token", refresh_token, client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e" }, response{ access_token, refresh_token, expires_in }(expires_inin seconds). (Verify live in Task 6 — the expired token exercises this path.) - Subscription OAuth calls to
/v1/messagesrequire:Authorization: Bearer <token>,anthropic-version: 2023-06-01,anthropic-beta: oauth-2025-04-20, and a first system block of exactly"You are Claude Code, Anthropic's official CLI for Claude."Otherwise → 401. AuthenticatedLlmCaller.call(provider, profile, credential, prompt, opts)already has ananthropicbranch hitting${endpoint}/v1/messages; it sendstemperature(fine forclaude-sonnet-4-6) and nosystemtoday.taskRunner.registerExecutor(type, executor)exists;services.tsregistersfile_outputthis way (~line 422).llm_callis NOT overridden today, so the hardcoded OllamaLlmCallExecutorruns.
Task 1: Claude auth resolver
Files:
- Create: packages/providers/src/claude-auth-resolver.ts
- Create: packages/providers/src/claude-auth-resolver.test.ts
- Modify: packages/providers/src/index.ts (add exports)
- [ ] Step 1: Write the failing test
Create packages/providers/src/claude-auth-resolver.test.ts:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const readFileMock = vi.fn();
const writeFileMock = vi.fn();
vi.mock('node:fs/promises', () => ({
readFile: (...a: unknown[]) => readFileMock(...a),
writeFile: (...a: unknown[]) => writeFileMock(...a),
}));
import {
getClaudeAccessToken,
isClaudeAuthAvailable,
__resetClaudeAuthCache,
} from './claude-auth-resolver.js';
const FUTURE = Date.now() + 60 * 60_000;
const PAST = Date.now() - 60_000;
function credsFile(expiresAt: number, accessToken = 'tok-current') {
return JSON.stringify({
claudeAiOauth: {
accessToken,
refreshToken: 'refresh-abc',
expiresAt,
subscriptionType: 'max',
rateLimitTier: 'default',
},
});
}
describe('claude-auth-resolver', () => {
beforeEach(() => {
__resetClaudeAuthCache();
readFileMock.mockReset();
writeFileMock.mockReset();
vi.restoreAllMocks();
});
afterEach(() => vi.restoreAllMocks());
it('returns the stored token when not expired', async () => {
readFileMock.mockResolvedValue(credsFile(FUTURE));
const token = await getClaudeAccessToken();
expect(token).toBe('tok-current');
});
it('refreshes when expired and writes the file back', async () => {
readFileMock.mockResolvedValue(credsFile(PAST));
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ access_token: 'tok-new', refresh_token: 'refresh-new', expires_in: 3600 }),
{ status: 200 },
),
);
const token = await getClaudeAccessToken();
expect(token).toBe('tok-new');
expect(fetchMock).toHaveBeenCalledWith(
'https://console.anthropic.com/v1/oauth/token',
expect.objectContaining({ method: 'POST' }),
);
expect(writeFileMock).toHaveBeenCalledTimes(1);
const written = JSON.parse(writeFileMock.mock.calls[0][1] as string);
expect(written.claudeAiOauth.accessToken).toBe('tok-new');
expect(written.claudeAiOauth.refreshToken).toBe('refresh-new');
expect(written.claudeAiOauth.subscriptionType).toBe('max'); // preserved
});
it('returns null when the creds file is missing', async () => {
readFileMock.mockRejectedValue(new Error('ENOENT'));
expect(await getClaudeAccessToken()).toBeNull();
expect(await isClaudeAuthAvailable()).toBe(false);
});
it('isClaudeAuthAvailable is true when a token exists', async () => {
readFileMock.mockResolvedValue(credsFile(FUTURE));
expect(await isClaudeAuthAvailable()).toBe(true);
});
});
- [ ] Step 2: Run the test to verify it fails
Run: cd packages/providers && pnpm vitest run src/claude-auth-resolver.test.ts
Expected: FAIL — Cannot find module './claude-auth-resolver.js'.
- [ ] Step 3: Write the resolver
Create packages/providers/src/claude-auth-resolver.ts:
/**
* Resolves Anthropic credentials from ~/.claude/.credentials.json.
*
* Reads the Claude Code CLI's OAuth tokens (which use your Claude
* subscription) and provides them as Bearer tokens for the Anthropic
* Messages API. Handles automatic token refresh via Anthropic's OAuth
* endpoint and writes refreshed tokens back so Claude Code stays in sync.
*/
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
export interface ClaudeOAuth {
accessToken: string;
refreshToken: string;
expiresAt: number; // epoch milliseconds
subscriptionType?: string;
rateLimitTier?: string;
}
export interface ClaudeCredentialsFile {
claudeAiOauth: ClaudeOAuth;
}
const CLAUDE_CREDS_PATH = join(homedir(), '.claude', '.credentials.json');
const TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
// Claude Code's registered public OAuth client ID
const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
/** In-process cache: token + epoch-ms expiry, avoids re-reading the file. */
let cachedToken: string | null = null;
let cachedExpiry: number | null = null;
/** Test-only: clear the in-process cache. */
export function __resetClaudeAuthCache(): void {
cachedToken = null;
cachedExpiry = null;
}
async function readClaudeCreds(): Promise<ClaudeCredentialsFile | null> {
try {
const raw = await readFile(CLAUDE_CREDS_PATH, 'utf-8');
return JSON.parse(raw) as ClaudeCredentialsFile;
} catch {
return null;
}
}
async function refreshAccessToken(creds: ClaudeCredentialsFile): Promise<string> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: creds.claudeAiOauth.refreshToken,
client_id: CLAUDE_CLIENT_ID,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Claude token refresh failed (${response.status}): ${text}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token?: string;
expires_in?: number;
};
const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000;
const updated: ClaudeCredentialsFile = {
claudeAiOauth: {
...creds.claudeAiOauth,
accessToken: data.access_token,
refreshToken: data.refresh_token ?? creds.claudeAiOauth.refreshToken,
expiresAt,
},
};
await writeFile(CLAUDE_CREDS_PATH, JSON.stringify(updated, null, 2), 'utf-8');
cachedToken = data.access_token;
cachedExpiry = expiresAt;
return data.access_token;
}
/**
* Get a valid Anthropic access token from the Claude Code creds file.
* Returns null if Claude Code isn't authenticated or the file is missing.
*/
export async function getClaudeAccessToken(): Promise<string | null> {
const bufferMs = 5 * 60_000;
if (cachedToken && cachedExpiry && Date.now() < cachedExpiry - bufferMs) {
return cachedToken;
}
const creds = await readClaudeCreds();
if (!creds?.claudeAiOauth?.accessToken) return null;
if (Date.now() < creds.claudeAiOauth.expiresAt - bufferMs) {
cachedToken = creds.claudeAiOauth.accessToken;
cachedExpiry = creds.claudeAiOauth.expiresAt;
return cachedToken;
}
if (!creds.claudeAiOauth.refreshToken) return null;
return refreshAccessToken(creds);
}
/** Check if Claude Code authentication is available. */
export async function isClaudeAuthAvailable(): Promise<boolean> {
const creds = await readClaudeCreds();
return !!creds?.claudeAiOauth?.accessToken;
}
- [ ] Step 4: Add exports to the package index
In packages/providers/src/index.ts, directly after the existing Codex export block (the } from './codex-auth-resolver.js'; lines), add:
export {
getClaudeAccessToken,
isClaudeAuthAvailable,
type ClaudeOAuth,
type ClaudeCredentialsFile,
} from './claude-auth-resolver.js';
- [ ] Step 5: Run tests to verify they pass
Run: cd packages/providers && pnpm vitest run src/claude-auth-resolver.test.ts
Expected: PASS (4 tests).
- [ ] Step 6: Commit
git add packages/providers/src/claude-auth-resolver.ts packages/providers/src/claude-auth-resolver.test.ts packages/providers/src/index.ts
git commit -m "feat(providers): add Claude Code subscription auth resolver"
Task 2: OAuth/anthropic headers in buildAuthHeaders
buildAuthHeaders currently, for a provider named “Claude (subscription)”, would hit the name-based inference and set x-api-key — wrong for OAuth. The provider we seed uses an explicit auth_header (Bearer) config, so it returns early before that inference. We extend the early-return branch to also emit the anthropic version + oauth-beta headers.
Files:
- Modify: packages/providers/src/authenticated-caller.ts (the buildAuthHeaders function, ~lines 46-72)
- Test: packages/providers/src/authenticated-caller.test.ts (add cases)
- [ ] Step 1: Write the failing test
Add to packages/providers/src/authenticated-caller.test.ts (import buildAuthHeaders if not already imported):
import { buildAuthHeaders } from './authenticated-caller.js';
function claudeProvider(extra: Record<string, unknown> = {}) {
return {
id: 'p1', workspace_id: 'w1', name: 'Claude (subscription)', kind: 'llm',
endpoint: 'https://api.anthropic.com', status: 'active',
auth: { type: 'api_key' },
config: {
api_format: 'anthropic',
anthropic_oauth: true,
auth_header: { header: 'Authorization', value_template: 'Bearer {{credential}}' },
...extra,
},
} as unknown as Parameters<typeof buildAuthHeaders>[0];
}
describe('buildAuthHeaders — Claude OAuth', () => {
it('emits Bearer + anthropic-version + oauth beta header', () => {
const h = buildAuthHeaders(claudeProvider(), 'tok-xyz');
expect(h['Authorization']).toBe('Bearer tok-xyz');
expect(h['anthropic-version']).toBe('2023-06-01');
expect(h['anthropic-beta']).toBe('oauth-2025-04-20');
expect(h['x-api-key']).toBeUndefined();
});
it('omits the oauth beta header when anthropic_oauth is not set', () => {
const h = buildAuthHeaders(claudeProvider({ anthropic_oauth: false }), 'tok-xyz');
expect(h['anthropic-version']).toBe('2023-06-01');
expect(h['anthropic-beta']).toBeUndefined();
});
});
- [ ] Step 2: Run the test to verify it fails
Run: cd packages/providers && pnpm vitest run src/authenticated-caller.test.ts -t "Claude OAuth"
Expected: FAIL — anthropic-version / anthropic-beta are undefined.
- [ ] Step 3: Implement
In packages/providers/src/authenticated-caller.ts, in buildAuthHeaders, replace the early-return block:
if (authConfig && authConfig.header) {
headers[authConfig.header] = authConfig.value_template.replace('{{credential}}', credential);
return headers;
}
with:
if (authConfig && authConfig.header) {
headers[authConfig.header] = authConfig.value_template.replace('{{credential}}', credential);
if (provider.config['api_format'] === 'anthropic') {
headers['anthropic-version'] = '2023-06-01';
if (provider.config['anthropic_oauth'] === true) {
headers['anthropic-beta'] = 'oauth-2025-04-20';
}
}
return headers;
}
- [ ] Step 4: Run tests to verify they pass
Run: cd packages/providers && pnpm vitest run src/authenticated-caller.test.ts
Expected: PASS (new cases + existing).
- [ ] Step 5: Commit
git add packages/providers/src/authenticated-caller.ts packages/providers/src/authenticated-caller.test.ts
git commit -m "feat(providers): emit anthropic-version + oauth beta headers for OAuth Claude provider"
Task 3: Credential hook + identity system prompt in AuthenticatedLlmCaller
Two changes in authenticated-caller.ts:
1. resolveCredentialAsync resolves a fresh Claude token for the claude-oauth profile.
2. The anthropic branch of call() injects the mandatory Claude Code identity system block when the provider is OAuth.
Files:
- Modify: packages/providers/src/authenticated-caller.ts (resolveCredentialAsync ~line 163; call() anthropic branch ~line 376)
- Modify: imports at top (add getClaudeAccessToken)
- Test: packages/providers/src/authenticated-caller.test.ts (add a call() body assertion)
- [ ] Step 1: Write the failing test
Add to packages/providers/src/authenticated-caller.test.ts. This drives a call() against a stubbed fetch and asserts the request body carries the identity system block:
import { AuthenticatedLlmCaller } from './authenticated-caller.js';
import { InMemoryProviderStore, ProviderRegistry } from './provider-registry.js';
import { InMemoryAuthProfileStore } from './auth-profile-store.js';
describe('AuthenticatedLlmCaller.call — Claude OAuth body', () => {
it('sends the Claude Code identity as the first system block', async () => {
const providerRegistry = new ProviderRegistry({ store: new InMemoryProviderStore() });
const authProfileStore = new InMemoryAuthProfileStore();
const caller = new AuthenticatedLlmCaller({ providerRegistry, authProfileStore });
const provider = {
id: 'p-claude', workspace_id: 'w1', name: 'Claude (subscription)', kind: 'llm',
endpoint: 'https://api.anthropic.com', status: 'active',
auth: { type: 'api_key' },
config: {
api_format: 'anthropic', anthropic_oauth: true, default_model: 'claude-sonnet-4-6',
auth_header: { header: 'Authorization', value_template: 'Bearer {{credential}}' },
},
} as never;
const profile = { id: 'claude-oauth', provider_id: 'p-claude', mode: 'token', status: 'active' } as never;
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 1, output_tokens: 1 } }),
{ status: 200 },
),
);
const res = await caller.call(provider, profile, 'tok-xyz', 'Summarize this.', { model: 'claude-sonnet-4-6' });
expect(res.content).toBe('ok');
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
expect(body.system).toBe("You are Claude Code, Anthropic's official CLI for Claude.");
expect(body.messages[0]).toEqual({ role: 'user', content: 'Summarize this.' });
});
});
- [ ] Step 2: Run the test to verify it fails
Run: cd packages/providers && pnpm vitest run src/authenticated-caller.test.ts -t "Claude OAuth body"
Expected: FAIL — body.system is undefined.
- [ ] Step 3: Implement the credential hook
At the top of packages/providers/src/authenticated-caller.ts, alongside the existing import { getCodexAccessToken } from './codex-auth-resolver.js';, add:
import { getClaudeAccessToken } from './claude-auth-resolver.js';
In resolveCredentialAsync, directly after the Codex if block, add:
// Claude subscription auth: get a fresh token (handles auto-refresh)
if (profile.id === 'claude-oauth' || profile.name === 'Claude (subscription)') {
const token = await getClaudeAccessToken();
if (token) return token;
}
- [ ] Step 4: Implement the identity system block
In call(), inside the if (apiFormat === 'anthropic') { branch, replace the body: JSON.stringify({ ... }) object so it conditionally includes system:
body: JSON.stringify({
model,
max_tokens: maxTokens,
...(provider.config['anthropic_oauth'] === true
? { system: "You are Claude Code, Anthropic's official CLI for Claude." }
: {}),
messages: [{ role: 'user', content: prompt }],
temperature,
}),
- [ ] Step 5: Run tests to verify they pass
Run: cd packages/providers && pnpm vitest run src/authenticated-caller.test.ts
Expected: PASS.
- [ ] Step 6: Commit
git add packages/providers/src/authenticated-caller.ts packages/providers/src/authenticated-caller.test.ts
git commit -m "feat(providers): resolve Claude OAuth token + inject Claude Code identity system prompt"
Task 4: Seed the Claude provider + auth profile
Mirror the Codex seed block so the provider and claude-oauth profile exist whenever Claude Code creds are present.
Files:
- Modify: apps/loom/src/lib/seed.ts (import line ~19; add a block after the Codex block, before the existing } that closes provider seeding)
- [ ] Step 1: Add the import
In apps/loom/src/lib/seed.ts, change the providers import to include the Claude helpers:
import {
getCodexAccessToken,
isCodexAuthAvailable,
getClaudeAccessToken,
isClaudeAuthAvailable,
} from '@cambium/providers';
- [ ] Step 2: Add the seed block
In seed.ts, immediately after the Codex try { ... } catch { ... } block that registers “OpenAI (Codex)”, add a parallel block (uses the same workspaceId / ws locals already in scope in that function):
// --- Auto-register Claude via subscription auth if available ---
try {
const claudeAvailable = await isClaudeAuthAvailable();
if (claudeAvailable) {
const accessToken = await getClaudeAccessToken();
if (accessToken) {
const claudeProvider = await services.providerRegistry.register({
workspace_id: workspaceId,
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',
anthropic_oauth: true,
auth_header: { header: 'Authorization', value_template: 'Bearer {{credential}}' },
},
cost_per_call: 0, // subscription, not per-token billing
});
const now = new Date().toISOString();
const profile: AuthProfile = {
id: createId<AuthProfileId>('claude-oauth'),
provider_id: claudeProvider.id as ProviderId,
workspace_id: ws,
name: 'Claude (subscription)',
mode: 'token',
credential: accessToken,
status: 'active',
usage_stats: { total_requests: 0, error_count: 0, consecutive_failures: 0 },
is_last_good: true,
created_at: now,
updated_at: now,
};
await services.authProfileStore.save(profile);
await services.providerRegistry.update(claudeProvider.id, {
auth: { type: 'api_key', credentials: { profile_id: profile.id } },
});
}
}
} catch (err) {
console.warn('[seed] Claude subscription provider not registered:', err);
}
Note: match the exact
catch/logging style of the adjacent Codex block if it differs — keep them consistent.
- [ ] Step 3: Typecheck
Run: cd apps/loom && pnpm typecheck (or pnpm turbo typecheck --filter @cambium/loom)
Expected: no type errors.
- [ ] Step 4: Commit
git add apps/loom/src/lib/seed.ts
git commit -m "feat(loom): seed Claude subscription provider + auth profile when creds present"
Task 5: Claude-backed llm_call executor (replaces Ollama default)
Register an llm_call executor in services.ts that routes through AuthenticatedLlmCaller targeting the Claude provider. It reproduces the prompt-building contract of the existing LlmCallExecutor (reads content/text/JSON of input, honors prompt_template with {{input}}).
Files:
- Modify: apps/loom/src/lib/services.ts (add executor class + register after the file_output registration, ~line 427)
- [ ] Step 1: Add the executor + registration
In apps/loom/src/lib/services.ts, immediately after the taskRunner.registerExecutor('file_output', …) block, add:
// Register llm_call executor backed by the Claude subscription provider.
// Replaces the default hardcoded Ollama executor for all workflow LLM tasks.
taskRunner.registerExecutor('llm_call', {
async execute(ctx) {
const config = ctx.strategy_config;
const inputText =
(ctx.input['content'] as string | undefined) ??
(ctx.input['text'] as string | undefined) ??
JSON.stringify(ctx.input);
const promptTemplate = config['prompt_template'] as string | undefined;
const prompt = promptTemplate ? promptTemplate.replace('{{input}}', inputText) : inputText;
const providers = await providerRegistry.list(DEFAULT_WORKSPACE_ID);
const provider = providers.find((p) => p.name === 'Claude (subscription)');
if (!provider) {
throw new Error('llm_call: Claude (subscription) provider is not registered — is Claude Code authenticated?');
}
const profile = await authProfileStore.findLastGood(provider.id);
if (!profile) {
throw new Error('llm_call: no auth profile for the Claude provider');
}
const credential = await getClaudeAccessToken();
if (!credential) {
throw new Error('llm_call: could not resolve a Claude access token');
}
const model =
(config['model'] as string | undefined) ??
(provider.config['default_model'] as string | undefined) ??
'claude-sonnet-4-6';
const start = Date.now();
const result = await authenticatedCaller.call(provider, profile, credential, prompt, {
model,
maxTokens: (config['max_tokens'] as number | undefined) ?? 4096,
temperature: (config['temperature'] as number | undefined) ?? 0.3,
budgetScopes: { workspaceId: DEFAULT_WORKSPACE_ID, workflowId: ctx.run.workflow_definition_id },
});
return {
output: { content: result.content },
metadata: {
duration_ms: Date.now() - start,
tokens_used: result.tokens_used,
provider_used: provider.name,
},
};
},
});
- [ ] Step 2: Add the import
Ensure getClaudeAccessToken is imported in services.ts. Add it to the existing @cambium/providers import group near the top (where AuthenticatedLlmCaller is imported):
getClaudeAccessToken,
(Confirm DEFAULT_WORKSPACE_ID, providerRegistry, authProfileStore, and authenticatedCaller are all in scope at the registration site — they are defined earlier in createServices. ctx is typed via the TaskExecutor interface from @cambium/hypha.)
- [ ] Step 3: Typecheck the whole workspace
Run: pnpm turbo typecheck
Expected: no type errors. (If ctx needs an explicit type, import TaskContext/TaskResult from @cambium/hypha and annotate execute(ctx: TaskContext): Promise<TaskResult>.)
- [ ] Step 4: Commit
git add apps/loom/src/lib/services.ts
git commit -m "feat(loom): route workflow llm_call through Claude subscription, replacing Ollama default"
Task 6: Live validation + re-run the intent
This is the integration gate. It exercises the real refresh path (the stored token is expired) and the real OAuth/identity requirements end-to-end.
Files:
- Create: apps/loom/scripts/smoke-claude-llm.ts (throwaway smoke script; delete after if you prefer)
- [ ] Step 1: Write a one-shot smoke script
Create apps/loom/scripts/smoke-claude-llm.ts:
import { getClaudeAccessToken } from '@cambium/providers';
import { AuthenticatedLlmCaller } from '@cambium/providers';
import { InMemoryProviderStore, ProviderRegistry, InMemoryAuthProfileStore } from '@cambium/providers';
async function main() {
const token = await getClaudeAccessToken();
if (!token) throw new Error('No Claude token — run `claude` to authenticate first.');
const providerRegistry = new ProviderRegistry({ store: new InMemoryProviderStore() });
const authProfileStore = new InMemoryAuthProfileStore();
const caller = new AuthenticatedLlmCaller({ providerRegistry, authProfileStore });
const provider = {
id: 'p', workspace_id: 'w', name: 'Claude (subscription)', kind: 'llm',
endpoint: 'https://api.anthropic.com', status: 'active', auth: { type: 'api_key' },
config: {
api_format: 'anthropic', anthropic_oauth: true, default_model: 'claude-sonnet-4-6',
auth_header: { header: 'Authorization', value_template: 'Bearer {{credential}}' },
},
} as never;
const profile = { id: 'claude-oauth', provider_id: 'p', mode: 'token', status: 'active' } as never;
const res = await caller.call(provider, profile, token, 'Reply with exactly: SMOKE_OK', { model: 'claude-sonnet-4-6', maxTokens: 32 });
console.log('RESULT:', res.content);
}
main().catch((e) => { console.error(e); process.exit(1); });
- [ ] Step 2: Run it
Run: cd apps/loom && pnpm exec tsx scripts/smoke-claude-llm.ts
Expected: prints RESULT: SMOKE_OK (or close).
If it 401s: the identity system block or the anthropic-beta: oauth-2025-04-20 header is the likely cause — verify Task 2 and Task 3 changes are present and that the refresh in Task 1 returned a fresh token. This is the known primary risk; do not proceed until this prints a real completion.
- [ ] Step 3: Re-trigger the headlines workflow end-to-end
Start Loom (pnpm turbo dev from repo root, or the Obadiah daemon if that’s where the intent runs), then re-run the “Gather AI headlines” intent (via the Loom UI, or re-submit the intent text). After it completes, verify in Postgres:
PGPASSWORD=cambium_dev psql -h localhost -U cambium -d cambium -x -c \
"select node_id, status, output_payload->>'content' is not null as has_output \
from task_executions \
where workflow_run_id = (select id from workflow_runs order by created_at desc limit 1) \
and node_id like 'process%';"
Expected: process_0 status succeeded, has_output = t.
- [ ] Step 4: Clean up + commit
rm apps/loom/scripts/smoke-claude-llm.ts
git add -A
git commit -m "test: validate Claude subscription llm_call end-to-end (smoke + intent re-run)"
Definition of done
pnpm turbo typecheckandpnpm turbo testpass.- A live
llm_callreturns a real Claude completion via subscription OAuth (Task 6, Step 2). - Re-running the headlines intent produces a
succeededprocess_0with non-null output (Task 6, Step 3). - Ollama is no longer required for workflow LLM tasks.
Outcome (2026-06-15)
Implemented across 7 commits (cf77df8..577887b) on feat/claude-subscription-llm. All unit/integration tests pass (157 in @cambium/providers; loom suites unaffected). Final holistic review: READY TO MERGE.
Deviations from the plan, discovered during Task 6 validation:
- Keychain, not the file. On macOS the live Claude Code creds are in the login Keychain (security, service Claude Code-credentials); ~/.claude/.credentials.json is stale (dead refresh token). The resolver was extended (commit 577887b) to read the Keychain first on darwin, fall back to the file elsewhere, and write refreshes back to the source it read from. The plan’s smoke test initially failed with invalid_grant against the file — this is how the Keychain dependency was found.
- Incidental fix. Commit 968bc39 isolates an unrelated lifecycle bug (sense/evaluate were each invoked twice per cycle) that was accidentally bundled into Task 5; split into its own commit and reviewed as correct/safe.
Validation status:
- ✅ Live end-to-end LLM call proven: a real claude-sonnet-4-6 completion via the subscription, through the exact AuthenticatedLlmCaller.call() + seeded-provider-config path the executor uses (OAuth headers + Claude Code identity prompt accepted, no 401).
- ⏳ Full intent re-run in the DB pending a Loom dev-server restart — the running instance held a stale in-memory getServices() singleton (pre-change code) and wedged at the fetch stage. Restart Loom, then POST /api/runs {workflow_definition_id:"03d5c760-..."} and confirm process_0 → succeeded.
Known limitation: only Loom-driven workflow runs use the Claude executor. The Obadiah daemon (deployments/obadiah/bridge/start-rhythms.ts) builds its own Ollama callers and is unaffected — a separate follow-up if the daemon’s LLM calls should also move to Claude.