A complete pattern for building apps where the AI reasoning layer lives outside the application shell — a Node.js server that drives the Claude CLI as a subprocess, streams responses over NDJSON, and exposes a chat-first frontend. No WebSockets. No React. No build step.
Every interface is chat-first. The chat pane lives on the RIGHT side and is the primary surface — all AI messages, data, and replies appear there. The left side holds domain content, controls, and navigation.
Every app starts in streaming mode (stream-json) with --verbose and --include-partial-messages enabled. Never use json as the default output format. Users see the chat composer immediately on startup.
Build the backend first, then the frontend. Order: (1) implement all Express routes and Claude runner logic, (2) verify each route via curl, (3) only then build the frontend UI. The frontend never defines business logic.
All Claude sessions run inside ~/.appname/, a dedicated working folder created on startup. All user data, sessions, and processed files live there. Symlinks connect it to the app's skills and agent.md so the AI auto-discovers instructions.
The server is a thin wrapper. It manages sessions (JSON files on disk), spawns Claude per message, and pipes stdout NDJSON events straight to the browser via chunked HTTP. express is the only dependency.
Three layers. Each layer has a single responsibility. The server is deliberately thin — it spawns, pipes, and persists. Claude does all the thinking.
Both platforms share the same core idea — spawn Claude CLI as a subprocess, stream events, communicate via files. Choose based on deployment target.
| Aspect | Web App (Node.js + Express) | macOS App (Swift + SwiftUI) |
|---|---|---|
| UI | Vanilla JS, HTML, CSS in browser | SwiftUI views |
| Streaming | HTTP chunked NDJSON via fetch | AsyncStream<ClaudeEvent> |
| Session process | New spawn() per message | PersistentClaudeSession (stays alive) |
| Credentials | ~/.appname/references/.env | Same .env + EnvStore.bootstrap() |
| Event format | Raw NDJSON JSON objects | Typed enum ClaudeEvent { ... } |
| Skill symlinks | ~/.appname/.agents/skills/ | ~/.claude/skills/ + ~/.agents/skills/ |
| Deployment | Railway / Docker / any server | Xcode build → .app bundle |
The runner wraps the claude binary as a Node.js subprocess. It handles argument construction, stdout line buffering, timeout, and both full and streaming modes.
| CLI Flag | Purpose |
|---|---|
-p <prompt> | Provide the prompt (required for headless) |
--session-id <uuid> | Create a new named session (first turn only) |
-r <uuid> | Resume an existing session (all subsequent turns) |
--output-format json | Return a single JSON result when done (full mode) |
--output-format stream-json | Stream NDJSON events as tokens arrive (default) |
--verbose | Include detailed event stream — needed for delta events |
--include-partial-messages | Emit assistant events with partial content |
--dangerously-skip-permissions | Allow all tool use without prompts (full permission mode) |
export function buildClaudeArgs({ prompt, sessionId, turnCount, permissionMode, outputFormat }) {
const args = ["-p", prompt.trim()];
// First turn: Claude creates the session. Subsequent turns: Claude resumes it.
if (turnCount === 0) args.push("--session-id", sessionId);
else args.push("-r", sessionId);
args.push("--output-format", outputFormat);
if (outputFormat === "stream-json") {
args.push("--verbose", "--include-partial-messages");
}
if (permissionMode === "full") {
args.push("--dangerously-skip-permissions");
}
return args;
}
// Parse each NDJSON line from Claude's stdout
const payload = JSON.parse(line);
// Text deltas → forward as "delta" to the frontend
if (payload.type === "stream_event" && payload.event?.type === "content_block_delta") {
const deltaText = payload.event?.delta?.text ?? "";
if (deltaText) {
assistantText += deltaText;
sendNdjson(res, { type: "delta", delta: deltaText });
}
}
// Verbose events → forward as "verbose" to the frontend for the activity pill
else if (payload.type === "stream_event") {
const et = payload.event?.type;
if (et === "message_start")
sendNdjson(res, { type: "verbose", event_type: "system_init" });
else if (et === "content_block_start" && payload.event?.content_block?.type === "tool_use")
sendNdjson(res, { type: "verbose", event_type: "tool_use", tool_name: payload.event?.content_block?.name });
else if (et === "content_block_start" && payload.event?.content_block?.type === "thinking")
sendNdjson(res, { type: "verbose", event_type: "thinking" });
else if (et === "content_block_start" && payload.event?.content_block?.type === "text")
sendNdjson(res, { type: "verbose", event_type: "writing" });
}
Newline-delimited JSON over chunked HTTP. Each event is a JSON object followed by \n. No WebSockets needed.
| Event Type | Direction | Key Payload Fields | When Sent |
|---|---|---|---|
start | server → client | sessionId, userMessage? | Immediately on request |
verbose | server → client | event_type, tool_name?, summary? | Thinking, tool_use, writing events from Claude |
delta | server → client | delta: string | Each text chunk as it arrives |
done | server → client | assistantMessage | After final result |
error | server → client | error: string, assistantMessage? | On any error |
section_ready | server → client | sectionIndex, title, content | After section write in document workflows |
Content-Type: application/x-ndjson; charset=utf-8Cache-Control: no-cacheConnection: keep-aliveres.flushHeaders?.() immediately after setting headersKeep a Map<sessionId, true> to reject concurrent requests with 409 Conflict. Add on request start, delete in finally so it always clears even on error.
export async function readNdjsonStream(response, handlers) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (buffer.includes("\n")) {
const boundary = buffer.indexOf("\n");
const line = buffer.slice(0, boundary).trim();
buffer = buffer.slice(boundary + 1);
if (!line) continue;
handlers.onEvent(JSON.parse(line));
}
}
}
// Error recovery: always check for terminal event
let receivedTerminal = false;
await readNdjsonStream(response, {
onEvent(event) {
if (event.type === "done" || event.type === "error") receivedTerminal = true;
// ...handle event
}
});
if (!receivedTerminal) {
setStatus("Connection lost. Please try again.");
}
Sessions are JSON files on disk at ~/.appname/sessions/{sessionId}.json. There is no per-session workingDir — all sessions share the app-level working folder as their cwd.
{
"sessionId": "uuid",
"engine": "claude",
"permissionMode": "safe",
"turnCount": 3,
"transcript": [
{ "id": "uuid", "role": "user", "content": "...", "status": "completed", "mode": "stream", "createdAt": "iso" },
{ "id": "uuid", "role": "assistant", "content": "...", "status": "completed", "mode": "stream", "createdAt": "iso" }
],
"createdAt": "iso",
"updatedAt": "iso"
}
Every app gets a dedicated working folder at ~/.appname/. Symlinks inside it let the AI agent auto-discover the app's skills and instructions without any extra CLI flags.
export function ensureWorkspace(appName, appDir) {
const workDir = join(homedir(), `.${appName}`);
// Create working folder and subdirectories
for (const sub of ["sessions", "uploads", "output"]) {
mkdirSync(join(workDir, sub), { recursive: true });
}
// Create .agents/ dir and symlink skills + agent.md
mkdirSync(join(workDir, ".agents"), { recursive: true });
ensureSymlink(join(appDir, "skills"), join(workDir, ".agents", "skills"));
ensureSymlink(join(appDir, "agent.md"), join(workDir, ".agents", "agent.md"));
return workDir;
}
// In server.js — call on startup
const WORK_DIR = ensureWorkspace("my-app", APP_DIR);
const claudeRunner = createClaudeRunner({ timeoutMs, workingDir: WORK_DIR });
When Claude runs with cwd set to ~/.appname/, it automatically discovers .agents/agent.md and .agents/skills/ without any extra CLI flags. The app's instructions shape every session.
Skills live in the app's own skills/ directory — never in ~/.agents/skills/. Each skill has a ## SCHEMA block defining its context fields, exposed via GET /api/skills/:name/schema.
During streaming, a single pill appears in the chat that updates in-place as verbose events arrive. All detailed activity accumulates in a hidden log the user can expand. This replaces a generic spinner with meaningful feedback.
Create an activity pill element and append it to the chat. Initial state: collapsed, gray, text "⏳ Starting..."
On each verbose event: update the pill label ("💭 Thinking...", "🔧 Tool: Read...", "✍️ Writing...") and append to the hidden log.
If no tools were called → remove the pill entirely. If tools were called → set pill text to "N tool calls", color orange, keep the log expandable.
export function createActivityPill() {
const pill = document.createElement("div");
pill.className = "activity-pill";
pill.dataset.expanded = "false";
pill.innerHTML = `
<button class="pill-header" onclick="togglePillLog(this)">
<span class="pill-chevron">▸</span>
<span class="pill-text">⏳ Starting...</span>
</button>
<div class="pill-log" hidden></div>`;
return pill;
}
export function finalizePill(pill, toolCount) {
if (toolCount === 0) { pill.remove(); return; }
updatePill(pill, { text: `🔧 ${toolCount} tool call${toolCount === 1 ? "" : "s"}`, color: "orange" });
}
// In your NDJSON event handler
let pill = null, toolCount = 0;
function handleEvent(event) {
if (event.type === "start") {
pill = createActivityPill();
messagesEl.appendChild(pill);
}
if (event.type === "verbose" && pill) {
const label = event.event_type === "thinking" ? "💭 Thinking..."
: event.event_type === "tool_use" ? `🔧 ${event.tool_name}...`
: event.event_type === "tool_result" ? "📥 Result..."
: event.event_type === "writing" ? "✍️ Writing..."
: null;
if (label) updatePill(pill, { text: label, log: label });
if (event.event_type === "tool_use") toolCount++;
}
if (event.type === "done" && pill) finalizePill(pill, toolCount);
}
For document generation workflows, instruct Claude to end responses with a machine-readable marker. This keeps human-readable content separate from the structured signal.
SECTION_READY:{"sectionIndex":1,"docIndex":1,"title":"Executive Summary"}
TASK_LIST_READY:[{"title":"Section 1","docNumber":1,"referenceFile":"doc01.md"}, ...]
const markerIdx = fullContent.lastIndexOf("\nSECTION_READY:");
if (markerIdx !== -1) {
const before = fullContent.slice(0, markerIdx).trim();
const markerLine = fullContent.slice(markerIdx + "\nSECTION_READY:".length).split("\n")[0].trim();
const markerData = JSON.parse(markerLine);
return { content: before, markerData };
}
Default port 3333 avoids conflicts with common local services. The working folder is created at runtime — mount a volume there to persist sessions across deploys.
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs
RUN npm install -g @anthropic-ai/claude-code
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
COPY public/ ./public/
COPY skills/ ./skills/
COPY agent.md ./agent.md
EXPOSE 3333
ENV NODE_ENV=production
CMD ["node", "src/server.js"]
{
"name": "my-headless-app",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"express": "^5.1.0"
}
}
// No build step. No TypeScript. No bundler.
// Add dependencies only as genuinely needed.
// marked (for Markdown rendering) via CDN — not npm.
Grid: 1fr 420px. Left = domain controls/content. Right = chat pane (header + messages + composer). Chat is always visible.
Every chat header has a "Copy CLI Resume" button that copies claude --resume <sessionId> to clipboard — so users can continue in terminal.
Append a pending bubble before the API call completes, then update it as deltas stream in. CSS ::after spinner on .message.pending.
All views in one HTML file. showView(name) hides all .view elements, shows the target. No router needed.
Files encoded as base64 JSON (not multipart). Keeps the API consistent — everything is JSON POST. Backend receives { files: [{ name, data }] }.
Users highlight text in rendered content to attach targeted feedback. Collected as pendingFeedbacks[], sent with the next chat message.