Module 15 · 60 min

Build a Local Web App

You can stand up a browser front end that talks to the Agent SDK through your own server, and say why shelling out to claude -p could not have carried it.

Surface
@anthropic-ai/claude-agent-sdk · ws · express
Workbench tag
module-15
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-29
A long band of paper tape unspooling left to right, punched with marks of varying scale.

One application, one git tag per module. git is the program that records versions of a folder of files; a commit is one recorded version, with a message saying what changed; a tag is a name git pins to one commit. git checkout module-N is the command that puts every file in the folder back the way it was at that commit. Every module after this one opens with git checkout module-N and closes at module-N+1.

Version 0 does one thing: a prompt goes in through a browser, one Agent SDK session runs against a directory, and the complete answer comes back. No streaming (text shown as it arrives), no approvals (a yes or no from you before a risky action), no session list. Those arrive in module 16, module 17, and module 18, each one adds a file or a case to what you build today rather than replacing it.

flowchart TD
subgraph browser["browser · client/"]
  direction LR
  ui["prompt box"] --> conn["connection.js<br/>socket, queue, reconnect"]
end
subgraph server["Node server · server/"]
  direction LR
  proto["protocol.js<br/>parse frames"] --> sess["session.js<br/>one per socket"] --> agent["agent.js<br/>the message loop"] --> sdk["sdk-stream.js<br/>the one query()"]
end
subgraph proc["one process per prompt"]
  direction LR
  claude["claude<br/>bundled binary"] --> wsd["workspace/<br/>the only directory it can touch"]
end
browser <-->|"WebSocket /ws · prompt, cancel<br/>ready, session, result, error"| server
server -->|"one SDK message stream per prompt"| proc
Version 0, end to end. Four parts, one contract between the first two, one function that calls the SDK.

The browser knows the protocol, the agreed list of messages the two sides may send each other, and nothing about the SDK. The server knows both, in separate files. The SDK call lives in one function. And the agent works in workspace/, a directory that is not the repository (the folder git tracks, which holds the server’s own code), so the first prompt you type cannot rewrite the server that is answering it.

Why the browser does not run claude -p

The obvious first design is a web page with a text box: the browser sends the prompt to the server, the server starts claude -p "<prompt>" as a separate program, and whatever that program prints goes back to the browser. It works for exactly one module, and then the requirements pile up.

The moment you want text as it arrives, that program’s printed output (its stdout) is a stream you now have to read piece by piece and re-send. The moment the agent wants to run rm and you want to be asked, there is no channel back into a -p process to say yes or no. The moment a user closes the tab and reopens it, the session is gone with the process. A -p session without --bare also loads every hook, MCP server, skill, and CLAUDE.md it finds, with no dialog, from whatever directory it runs in; module 14 covers what that means for a server.

The SDK gives you the same loop as a library. Your server process owns the session, reads every message the loop emits, and can hand a callback into it, a function you give the loop so it can call you back when it needs an answer.

A WebSocket carries all of that between the browser and your server. A normal page request is one ask and one answer. A WebSocket is a connection that stays open, so either side can send at any moment.

Run the fake workbench end to end

The workbench is its own repository, 01000001-01001110/claude-workbench. Clone it (git clone downloads a full copy of a repository into a new folder), go into that folder, check out this module’s tag, and run it without an API key first:

git checkout module-15
npm install
WORKBENCH_FAKE_SDK=1 npm run dev

The leading NAME=value sets a variable for that one command in bash and zsh. PowerShell splits it into two lines: $env:WORKBENCH_FAKE_SDK = "1", then npm run dev. Every later module writes the bash form; translate the same way when you are on Windows.

WORKBENCH_FAKE_SDK=1 in front of a command sets that environment variable for that one run only. npm run dev starts the server, and its startup ends with one line of JSON saying where it is listening:

{"t":"...","level":"info","event":"listen","url":"http://127.0.0.1:3000","workspace":"/home/you/claude-workbench/workspace","fake":true,"permissionMode":"dontAsk","maxTurns":10}

Open http://localhost:3000, type anything, and you get a canned answer within a second:

Fake mode is on, so no model was called. The prompt was: Summarise what is in this workspace.

That answer passed through the browser’s protocol file, the socket, the server’s frame parser (a frame is one message sent over the socket, and the parser checks its shape), the session bookkeeping, the agent loop, and back. Only the SDK call was swapped for a fake that emits the same three messages a short real session emits: system/init, one assistant turn, one result. The whole path is real; the model call is not.

