Skill Reference · April 2026
Headless
App Creator

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.

Foundation
Core Principles
1

Chat-First Layout

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.

2

Streaming + Verbose by Default

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.

3

Actions-First Development

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.

4

Working Folder Isolation

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.

5

Thin Server, No Build Step

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.

Architecture
How It Works

Three layers. Each layer has a single responsibility. The server is deliberately thin — it spawns, pipes, and persists. Claude does all the thinking.

Browser (vanilla JS, ES modules) │ fetch → NDJSON streamExpress server (Node.js ESM, no build step) │ spawn("claude", args, { stdio })Claude CLI subprocess (--output-format stream-json --verbose --include-partial-messages)
Platform
Web App vs macOS Native

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)
UIVanilla JS, HTML, CSS in browserSwiftUI views
StreamingHTTP chunked NDJSON via fetchAsyncStream<ClaudeEvent>
Session processNew spawn() per messagePersistentClaudeSession (stays alive)
Credentials~/.appname/references/.envSame .env + EnvStore.bootstrap()
Event formatRaw NDJSON JSON objectsTyped enum ClaudeEvent { ... }
Skill symlinks~/.appname/.agents/skills/~/.claude/skills/ + ~/.agents/skills/
DeploymentRailway / Docker / any serverXcode build → .app bundle
Structure
Project Layout
project/ # app source ├── src/ │ ├── server.js # entry: loads .env, creates working folder, starts Express │ ├── app.js # Express app factory (injected deps) │ ├── engines/ │ │ └── claude.js # Claude CLI subprocess runner │ ├── routes/ │ │ ├── skills.js # GET /api/skills, GET /api/skills/:name/schema │ │ └── [domain].js # domain-specific streaming routes │ └── services/ │ ├── session-store.js # read/write sessions inside working folder │ ├── workspace.js # working folder setup + symlink management │ ├── engine-health.js # check if `claude` binary is available │ └── [domain]-store.js # domain data (quotes, docs, etc.) ├── public/ │ ├── index.html # chat-first UI (left: controls, right: chat) │ ├── app.js # chat page module │ ├── utils.js # readNdjsonStream, showAiWorking, requestJson │ └── styles.css # shared styles + AI widget CSS ├── skills/ # bundled with the app, NOT in ~/.agents/skills/ │ └── <skill-name>/ │ └── SKILL.md # skill definition with ## SCHEMA block ├── agent.md # app-level AI instructions (symlinked into workdir) ├── Dockerfile └── package.json # "type": "module", express only ~/.appname/ # working folder (created on server startup) ├── sessions/ # one JSON file per session ├── uploads/ ├── output/ ├── .agents/skills/ → app/skills/ # symlink — AI auto-discovers └── .agents/agent.md/ → app/agent.md # symlink — AI auto-discovers
Backend
Claude CLI Runner

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 FlagPurpose
-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 jsonReturn a single JSON result when done (full mode)
--output-format stream-jsonStream NDJSON events as tokens arrive (default)
--verboseInclude detailed event stream — needed for delta events
--include-partial-messagesEmit assistant events with partial content
--dangerously-skip-permissionsAllow all tool use without prompts (full permission mode)
Argument Builder src/engines/claude.js
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;
}
Verbose Event Forwarding (stream mode) src/engines/claude.js
// 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" });
}
Protocol
NDJSON Streaming

Newline-delimited JSON over chunked HTTP. Each event is a JSON object followed by \n. No WebSockets needed.

Event TypeDirectionKey Payload FieldsWhen Sent
startserver → clientsessionId, userMessage?Immediately on request
verboseserver → clientevent_type, tool_name?, summary?Thinking, tool_use, writing events from Claude
deltaserver → clientdelta: stringEach text chunk as it arrives
doneserver → clientassistantMessageAfter final result
errorserver → clienterror: string, assistantMessage?On any error
section_readyserver → clientsectionIndex, title, contentAfter section write in document workflows
Backend

Required Headers

  • Content-Type: application/x-ndjson; charset=utf-8
  • Cache-Control: no-cache
  • Connection: keep-alive
  • Call res.flushHeaders?.() immediately after setting headers
Concurrency Guard

Active-Run Guard

Keep 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.

Frontend Stream Reader public/utils.js
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.");
}
Persistence
Session Store

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.

Session Schema JSON
{
  "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"
}
Working Folder
Workspace & Symlinks

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.

Workspace Setup src/services/workspace.js
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 });
Why Symlinks

Auto-Discovery

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

Bundled with the App

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.

Frontend
Activity Pill

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.

1

Insert pill before fetch

Create an activity pill element and append it to the chat. Initial state: collapsed, gray, text "⏳ Starting..."

2

Update as events arrive

On each verbose event: update the pill label ("💭 Thinking...", "🔧 Tool: Read...", "✍️ Writing...") and append to the hidden log.

3

Finalize on stream end

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.

Pill Helpers public/utils.js
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);
}
Multi-Step Workflows
Structured Output Markers

For document generation workflows, instruct Claude to end responses with a machine-readable marker. This keeps human-readable content separate from the structured signal.

Marker Format Claude output
SECTION_READY:{"sectionIndex":1,"docIndex":1,"title":"Executive Summary"}
TASK_LIST_READY:[{"title":"Section 1","docNumber":1,"referenceFile":"doc01.md"}, ...]
Parsing the Marker JavaScript
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 };
}
Deployment
Dockerfile & package.json

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.

Dockerfile Railway / Docker
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"]
package.json Node ESM
{
  "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.
UI Patterns
Key Frontend Conventions
Layout

Chat-First Split Pane

Grid: 1fr 420px. Left = domain controls/content. Right = chat pane (header + messages + composer). Chat is always visible.

Sessions

Resume Button

Every chat header has a "Copy CLI Resume" button that copies claude --resume <sessionId> to clipboard — so users can continue in terminal.

Messages

Optimistic Pending

Append a pending bubble before the API call completes, then update it as deltas stream in. CSS ::after spinner on .message.pending.

Navigation

Multi-View SPA

All views in one HTML file. showView(name) hides all .view elements, shows the target. No router needed.

Files

Drag & Drop Upload

Files encoded as base64 JSON (not multipart). Keeps the API consistent — everything is JSON POST. Backend receives { files: [{ name, data }] }.

Feedback

Highlight-to-Feedback

Users highlight text in rendered content to attach targeted feedback. Collected as pendingFeedbacks[], sent with the next chat message.

Ready to build your own headless app?
Download the full skill package — includes this reference, code templates, and the complete agent.md pattern — ready to drop into your Claude Code setup.