A teaching app for the Anthropic certifications: a simple TypeScript chat client over the Claude API, with a live log panel, example tools, and switchable orchestration patterns. Every file is commented with a plain-language overview of what it does.
How to use this plan. Work top to bottom. Each step is sized for under 30 minutes and ends with a done-check you can run or see. Tick the box when the check passes — your progress is saved in this browser, so open this file from the same path each time. If a step stalls, paste it back to Claude and ask for it to be split into 23a, 23b, and so on.
This plan sits in docs/ inside the project it describes. Every path in the steps below is relative to the project root — the folder above this one — and that is where you run every command.
Project root anthropic-chat-lab\
Stack Node + Express + vanilla TS
Model claude-opus-5-5
Patterns 7 switchable
Tools 6 examples
Phases 10
Hide completed
Reset all
PHASE 01
Project skeleton
Get a TypeScript project that compiles and runs before any API calls exist.
01
Initialise npm in the project folder
Open a terminal in anthropic-chat-lab/ — the folder this plan sits in. Run npm init -y, then edit package.json to set "type": "module" and a clear "name".
Done check
node -e "console.log(require('./package.json').type)"
Prints module.
02
Install runtime and dev dependencies
Install @anthropic-ai/sdk, express, dotenv, zod. As dev deps install typescript, tsx, @types/node, @types/express.
Done check
npm ls --depth=0
Lists all eight packages, no UNMET lines.
03
Write tsconfig.json
Target ES2022, module NodeNext, strict: true, outDir: dist, rootDir: ., and "types": ["node"]. Include server, public and scripts. Add a comment block at the top saying what each option is for.
Done check
mkdir server public scripts
echo "export {};" > scripts/placeholder.ts
npx tsc --noEmit
Exits 0 with no output.
Gotcha tsc fails with TS18003 when include matches zero files, so create the folders and one throwaway file first. Also set "types": ["node"] — declaring lib explicitly stops @types/node loading automatically, and process then goes unrecognised.
04
Add .gitignore and .env.example
Ignore node_modules, dist, .env. Create .env.example with ANTHROPIC_API_KEY= and PORT=3000 as a template to copy.
Done check
ls .gitignore .env.example && grep -x "\.env" .gitignore
Both files listed, and the grep prints .env.
05
Create your real .env and verify the key loads
Copy .env.example to .env and paste your real API key into it. Write scripts/check-env.ts to load dotenv and print only the key's first 8 characters — never a secret in full.
Done check
npx tsx scripts/check-env.ts
Prints PASS … sk-ant-a…, not undefined.
Gotcha Only you can paste the key — an assistant cannot do this step for you. In the script, narrow the key with an early return inside a function: TypeScript does not know process.exit() ends the program, so after a bare exit call it still treats the value as possibly undefined.
06
Add npm scripts
Add "dev": "tsx watch server/index.ts", "build": "tsc", "typecheck": "tsc --noEmit" and "check-env": "tsx scripts/check-env.ts". Remove the generated test placeholder.
Done check
npm run typecheck
Exits 0 with no type errors.
PHASE 02
First Claude call
Prove the API works from a script before wrapping it in a server.
07
Create the shared Anthropic client module
Write server/anthropic.ts: load dotenv, export one shared client, and export MODEL, SMALL_MODEL and MAX_TOKENS as named constants. Comment why one shared client beats one per request.
Done check
npx tsx scripts/scratch-client.ts
A two-line scratch script importing the module and logging typeof client.messages.create prints function. Delete the script after.
Gotcha Cross-file imports need a .js extension even though the file on disk is .ts — import … from "./anthropic.js". That is how NodeNext works, and it catches nearly everyone once.
08
Send one hard-coded message
In scripts/hello-claude.ts, call client.messages.create with max_tokens: 1024 and one user message. Loop response.content and print blocks where block.type === "text".
Done check
npx tsx scripts/hello-claude.ts
Prints a real sentence from Claude. Costs a fraction of a penny.
Gotcha A 401 here is often routing, not credentials. The SDK reads ANTHROPIC_BASE_URL from the environment, so a company LLM gateway silently intercepts the call. If the error names a gateway, pin the client: new Anthropic({ baseURL: "https://api.anthropic.com", apiKey: process.env.ANTHROPIC_API_KEY }).
09
Print the usage numbers
Extend the script to log response.usage.input_tokens, output_tokens, response.model and response.stop_reason. Add a rough cost line so the numbers mean something.
Done check
npx tsx scripts/hello-claude.ts
Shows non-zero token counts and stop_reason end_turn.
10
Try a deliberately bad key
Catch the error with instanceof Anthropic.AuthenticationError and print a friendly message. Note the status code in a comment. Pass the bad key as an environment variable so your real .env is never edited.
Done check
ANTHROPIC_API_KEY=sk-ant-invalid npx tsx scripts/hello-claude.ts
Prints a readable auth message, no stack trace. Re-run without the prefix to confirm it still works.
PHASE 03
Server and static frontend
A page in the browser that can reach the server. No Claude yet.
11
Minimal Express server
Write server/index.ts: create the app, add express.json(), serve public/ as static, add GET /api/health returning { ok: true }, and listen on PORT.
Done check
npm run dev
# then, in a second terminal:
curl localhost:3000/api/health
Prints {"ok":true}.
12
Build the HTML shell
Write public/index.html with three regions: a header holding the pattern selector, a message transcript, and a composer (textarea plus Send button). No styling yet.
Done check
By eye — localhost:3000
All three regions visible, stacked, unstyled.
13
Add the log panel region
Add a fourth region beside or below the transcript for logs, with a heading and an empty scrollable list. This is where every event will appear.
Done check
By eye — localhost:3000
The empty log panel is visible, and scrolls once you paste in a dozen dummy rows by hand.
14
Style the two-column layout
Write public/styles.css: chat on the left, logs on the right, stacking to one column under 800px. Use a monospace font for the log panel.
Done check
By eye — localhost:3000, then drag the window narrow
Two columns at desktop width; one column below 800px, with nothing clipped.
15
Compile the frontend TypeScript
Create public/app.ts that logs "frontend loaded". Add a tsconfig include or a second build script so it emits public/app.js, and reference that from the HTML.
Done check
npx tsc # emits public/app.js
# then open localhost:3000 and look at the browser console
Console shows frontend loaded, Network tab shows no 404 for app.js.
16
Wire the Send button to the health route
On click, read the textarea, fetch('/api/health'), and append the result to the transcript. This proves the round trip end to end.
Done check
By eye — localhost:3000
Clicking Send appends {"ok":true} to the transcript.
PHASE 04
The log channel
Build logging before the features, so every feature after this is observable while you write it.
17
Define the log event shape
In server/types.ts, define a LogEvent type: level (info / tool / api / error), label, optional detail, and at timestamp. Comment what each level means.
Done check
npm run typecheck
Exits 0, with LogEvent exported from server/types.ts.
18
Create the logger helper
Write server/logger.ts exporting a makeLogger(emit) function that returns { info, tool, api, error } methods, each building a LogEvent and passing it to emit.
Done check
npx tsx -e "import {makeLogger} from './server/logger.ts';
makeLogger(console.log).info('hello')"
Prints one object with level, label and at.
19
Add a Server-Sent Events endpoint
Add POST /api/chat that sets Content-Type: text/event-stream, writes one hard-coded event as data: {...}\n\n, then ends. Comment why SSE suits one-way streaming.
Done check
curl -N -X POST localhost:3000/api/chat
Prints one data: {...} line, then the connection closes.
20
Consume the stream in the browser
Replace the health fetch with a POST whose body you read via response.body.getReader(). Split on blank lines, JSON.parse each data: payload.
Done check
# open localhost:3000, click Send, watch the browser console
The hard-coded event appears as a parsed JavaScript object, not as raw text.
21
Render log events into the panel
Write appendLog(event) in app.ts: create a row with the timestamp, a coloured level tag, and the label. Auto-scroll to the newest row.
Done check
By eye — localhost:3000
The event renders as a formatted row with a timestamp and a coloured level tag.
22
Make detail rows expandable
When an event has detail, render it inside a <details> element so full JSON is available without flooding the panel.
Done check
By eye — localhost:3000
A row with detail expands to pretty-printed JSON on click; rows without detail stay plain.
PHASE 05
Real chat, streaming
Claude's words reach the page, and the conversation survives more than one turn.
23
Call Claude from the chat route
In POST /api/chat, read { messages } from the body and call client.messages.create. Emit an api log before the call and after it, then send the text as a text event.
Done check
By eye — localhost:3000
A typed question returns Claude's answer, with an api log row before and after it.
24
Switch to streaming
Replace .create with client.messages.stream. Iterate events; on content_block_delta with text_delta, forward the fragment as a delta event.
Done check
By eye — localhost:3000
Text appears progressively rather than in one block at the end.
25
Log the final message summary
After the stream, call stream.finalMessage() and emit an api log with the model, stop reason, and both token counts in detail.
Done check
By eye — localhost:3000
Each reply ends with one expandable log row showing model, stop reason and both token counts.
26
Keep conversation history client-side
Hold a messages array in app.ts. Push the user turn before sending and the assistant turn after the stream closes, then send the whole array each time.
Done check
By eye — localhost:3000
Tell Claude your name, then ask "what did I just say?" — it answers correctly.
27
Add a system prompt
Add a system string in server/prompts.ts describing a patient teaching assistant. Pass it on every request and log that it was applied.
Done check
By eye — localhost:3000
Replies take on the described tone, and a log row names the system prompt.
28
Handle API errors in the UI
Wrap the call in try/catch. Catch specific classes first — RateLimitError, then APIStatusError, then APIConnectionError — and emit an error event the frontend renders in red.
Done check
# temporarily set MODEL to "claude-does-not-exist" in server/anthropic.ts,
# send a message, then change it back
A readable red error row appears and the server stays running.
PHASE 06
Example tools
Six tools that each teach one idea. Build the registry first so adding the rest is quick.
29
Define the tool registry
In server/tools/registry.ts, define a LabTool interface: name, description, Zod input schema, and an async run. Export an array plus a byName lookup map.
Done check
npm run typecheck
Exits 0, with LabTool and an empty tools array exported.
30
Tool 1 — calculator
Write server/tools/calculator.ts taking { a, b, op }. Use a switch, not eval. Comment why evaluating model-supplied strings is unsafe.
Done check
npx tsx -e "import {calculator} from './server/tools/calculator.ts';
calculator.run({a:7,b:6,op:'multiply'}).then(console.log)"
Prints 42.
31
Tool 2 — current time
Write a get_current_time tool taking an optional IANA timezone and returning a formatted string. The classic example of knowledge the model cannot have.
Done check
npx tsx -e "import {currentTime} from './server/tools/current-time.ts';
currentTime.run({timezone:'Europe/London'}).then(console.log)"
Prints the correct London time.
32
Tool 3 — in-memory notes store
Write a notes tool with actions save / get / list over a module-level Map. Shows state persisting between tool calls inside one conversation.
Done check
npx tsx scripts/try-notes.ts # save one note, then list
The listing includes the note just saved.
33
Seed a small knowledge base file
Create server/data/kb.json: 8–12 short records about the Anthropic API, each with id, title, and body. This is your retrieval corpus.
Done check
node -e "console.log(require('./server/data/kb.json').length)"
Prints 8 or more.
34
Tool 4 — knowledge base search
Write a search_docs tool doing case-insensitive keyword scoring over kb.json and returning the top 3 with ids. Simple retrieval without a vector database.
Done check
npx tsx -e "import {searchDocs} from './server/tools/search-docs.ts';
searchDocs.run({query:'tool use'}).then(console.log)"
The most relevant record is first in the results.
35
Tool 5 — sandboxed file reader
Write a read_file tool confined to server/data/. Resolve the path, then reject anything that escapes the root. Comment that model output is untrusted input.
Done check
npx tsx -e "import {readFile} from './server/tools/read-file.ts';
readFile.run({path:'../../.env'}).then(console.log)"
Returns a refusal. If it prints your API key, the path check is broken — fix before continuing.
36
Tool 6 — the unreliable tool
Write a flaky_service tool that throws on roughly half of calls, controlled by a failRate input so lessons are repeatable. Built to teach error recovery.
Done check
npx tsx scripts/try-flaky.ts # ten calls at failRate 1, then ten at 0
Ten failures, then ten successes.
37
Register all six and expose them
Import every tool into the registry array. Add GET /api/tools returning names and descriptions, and list them somewhere in the UI header.
Done check
curl localhost:3000/api/tools
Returns six entries, and they render in the UI header.
PHASE 07
The tool-use loop
Write the loop by hand first — the certification tests whether you understand it, not whether you can call a helper.
38
Convert registry entries to API tool definitions
Write a helper turning each LabTool into { name, description, input_schema }. Convert the Zod schema to JSON Schema with z.toJSONSchema or a small hand-written mapping.
Done check
npx tsx -e "import {toApiTools} from './server/tools/registry.ts';
console.log(JSON.stringify(toApiTools(), null, 2))"
Six definitions, each with an input_schema of type object.
39
Pass tools and detect tool_use
Send tools on the request. After the response, check for stop_reason === "tool_use" and log which tools were requested, without running them yet.
Done check
By eye — localhost:3000
Asking "what is 7 times 6?" produces a log row naming calculator.
40
Execute one tool and return the result
Push the assistant turn, run the matching tool, then push a user turn containing a tool_result block with the right tool_use_id. Send again.
Done check
By eye — localhost:3000
"What is 7 times 6?" ends with Claude replying 42.
41
Make it a real loop with a cap
Wrap the exchange in a while loop that continues while stop_reason is tool_use, capped at 10 iterations. Log the iteration number each pass.
Done check
By eye — localhost:3000
Ask "what time is it, and what is 12 squared?" — the log shows iteration 1 and iteration 2.
42
Handle parallel tool calls
When one response holds several tool_use blocks, run them with Promise.all and return all results in a single user message. Comment why splitting them trains the model out of parallel calls.
Done check
By eye — localhost:3000
"What time is it in London and Tokyo?" produces two tool rows and a single result message.
43
Return tool errors properly
Catch throws inside tool execution and return a tool_result with is_error: true and the message — never drop the block, or the API rejects the next request.
Done check
By eye — localhost:3000
Ask Claude to call flaky_service with failRate 1 — it reports the failure rather than hanging.
44
Validate tool inputs before running
Parse each tool_use.input through its Zod schema. On failure return an error result naming the bad field rather than crashing the server.
Done check
By eye — localhost:3000
Temporarily hard-code a bad input in the loop: a tidy validation error appears, naming the bad field, and the server stays up.
45
Show tool activity in the transcript
Emit tool events carrying name, input, and result. Render them in the transcript as a distinct block, not just in the log panel.
Done check
By eye — localhost:3000
A tool-using answer shows an inline tool card between the question and the reply.
PHASE 08
Orchestration patterns
Seven patterns behind one interface, so the dropdown can swap them at runtime.
46
Define the pattern interface
In server/orchestration/types.ts, define Pattern: an id, a display name, a one-paragraph teaching description, and run({ messages, tools, log, emitText }).
Done check
npm run typecheck
Exits 0, with Pattern exported.
47
Pattern 1 — single call
Move the plain streaming call into patterns/single-call.ts. One request, one response, no tools. The baseline everything else is measured against.
Done check
By eye — localhost:3000
Chat still works end to end, now routed through the pattern interface.
48
Pattern 2 — tool loop
Move the Phase 7 loop into patterns/tool-loop.ts unchanged behind the same interface.
Done check
By eye — localhost:3000
Tool questions still resolve correctly through the pattern.
49
Add the pattern registry and route plumbing
Export all patterns from an index keyed by id. Accept a pattern field in the request body, look it up, and fall back to single-call with a warning log.
Done check
curl -N -X POST localhost:3000/api/chat -H "Content-Type: application/json" \
-d '{"pattern":"tool-loop","messages":[{"role":"user","content":"7 times 6?"}]}'
The tool loop runs. Repeat with "pattern":"nonsense" and a warning is logged instead.
50
Pattern 3 — prompt chaining
Write patterns/chaining.ts: call one (outline), then call two (write from that outline). Log each stage and emit only the final text.
Done check
By eye — localhost:3000, pattern set to Prompt chaining
"Explain prompt caching" produces two api log rows and one answer.
51
Pattern 4 — routing
Write patterns/routing.ts: a cheap claude-haiku-5-5 classifier picks a category, then a specialist system prompt handles it on the main model. Log the chosen route.
Done check
By eye — localhost:3000, pattern set to Routing
A coding question and a conceptual question log different routes.
52
Pattern 5 — parallelisation
Write patterns/parallel.ts: split the question into 3 fixed aspects, run them with Promise.all, then a final call merges the answers. Log start and finish of each branch.
Done check
By eye — localhost:3000, pattern set to Parallelisation
Three branches start together and finish out of order.
53
Pattern 6 — orchestrator and workers
Write patterns/orchestrator.ts: a lead call returns a JSON list of subtasks via structured outputs, workers run them in parallel, a synthesiser merges. Log the plan.
Done check
By eye — localhost:3000, pattern set to Orchestrator
A parsed subtask list appears in the log before any worker starts.
54
Pattern 7 — evaluator and optimiser
Write patterns/evaluator.ts: generate, then have an evaluator return { pass, feedback }. Loop up to 3 rounds, feeding feedback back in. Log each verdict.
Done check
By eye — localhost:3000, pattern set to Evaluator
A deliberately vague request shows at least one failed round before passing.
55
Add the pattern selector to the UI
Populate a dropdown from GET /api/patterns, send the selection with every message, and show the pattern's teaching description beneath it.
Done check
By eye — localhost:3000
Asking the same question under two patterns produces visibly different log sequences.
PHASE 09
Certification topics
The remaining exam surface: server tools, caching, structured outputs, thinking.
56
Add the hosted web_search tool
Declare { type: "web_search_20260209", name: "web_search", max_uses: 3 } in the tool-loop pattern behind a UI toggle. It has no run — the server executes it.
Done check
By eye — localhost:3000, web search toggled on
A current-events question returns an answer, with search result blocks in the log.
57
Contrast client and server tool execution
Tag every tool log row as client or server and add a line in the UI explaining the difference. This distinction is examinable.
Done check
By eye — localhost:3000
A question needing both a local tool and web search shows both client and server tags.
58
Turn on prompt caching
Mark the system prompt with cache_control: { type: "ephemeral" } as a structured block. Comment that caching is a prefix match and volatile content must come last.
Done check
By eye — localhost:3000
Send three messages in one conversation: the second and third log a non-zero cache_read_input_tokens.
59
Show cache savings in the log
Extend the usage log row with cache creation and cache read tokens, and the percentage of input served from cache.
Done check
By eye — localhost:3000
The cache-hit percentage rises across a conversation.
60
Demonstrate structured outputs
Add a small panel that asks Claude to extract { topic, difficulty, keywords } from the last message using output_config.format, and render the parsed object.
Done check
By eye — localhost:3000
The panel shows valid JSON matching the schema, on several different questions.
61
Add a thinking toggle
Add a checkbox sending thinking: { type: "adaptive", display: "summarized" }. Render thinking deltas in a muted style above the reply. Comment that the default display is omitted.
Done check
By eye — localhost:3000, thinking toggled on
A hard question shows a reasoning summary above the reply.
62
Add an effort selector
Add a low / medium / high control setting output_config.effort, and log the chosen level with the resulting output token count so the trade-off is visible.
Done check
By eye — localhost:3000
The same question at low and at high effort shows clearly different output token counts.
PHASE 10
Teaching polish
Make it usable by someone who did not build it.
63
Audit every file for its header comment
Walk each source file and confirm it opens with a short plain-language overview: what it does, why it exists, which concept it teaches.
Done check
npx tsx scripts/audit-comments.ts
Reports every .ts file as having a header comment, with no misses.
64
Add a preset questions row
Add 5 buttons that fill the composer with a question chosen to exercise a specific pattern or tool, so a class can start without typing.
Done check
By eye — localhost:3000
Each of the five buttons fills the composer, and each one triggers the behaviour it was written for.
65
Add clear-chat and export-log buttons
Clear resets the transcript, history array, and panel. Export copies the log as JSON to the clipboard for discussion after an exercise.
Done check
By eye — localhost:3000
Clear empties transcript, history and log panel. Export puts valid JSON on the clipboard — paste it somewhere to confirm.
66
Show a running cost estimate
Keep a session total of tokens and multiply by the model's published rates. Display it in the header and comment that rates change.
Done check
By eye — localhost:3000
The cost figure rises after each message and returns to zero on Clear.
67
Write the README
Cover setup, where to put the key, how to run, what each pattern teaches, what each tool demonstrates, and a suggested lesson order.
Done check
# in a scratch folder, away from this one:
git clone fresh-test && cd fresh-test
# now follow your own README, start to finish
The app runs without you needing any knowledge that is not written down.
68
Final pass — typecheck and walkthrough
Run npm run typecheck clean, then exercise all seven patterns and all six tools once, watching for silent failures in the log.
Done check
npm run typecheck
# then exercise all seven patterns and all six tools in the UI
Zero type errors, and no unexpected error rows anywhere in the walkthrough.
If a step stalls
Paste the step back and ask for a split. A step is worth splitting when any of these is true:
It touches more than two files at once.
The done-check needs several separate things to be true.
You cannot see a result until the whole step is finished.
It has run past 30 minutes and is not nearly done.
Steps 41, 53 and 54 are the likeliest candidates — loops and multi-call patterns have the most places to go wrong.