The repository keeps a decision record for each choice made while building it, a short file that names the options considered and the reason for the one taken. Record 0018 explains why the swap is at that seam, the one point where the two halves join, and nowhere higher: a fake at the protocol layer would test the fake, and every bug in the real path would wait until someone spent money to find it.

Now the real thing:

export ANTHROPIC_API_KEY=sk-ant-...
npm run dev

The page looks identical. curl, a command that fetches a web address and prints what came back, is how you tell the two apart. Ask the server’s health address:

curl localhost:3000/healthz
{"ok":true,"fake":false,"workspace":"/home/you/claude-workbench/workspace"}
Two rules that apply to anything you ship

Both are from the Agent SDK overview. First, authentication: unless previously approved, Anthropic does not allow third-party developers to offer claude.ai login or its rate limits for their products, including agents built on this SDK. The workbench takes an API key from the environment and nothing else. Second, naming: a product built on the SDK may not be called “Claude Code” or “Claude Code Agent”, and may not imitate Claude Code’s visual elements. “Claude Agent” and “Powered by Claude” are the permitted forms. The app is a workbench; its masthead says Powered by Claude; the client’s design record (0058), the client being the browser half of the app, lists the terminal-style choices it refused: no monospace body, no dark-by-default frame around the page, no > or $ at the start of the input line.

The SDK call, the loop, and the container

The third file below is docker-compose.yml . A container is a sealed copy of the app with its own Node inside, run by Docker, so you can start the app without installing Node yourself. That file describes the container.

claude-workbench/
├─ server/
│ └─ src/
server/src/sdk-stream.js

One function, one query(). Every option here has a decision record.

// The real Agent SDK call. One function, one `query()`.
//
// Role: turn an agent request into a stream of SDK messages. This is the only
// file in the server that imports `query`.
//
// Invariants:
// - Options set here are the ones version 0 has a decision record for: cwd,
//   systemPrompt, maxTurns, permissionMode, abortController. Anything else is
//   the SDK's default and stays that way until a module needs it.
// - The abort path is the caller's AbortSignal, forwarded into the SDK's own
//   `abortController`. Verified against @anthropic-ai/claude-agent-sdk 0.3.251:
//   `Options.abortController` is the documented cancellation input, and
//   `Query.interrupt()` is described as a control request "only supported when
//   streaming input/output is used", which version 0 does not use.

import { query } from "@anthropic-ai/claude-agent-sdk";

/** @typedef {import("./agent.js").AgentRequest} AgentRequest */
/** @typedef {import("./agent.js").MessageStream} MessageStream */

/**
* Start one real session and return its stream of messages.
*
* @type {MessageStream}
*/
export const sdkStream = (request) => {
// An AbortController is the pair to an AbortSignal: the controller has the
// button, `abort()`, and the signal is what everyone else watches. The SDK
// takes a controller, and the session layer owns one already, so this one is
// a relay: abort in, abort out.
const controller = new AbortController();
if (request.signal.aborted) controller.abort();
else
  request.signal.addEventListener("abort", () => controller.abort(), {
    once: true,
  });

return query({
  prompt: request.prompt,
  options: {
    // The agent works in the workspace directory, never in the repository
    // that holds this server. See docs/decisions/0014.
    cwd: request.cwd,
    // The full Claude Code system prompt. Omitting systemPrompt would give the
    // SDK's minimal default, which covers tool calling only. See 0015.
    systemPrompt: { type: "preset", preset: "claude_code" },
    maxTurns: request.maxTurns,
    permissionMode: request.permissionMode,
    abortController: controller,
  },
});
};
The SDK call, the loop that consumes it, and the container that runs both. Read sdk-stream.js first; it is the only file that imports query().

The browser side is five JavaScript files served from client/public exactly as written, no framework (no library underneath drawing the page for you) and no build step (no command that turns the files into other files before the browser gets them).

client/public/connection.js owns the socket, a queue of messages waiting to be sent, and the reconnect. The reconnect waits longer after each failure, 500 ms doubling up to an 8 s ceiling, with 25 percent random spread so many browsers do not all retry at once, and it never gives up; the usual names for those two behaviours are backoff and jitter. One rule in it: a prompt still running when the socket drops is reported as interrupted and never resent, because the protocol says the server does not resume it, and resending would run and bill a second session nobody asked for. The reconnect handler does not resume sessions; module 18 adds that.

The contract between the two halves is one file, docs/PROTOCOL.md: four client frame types, four server frame types, every frame carrying the id the client chose (a label that lets a reply be matched to its prompt), and five more types reserved by name so that a version 0 client ignores a module 16 server without erroring. Read it before either package; both were written against it and neither against the other.

Decision records

