Skip to content

AI chat dispatch to the Claude CLI

Source: atrium/backend/routes/ai.js · POST /api/ai/chat · atrium/backend/lib/aiChatSessions.js — session store + stream-json parser · atrium/backend/sockets/aiChat.js — room join/leave Category: Pattern — agent integration

AI chat dispatch to Claude CLI — integrate an AI assistant into your app without calling the LLM API directly. Spawn the local claude CLI binary with a task-aware prompt, capture its output, stream it back. Reuses the user’s existing Claude subscription, inherits whatever tools and MCP servers are configured locally, no API key in your codebase.

An Express route that receives a user message, builds a system prompt with relevant app context (current tasks, project details, user role), spawns claude "<prompt>" as a child process, and returns the response. A concurrency guard prevents two chat requests from spawning simultaneous CLI sessions. Timeouts prevent runaway processes.

The problem: Embedding AI assistance in a self-hosted app has three uncomfortable paths:

  1. Call the Anthropic API directly — requires you to manage an API key, bill usage separately, and replicate tool-use scaffolding you already have in Claude Desktop / Claude Code
  2. Bundle a local LLM — heavyweight, lower quality, different capability profile
  3. Require the user to paste context into a separate Claude window — defeats the “integrated” part

The fix: spawn claude from the backend. The user’s local subscription pays; whatever context you build is passed as the prompt argument; whatever response comes back is your answer. The CLI already handles streaming, authentication, rate limits, and tool use.

backend/routes/ai.js
const { spawn } = require('child_process');
let activeSession = null; // concurrency guard
router.post('/chat', requireAuth, async (req, res) => {
if (activeSession) {
return res.status(409).json({
error: 'A chat session is already active. Wait for it to finish.',
});
}
const { message, username } = req.body;
const prompt = buildPrompt(message, username, await getBoardContext());
activeSession = { startedAt: Date.now() };
const child = spawn('claude', [prompt], {
timeout: 2 * 60 * 1000, // 2-minute hard cap
cwd: process.env.WORKING_DIRECTORY || process.cwd(),
});
let stdout = '', stderr = '';
child.stdout.on('data', (d) => { stdout += d.toString(); });
child.stderr.on('data', (d) => { stderr += d.toString(); });
child.on('close', (code) => {
activeSession = null;
if (code === 0) res.json({ response: stdout });
else res.status(500).json({ error: stderr || 'Claude CLI failed' });
});
child.on('error', (err) => {
activeSession = null;
res.status(500).json({ error: err.message });
});
});
function buildPrompt(message, username, context) {
return [
`You are an AI assistant inside Atrium. You help users plan, create, and manage tasks.`,
`## Current User: ${username}`,
`## Board Overview`,
context.summary,
``,
`## User message:`,
message,
].join('\n');
}

Streaming + resumable sessions (the v2 shape)

Section titled “Streaming + resumable sessions (the v2 shape)”

The one-shot shape above holds the HTTP response open for the whole generation — fine for short answers, dead UI for long ones. The upgrade (T3 Chat-style) keeps all stream state server-side so the client is just a view that can detach and re-attach:

  1. Session store keyed by thread (task:<id> / user:<name>), each holding the accumulated text buffer plus the child process handle. One generation per thread; the map doubles as the concurrency guard.
  2. Spawn with --print --verbose --output-format stream-json --include-partial-messages and parse NDJSON off stdout: stream_event/text_delta events are token-level appends; assistant message events are per-turn full texts (dedupe with an endsWith check so CLIs without partial-message support still stream coarsely); the final result event replaces the buffer — it’s the canonical answer, intermediate turn text is narration.
  3. Relay to a Socket.IO room per thread (ai:task:<id>): chunk events carry {text} appends or {replace}, terminal done/error events close it out. POST /chat returns 202 {streaming: true} immediately.
  4. Resume = room join ack. Clients emit a join event on mount; the ack carries a snapshot of any in-flight session (accumulated buffer + the pending user message). A page refresh mid-generation renders the snapshot, then live chunks continue on top. No SSE reconnection logic, no lost responses.
  5. Stop endpoint marks the session cancelled and kills the process tree (taskkill /pid <pid> /T /F on Windows — with shell: true the pid you have is the shell, and killing only it orphans the real process). The close handler persists the partial answer with a cancelled marker.
  6. Idle watchdog, not a flat cap. Reset a kill timer on every stdout/stderr chunk instead of capping total runtime — long tool-using generations survive, hung processes still die.
// The parser/store are pure (no child_process, no socket.io) — unit-testable.
const parser = createStreamParser({
currentBuffer: () => sessions.get(key)?.buffer || '',
onDelta: (text) => { sessions.appendText(key, text); io.to(room).emit('ai_chat_chunk', { key, text }); },
onResult: (text) => { sessions.replaceText(key, text); io.to(room).emit('ai_chat_chunk', { key, replace: text }); },
});
claude.stdout.on('data', (d) => { parser.write(d.toString()); resetIdleTimer(); });

Client-side: keep the in-flight text in separate state from the message list and memoize the finished-message component — otherwise every token re-renders (and re-markdowns) the whole conversation.

  • AtriumPOST /api/ai/chat lets users talk to Claude with the task board as context; responses show up in the chat panel
  • Pattern generalizes to any self-hosted app where the user already has claude, gemini, or similar CLIs installed
  • Concurrent sessions are trouble. Two spawned Claude CLI processes in the same working directory can race on git operations, file writes, session state. Guard with a single in-memory flag or a filesystem lockfile. Reject requests that arrive during an active session rather than queueing.
  • Timeouts are mandatory. A runaway Claude session with an infinite loop tool call will block your single-concurrency chat forever. 2 minutes is a reasonable upper bound for chat responses.
  • Prompt size has real limits. Passing the entire task board as context quickly exceeds shell argument length limits. Summarize (counts, recent tasks, relevant IDs) rather than dumping everything.
  • Streaming is harder than one-shot — but worth it past ~10s responses. See the v2 shape above: the key insight is keeping the buffer server-side and treating socket clients as re-attachable views, which gets you refresh-survival for free.
  • The CLI’s working directory matters. cwd determines what files the AI can read via its tools. Set it to a scoped directory — not your backend’s root — if you want to limit the blast radius.
  • Stderr has useful context. Don’t just swallow it; forward it in the error path so failed chats are debuggable.
  • Respect user intent on tools. Claude might decide to git push as part of answering a question. Either configure the CLI’s allowed tools for this use case (safer) or be aware your backend is handing the AI an arbitrary shell.
  • API-key alternative. If the person running your backend doesn’t have claude installed, fall back gracefully: detect the binary on startup, hide the chat UI if missing, show an instructional message.