| name | configure-chat-api |
| description | Configure the LLM API behind the AgentPlanner website chat interface (src/domains/chat). Makes the chat provider-selectable via a CHAT_PROVIDER env switch — OpenAI (gpt-4o) or Anthropic Claude (claude-opus-4-8) — behind one adapter interface, preserving the existing SSE events, tool loop, destructive-confirm flow, and MCP nudge. Use when asked to wire up / swap / add the chat API or LLM provider for the AgentPlanner chat, add Claude/Anthropic support to the chat, or make the chatbot provider-agnostic. |
Configure the AgentPlanner chat API (provider-selectable)
The website chat backend lives in src/domains/chat/services/chatAgent.service.js. Today it is hardwired to OpenAI (openai SDK, gpt-4o). This skill makes the LLM provider selectable at runtime via CHAT_PROVIDER, with two adapters behind one interface:
openai → OpenAI chat.completions (the existing behavior, extracted verbatim)
anthropic → Anthropic Messages API + tool use (@anthropic-ai/sdk, default claude-opus-4-8)
Everything provider-agnostic stays put: the systemPrompt (safety rules + MCP nudge), the tool registry (tools.js), the destructive-confirm queue, the empty-confirm-message fix, and every SSE event the frontend consumes (conversation, token, tool_call, tool_result, confirm_required, message, done). Only the "talk to the model + shuttle tool calls" step is abstracted.
This skill is authoritative for the Anthropic surface. It was authored against the claude-api skill reference. Model ID is claude-opus-4-8 — do not append a date suffix. max_tokens is required. temperature/top_p/top_k and budget_tokens are rejected on Opus 4.8 (400) — don't add them. If you need a binding not shown here, consult the claude-api skill rather than guessing.
The adapter interface
Both providers implement the same shape so streamTurn never branches on provider:
initConversation(system) -> conv // provider-native container for system + messages
pushHistory(conv, role, content) // append a prior user/assistant turn (plain text)
streamStep(conv, tools, onToken) -> step // stream ONE assistant turn; onToken(delta) per text chunk
// step = { text, toolCalls:[{id,name,args}], assistant }
recordAssistantStep(conv, step) // append the assistant turn (text + tool calls) to history
recordToolResults(conv, results) // append results for the step; results:[{id, content, isError?}]
isConfigured() -> bool // active provider has its API key
id // 'openai' | 'anthropic'
tools passed to streamStep is the normalized list TOOLS.map(t => ({ name, description, parameters: t.parameters })); each provider formats it natively (OpenAI function wrapper vs Anthropic input_schema).
The one meaningful divergence the interface hides: Anthropic requires all tool results for a step in a single user message, while OpenAI wants one {role:'tool'} message per call. recordToolResults takes the whole batch, so the loop calls it once per step and each provider does the right thing.
Files this skill provides (drop-ins)
Reference implementations live in reference/ next to this file:
reference/provider.js — factory: reads CHAT_PROVIDER, returns the adapter
reference/openaiProvider.js — OpenAI adapter (existing behavior, extracted)
reference/anthropicProvider.js — Anthropic Messages API + tool-use adapter
They are written as CommonJS .js to match the chat domain. Intended install location: src/domains/chat/services/providers/. From there, require('../chat.service') resolves correctly (same as chatAgent.service.js's require('./chat.service')).
Applying it (do these steps only when asked to apply)
-
Install the Anthropic SDK (OpenAI is already a dependency):
npm install @anthropic-ai/sdk
-
Copy the adapters into src/domains/chat/services/providers/:
provider.js, openaiProvider.js, anthropicProvider.js.
-
Rewire chatAgent.service.js. Replace the direct OpenAI client + inline streaming/tool-shuttling with the provider. The orchestration, destructive-confirm, and persistence stay identical. The target loop:
const { getProvider } = require('./providers/provider');
function isConfigured() { return getProvider().isConfigured(); }
async function streamTurn(conversation, userText, user, authHeader, sse) {
const provider = getProvider();
const existing = await dal.chatMessagesDal.listByConversation(conversation.id);
await dal.chatMessagesDal.create({ conversationId: conversation.id, role: 'user', content: userText });
if (existing.length === 0) { }
if (!provider.isConfigured()) throw new ChatServiceError(`AI chat is not configured for provider "${provider.id}".`, 503, 'not_configured');
const history = (await dal.chatMessagesDal.listByConversation(conversation.id)).slice(-HISTORY_LIMIT);
const conv = provider.initConversation(systemPrompt(user));
for (const m of history) provider.pushHistory(conv, m.role, m.content);
const normalizedTools = TOOLS.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters }));
let assistantText = '';
const toolEvents = [];
const pendingActions = [];
for (let step = 0; step < MAX_STEPS; step++) {
const s = await provider.streamStep(conv, normalizedTools, (delta) => {
assistantText += delta;
sse.send('token', { delta });
});
if (s.toolCalls.length === 0) break;
provider.recordAssistantStep(conv, s);
const results = [];
let pausedForConfirm = false;
for (const c of s.toolCalls) {
const tool = TOOL_MAP[c.name];
const args = c.args || {};
if (!tool) {
results.push({ id: c.id, content: JSON.stringify({ error: `Unknown tool ${c.name}` }), isError: true });
continue;
}
if (tool.destructive) {
const actionId = randomUUID();
const action = { id: actionId, name: c.name, args, status: 'pending', description: tool.describe ? tool.describe(args) : `Run ${c.name}` };
pendingActions.push(action);
toolEvents.push({ type: 'confirm', name: c.name, status: 'pending', actionId, description: action.description });
sse.send('confirm_required', action);
results.push({ id: c.id, content: JSON.stringify({ status: 'queued_for_user_confirmation', actionId }) });
pausedForConfirm = true;
continue;
}
sse.send('tool_call', { name: c.name, args });
const result = await runTool(tool, args, user, authHeader);
const ev = { type: 'tool', name: c.name, status: result.ok ? 'ok' : 'error', summary: result.ok ? result.summary : result.error };
toolEvents.push(ev);
sse.send('tool_result', ev);
results.push({
id: c.id,
content: JSON.stringify(result.ok ? { ok: true, data: result.data } : { ok: false, error: result.error }).slice(0, TOOL_RESULT_CHAR_CAP),
isError: !result.ok,
});
}
provider.recordToolResults(conv, results);
if (pausedForConfirm) break;
}
}
Notes:
- Delete the now-unused
openAiToolDefs import and the module-level MODEL/_client/client() — those move into the providers. Keep randomUUID, dal, chatService, TOOL_MAP, runTool, systemPrompt, and the constants.
confirmAction(...) is unchanged — it calls runTool directly and never touches the model.
recordAssistantStep is only called when s.toolCalls.length > 0 (the OpenAI adapter would otherwise emit an empty tool_calls array, which the API rejects).
-
Environment. Add to .env / .env.example:
CHAT_PROVIDER=openai
OPENAI_API_KEY=...
OPENAI_CHAT_MODEL=gpt-4o
ANTHROPIC_API_KEY=...
ANTHROPIC_CHAT_MODEL=claude-opus-4-8
CHAT_MAX_TOKENS=4096
CHAT_MODEL still works as a shared override for whichever provider is active.
-
Verify. Run node .claude/skills/configure-chat-api/scripts/check-setup.mjs to confirm the selected provider's key + SDK are present. Then smoke-test a real turn (register a throwaway user, POST to /conversations/:id/messages) under each provider by flipping CHAT_PROVIDER.
What NOT to change
systemPrompt(user) — the safety rules and MCP nudge are provider-agnostic and apply to both.
tools.js — the tool registry, destructive flags, and get_connector_setup behavior are unchanged.
- The SSE event names/shapes — the frontend contract must stay byte-for-byte the same.
- The destructive-confirm queue and the empty-confirm-message synthesis.
Gotchas (Anthropic path)
max_tokens is mandatory — the adapter sends CHAT_MAX_TOKENS (default 4096). Omitting it is a 400.
- Echo tool_use blocks back unchanged.
recordAssistantStep pushes the raw content blocks from finalMessage(); don't reconstruct them.
- One user message per step for tool results, each block
{type:'tool_result', tool_use_id, content, is_error?} — the adapter handles this; keep the loop calling recordToolResults once per step.
- Stream text only from
text_delta. If you later enable adaptive thinking (thinking:{type:'adaptive'}), the loop already ignores thinking_delta, so reasoning won't leak into the user stream — but thinking adds latency; leave it off for a snappy chat unless asked.
- Model ID is exactly
claude-opus-4-8. No date suffix. temperature/top_p/budget_tokens are 400s on Opus 4.8 — the adapter deliberately omits them.