The repository holds 47 decision records under docs/decisions/, one per choice, each with the options considered and the reason in the mechanism’s terms. A few that matter on day one:

  • 0016, permissionMode: "dontAsk". Version 0 has no approval screen, so a request that would have prompted is denied, the model is told, and the session finishes. Reads work; writes are refused rather than performed or left hanging. default is the mode this app wants, and it is wrong today for one reason: there is no canUseTool callback yet to answer the prompt. That callback is module 17.
  • 0014, the workspace directory. Everything the agent can touch is in one directory a person can inspect, empty, or delete. It is gitignored, listed in .gitignore so git never commits what the agent writes there. Pointing the agent at the repository instead would make “clean up the unused files” a live question about the application.
  • 0011, bind to loopback. To bind is to pick which address a server listens on. Loopback is 127.0.0.1, which only programs on your own machine can reach; 0.0.0.0 means every address the machine has, including the one other people on the network can see. There is no login, no token, and no check on which page opened the socket, so on a shared network a 0.0.0.0 bind hands your API key’s spending to the room. The container sets HOST=0.0.0.0 because there the only way in is the one port you published, and that is the only place it is set.
  • 0065, no Alpine. Alpine and Debian are two versions of Linux a container can be built on. The SDK’s bundled Claude Code binary was built against glibc, the base library nearly every Linux program leans on. Alpine ships a different one, musl, and a binary that starts on Debian and fails on Alpine fails at the first prompt with an error that does not say why.
  • 0067, no key in the image. The image is the saved snapshot a container starts from, and the Dockerfile, the recipe it is built from, has two lines that could carry a key into it. ENV writes it into the image’s metadata, where docker inspect prints it. ARG writes it into the build history, where docker history shows it, and unsetting it later does not remove the layer (the saved step) that holds it. Both survive a push to a registry, the server images are shared through.
Build

The same ladder as module 13: run it, read one file, change one line, then build.

  1. Run it. Run the workbench in fake mode and send a prompt, then confirm with curl localhost:3000/healthz that fake is true. Set your key, restart without the flag, and ask it to describe the workspace: the answer should name the empty directory, and the result line under it should show a model name, a turn count, and a cost. Then run the container path: docker compose build, then WORKBENCH_FAKE_SDK=1 docker compose up, and open the same page. Finally run npm test at the repository root with no key set; you should see 48 server tests with one skipped, 30 client tests, and one end-to-end test that started its own server. That skipped test is the live smoke test, and it says why it skipped. When you have a key in the environment it runs a real session and spends money.
  2. Read one file. Read server/src/sdk-stream.js top to bottom and say, for each of the five options passed to query(), what it does and which decision record covers it. Then read agent.js and name the four case branches.
  3. Change one line. In docker-compose.yml, change the left side of “3000:3000” to 3001, run WORKBENCH_FAKE_SDK=1 docker compose up again, and predict which of http://localhost:3000 and http://localhost:3001 answers before you open them. Only the left side is your machine’s door; the right side is the container’s. Change it back.
  4. Build. Put a file of your own in workspace/, say notes.txt with one sentence in it, and in real mode ask the agent what the file says. The answer should quote your sentence, which proves the agent is standing in workspace/ and not in the repository.
The mistake most people make first

You get the real mode running, and your second prompt is “create a file called notes.md with a todo list”. The agent says it was not permitted to write the file. No error appears anywhere, /healthz says fake: false, and the server log shows prompt.done subtype=success. Everything reports success and nothing was written.

That is permissionMode: “dontAsk” doing what record 0016 says: a call that would have asked is denied, the model is told, and the session completes normally. The answer text reads like a refusal, and the workspace is still empty. Nothing in version 0 can approve a write, because approval is a callback and module 17 adds it. Do not reach for bypassPermissions to make it go away: the server refuses that value until module 17 adds the approval callback.

Does this travel?

Any agent SDK that exposes its loop as a message stream fits the same three layers, and the fake-stream seam is a general technique: swap the one function that talks to the model, keep everything above it real. The WebSocket protocol depends on no vendor. What does not travel is the specific, the claude_code preset, the four permission modes, the bundled binary and its glibc requirement, and the two rules in the note above, which are Anthropic’s terms and apply to this SDK alone.

Check yourself

  1. The browser sends two prompts a second apart. What does the server do with the second one, and what does the client do? Both answers are in the repository, and they differ.
  2. A result frame arrives with subtype: "error_max_turns" and an empty text. Is that an error frame? Where would you look to find out what the session cost?
  3. You mount the whole repository as the workspace so the agent can work on real code. Name the first thing that goes wrong, and the record that predicted it.