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
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"}
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.
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,
},
});
}; server/src/agent.js The message loop from module 14. The function that produces the messages is handed in, and that is where fake mode swaps in.
// The Agent SDK message loop, in one place.
//
// Role: run one agent session and reduce the stream of SDK messages to the two
// things version 0 reports, the session identity and the final result. The
// switch below is the loop from the module 14 lab, kept visible on purpose:
// modules 16 (streaming), 16 (approvals) and 17 (sessions) each add a case or a
// handler here rather than replacing the file.
//
// Invariants:
// - This module never touches a WebSocket and never formats a protocol frame.
// It reports through a handlers object and returns a result object.
// - The message stream is injected. `sdkStream` calls the real SDK,
// `createFakeStream` replays a canned sequence; the loop cannot tell them
// apart.
// - Exactly one of these happens per call: a return with a result, or a throw.
// A stream that ends without a `result` message is a throw, not a silent
// empty answer.
//
// The shapes this file passes around are written below as JSDoc `@typedef`
// comments. A typedef is a comment that names a shape so a later comment, and
// an editor, can refer to it. Nothing in it runs; the code is plain JavaScript.
/**
* One request to run the agent.
*
* @typedef {object} AgentRequest
* @property {string} prompt The user's prompt, verbatim.
* @property {string} cwd Absolute directory the agent runs in.
* @property {number} maxTurns Ceiling on agentic turns.
* @property {string} permissionMode One of the modes listed in config.js.
* @property {AbortSignal} signal Aborted when the client cancels or the socket
* closes. An AbortSignal is a small object with a flag on it, `aborted`, that
* flips from false to true once and never back. Code that is waiting can also
* ask to be told the moment it flips.
*/
/**
* Who the session belongs to, as the SDK reports it.
*
* @typedef {object} SessionInfo
* @property {string} sessionId
* @property {string} model
*/
/**
* Callbacks the loop reports through while a session runs.
*
* @typedef {object} AgentHandlers
* @property {(info: SessionInfo) => void} onSession Called once, when the SDK
* emits its system/init message.
*/
/**
* What one finished session produced.
*
* @typedef {object} AgentResult
* @property {string} subtype "success", or one of the SDK's error subtypes such
* as "error_max_turns".
* @property {string} text The answer. Empty string when `subtype` is not
* "success".
* @property {number} turns
* @property {number} costUsd
*/
/**
* A source of SDK messages for one request.
*
* It is a function that takes an AgentRequest and returns something the `for
* await` loop below can walk through, one message at a time, waiting for each.
*
* @typedef {(request: AgentRequest) => AsyncIterable<any>} MessageStream
*/
/** Thrown when the session stopped without producing a result. */
export class AgentError extends Error {
constructor(message) {
super(message);
this.name = "AgentError";
}
}
/** Thrown when the run was cancelled through the request's AbortSignal. */
export class AgentCancelled extends Error {
constructor() {
super("cancelled");
this.name = "AgentCancelled";
}
}
/**
* Run one session to completion.
*
* `createStream` is the seam that fake mode swaps. Everything downstream of
* this function sees the same shapes either way.
*
* @param {AgentRequest} request
* @param {AgentHandlers} handlers
* @param {MessageStream} createStream
* @returns {Promise<AgentResult>}
*/
export async function runAgent(request, handlers, createStream) {
let result;
// Assistant text is collected but not sent in version 0. The result message
// already carries the final answer; this is here because module 16 streams
// these blocks as `delta` frames and the collection point is the same.
const assistantText = [];
try {
// `for await` is an ordinary loop with one difference: each trip round it
// waits for the next item to arrive before running the body. That is what
// reading a live stream of messages needs, because the next message has not
// been written yet when the loop asks for it.
for await (const message of createStream(request)) {
switch (message.type) {
case "system":
// The first message of every session. Only the init subtype carries
// the session id and model; other system subtypes exist and are not
// interesting to version 0.
if (message.subtype === "init") {
handlers.onSession({
sessionId: message.session_id,
model: message.model,
});
}
break;
case "assistant":
for (const block of message.message.content) {
if (block.type === "text") assistantText.push(block.text);
}
break;
case "user":
// Tool results come back to the model as user messages. The bundled
// Claude Code process ran the tool, not this code.
break;
case "result": {
// The envelope that closes the session. Only the success variant has
// a `result` string, so the answer is read by checking that the
// property is there rather than assuming it.
const text =
message.subtype === "success" && typeof message.result === "string"
? message.result
: "";
result = {
subtype: message.subtype,
text,
turns: message.num_turns,
costUsd: message.total_cost_usd,
};
break;
}
default:
// The SDK adds message types over time. Version 0 ignores what it
// does not know rather than failing the prompt over it.
break;
}
}
} catch (cause) {
// An abort surfaces here as a rejection. Which error class the SDK throws
// on abort is not part of its documented contract, so the signal is the
// thing checked, not the error name.
if (request.signal.aborted) throw new AgentCancelled();
throw cause;
}
if (request.signal.aborted) throw new AgentCancelled();
if (result === undefined) {
throw new AgentError("the agent session ended without a result message");
}
return result;
} docker-compose.yml The same app in a container. The key is forwarded from your shell, never written down.
# Run the workbench in a container.
#
# Role: the one command a reader runs to see the app without installing Node.
# docker compose build
# WORKBENCH_FAKE_SDK=1 docker compose up
#
# Invariants:
# - No secret is written in this file. ANTHROPIC_API_KEY is passed through
# from the host environment with the "VAR" short form, which forwards the
# host's value and sets nothing if it is unset.
# - ./workspace on the host is the agent's working directory. It is a bind
# mount, not a volume, because the point of the workbench is to look at the
# files the agent changed with your own editor. See docs/decisions/0066.
# - Port 3000 on the host maps to 3000 in the container, which is the server's
# default PORT and what README.md and client/README.md both quote.
services:
workbench:
build:
context: .
dockerfile: Dockerfile
image: claude-workbench:0.15.0
ports:
# Host:container. Bound to all host interfaces, matching the plain
# "localhost:3000" the docs quote; change the left side to
# "127.0.0.1:3000" if the machine is on a network you do not trust.
- "3000:3000"
environment:
# Short form: forward the host's value, or leave it unset if the host has
# none. The long form "KEY: ${KEY}" would set it to the empty string
# instead, and an empty API key reads as a configured key that fails.
- ANTHROPIC_API_KEY
- WORKBENCH_FAKE_SDK
# Optional server settings, forwarded the same way so a reader can try one
# without editing this file.
- WORKBENCH_MAX_TURNS
- WORKBENCH_PERMISSION_MODE
# Not forwarded, set here: inside the container these two are not the
# host's business, and the paths below are where the image puts them.
- HOST=0.0.0.0
- PORT=3000
- WORKBENCH_DIR=/app/workspace
# Where the browser files are. The client has no build step, so this is
# client/public copied in as it stands. The Dockerfile sets the same
# value; it is repeated here because this is the file a reader opens
# first. See docs/decisions/0069.
- WORKBENCH_CLIENT_DIR=/app/client/public
volumes:
# The agent reads and writes here. Everything it does is visible on the
# host at ./workspace immediately, and nothing it does can reach the rest
# of the repository, because nothing else is mounted.
- ./workspace:/app/workspace
# Compose's default is to leave stdin closed and no tty, which is what a
# server wants. Named explicitly so nobody adds them by habit.
stdin_open: false
tty: false
restart: "no" 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.defaultis the mode this app wants, and it is wrong today for one reason: there is nocanUseToolcallback 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
.gitignoreso 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.0means 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 a0.0.0.0bind hands your API key’s spending to the room. The container setsHOST=0.0.0.0because 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.
ENVwrites it into the image’s metadata, wheredocker inspectprints it.ARGwrites it into the build history, wheredocker historyshows 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.
The same ladder as module 13: run it, read one file, change one line, then build.
- Run it. Run the workbench in fake mode and send a prompt, then confirm with
curl localhost:3000/healthzthatfakeistrue. 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, thenWORKBENCH_FAKE_SDK=1 docker compose up, and open the same page. Finally runnpm testat 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. - Read one file. Read
server/src/sdk-stream.jstop to bottom and say, for each of the five options passed toquery(), what it does and which decision record covers it. Then readagent.jsand name the fourcasebranches. - Change one line. In
docker-compose.yml, change the left side of“3000:3000”to3001, runWORKBENCH_FAKE_SDK=1 docker compose upagain, and predict which ofhttp://localhost:3000andhttp://localhost:3001answers before you open them. Only the left side is your machine’s door; the right side is the container’s. Change it back. - Build. Put a file of your own in
workspace/, saynotes.txtwith 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 inworkspace/and not in the repository.
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.
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
- 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.
- A
resultframe arrives withsubtype: "error_max_turns"and an emptytext. Is that an error frame? Where would you look to find out what the session cost? - 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.