Day 6

Anatomy of an AI app

You ship
WorkBot's conversation core: a streaming CLI chatbot on either provider, with model switching and cost tracking.

What you'll build today

After the first week, WorkBot is a working web app. People file requests, the operations team tracks them in the admin console, and the company's policies are published as pages. None of it uses a model yet, so WorkBot cannot answer a question or help anyone with their work.

Today you give WorkBot its conversation core, starting in the terminal, where it is quick to test. A provider is the company that serves the model, Anthropic or OpenAI today, and each publishes an SDK for its API. You put both SDKs behind one interface, so WorkBot can talk to either provider the same way.

Along the way you learn what goes into a model call and what comes back. You meet the system prompt, the conversation, the settings that shape a reply, streaming, and the tokens you pay for.

By the end of the day you have a terminal chatbot that speaks as WorkBot and follows its source and safety rules. It switches models mid-conversation, reports what the session cost, and passes a small live test against the real API.

By the end of the day you can

  • Use the Anthropic TypeScript SDK to make API calls with full control over the parameters
  • Understand and tune the settings on each call: model, temperature, top-p, max_tokens and stop sequences
  • Implement both streaming and non-streaming responses
  • Apply the core prompt engineering techniques

Topics

Anthropic TypeScript SDK

  • Installation: pnpm add @anthropic-ai/sdk
  • Authentication: API keys, environment variables and key rotation
  • The Messages API: creating messages, and the roles in a conversation (system, user and assistant)
  • Model selection: Claude Opus for complex reasoning, Sonnet for balance, and Haiku for speed and low cost. When to use each
  • Message anatomy: the role, and the content blocks (text, image, tool_use, tool_result)

The Same Call on OpenAI

  • The OpenAI TypeScript SDK: pnpm add openai. The Responses API (client.responses.create) is the current interface, and Chat Completions is the older one
  • Message anatomy: instructions holds the system prompt, input is text or a list of items, and output items carry text and tool calls
  • Model tiers: both providers offer large, standard and small models. Choose one by the task
  • The same settings under other names: temperature, top_p, max_output_tokens
  • Streaming: stream: true, with response.output_text.delta events. Server-Sent Events (SSE) carry the stream, as they do on Anthropic
  • Errors: handle 429 and overloaded responses the same way on both

The Knobs

  • temperature (0 to 1) controls randomness. 0 is deterministic and 1 is creative. Use 0 for extraction and classification, and 0.7 or more for creative tasks. Not every model accepts it: the current Claude models reject it
  • top_p (0 to 1) is nucleus sampling, an alternative to temperature. In general, set one or the other
  • max_tokens is the longest response allowed. Set it from the length of output you expect
  • stop_sequences are strings that end generation when the model writes them. Use them for structured output, and to stop a response that runs on
  • The system prompt holds instructions that shape every response in the conversation. Learn how a good one is built
  • Model-specific behavior: the same prompt performs differently on Opus, Sonnet and Haiku

Streaming vs Non-Streaming

  • Non-streaming: client.messages.create() waits for the full response. It is simple, and suits background processing
  • Streaming: client.messages.stream() delivers tokens as the model generates them. A chat interface needs it
  • SSE: the protocol behind streaming
  • ReadableStream: the stream API in browsers and Node.js for processing chunks
  • Stream events to handle: message_start, content_block_delta, message_stop
  • When to use each: streaming when a person is waiting for the reply, non-streaming for batch and background work

Prompt Engineering

  • System prompts: set the behavior, persona, constraints and output format
  • Few-shot prompting: give examples of the input and output you want
  • Chain-of-thought: ask the model to reason step by step before it answers
  • Structured output: get JSON, XML or another format back reliably
  • Prompt patterns: assign a role, break the task into steps, and state the constraints

Error Handling and Cost

  • Rate limits (429): exponential backoff and retry strategies
  • Overloaded (529): the provider's servers are throttling requests. Queue the request and retry
  • Token counting: estimate input and output tokens, and read the usage field in the response
  • Cost calculation: tokens times the model's price. Track cost from the first day

Build

Features shipped today

  • WorkBot: The conversation core, a command-line chatbot with WorkBot's system prompt: an operations persona, safety rules and a source policy. It has flags for the provider and the model, and a cost command. It stays in WorkBot as the admin assistant in the terminal

Build a command-line chatbot with these features:

  • Choose the provider (Anthropic or OpenAI) and the model with command-line flags
  • Set the temperature with a flag
  • Stream output to the terminal, handling each chunk as it arrives
  • Keep the conversation history across turns
  • A /cost command that shows the tokens used and the estimated cost of the session
  • A /model command to switch the provider or the model in the middle of a conversation
  • A /system command to update the system prompt
  • Handle errors gracefully, and retry on rate limits

Step by step

Two SDKs behind one interface

Every later day calls a model through the code you write here, so start with one interface that both providers fit behind. Each SDK has its own request and response shapes, and the interface hides those differences from the rest of WorkBot.

Study first

  • In Anthropic's Messages API, learn system, messages and max_tokens, how client.messages.stream streams a reply, and where the final message reports its usage.
  • In OpenAI's Responses API, learn the matching fields, instructions, input and max_output_tokens, along with its streaming events and the usage on the completed response.
  • Learn the sampling controls. A model that reasons takes a reasoning effort, output_config.effort for Claude (effort) and reasoning.effort for OpenAI (reasoning models). A model that still accepts temperature takes a value from 0 to 1, but the current Claude models reject it.

Build

  1. Create an API key in the Anthropic console and put it in .env.local as ANTHROPIC_API_KEY. Add OPENAI_API_KEY as well if you have an OpenAI key.
  2. Write src/lib/providers.ts around a Provider type, which has a name, a list of Models and a stream(completion, onText) method that resolves with the tokens used. Each Model carries its id and its published price per million tokens, and the first model in the list is the default.
  3. In the same file, write one factory per SDK, anthropic() and openai(), each of which returns a Provider. Pass maxRetries: 3 to each SDK's constructor, so the SDK retries rate limits and server errors for you.
  4. Test both factories without the network. Give each SDK a fake fetch that returns an event stream you write from the SDK's own event types. Then check that text deltas arrive in order, that usage comes from the final event, and that the request body carries the right fields.

Done when

  • pnpm test passes for both providers.
  • A call to Haiku, Anthropic's small model, through anthropic().stream prints a reply.
Technical notes
  • Prices live on the model list and nowhere else. A model without a published price has none, so the cost report names it instead of guessing.
  • Both SDKs retry 429 and 5xx responses with backoff, including Anthropic's 529 when the API is overloaded. After the third retry, the error reaches your code.
  • The OpenAI stream ends with one of 3 events. response.completed is a finished reply, and response.incomplete means the reply was cut off but still reports usage. response.failed carries the API's message, which the provider throws as an error.
  • Create each SDK object on first use rather than when the provider is chosen. Switching to a provider without a key then fails at the request, where the error can be shown, and not at the switch.

Write the system prompt

With a provider in place, WorkBot needs to know who it is and what it may say. The system prompt is the instruction the model reads before every conversation, so it is where WorkBot's persona, source policy and safety rules live.

Study first

Build

  1. Write src/lib/system-prompt.ts in 4 parts, as prose a person can review. Start with who WorkBot is and whom it serves.
  2. In the sources part, tell WorkBot to answer policy questions from company documents and to name the document it used. When no document covers a question, WorkBot says so and names the team to ask.
  3. In the safety part, rule out limits or deadlines WorkBot cannot source, and personal details passed from one employee to another. Tell WorkBot to make no approvals or commitments and to send incidents and HR matters to the responsible team, then end with the style.
  4. Once the terminal chat from the next part runs, test the prompt with 2 questions WorkBot must decline. Ask for the refund limit a support agent can approve, which no document covers yet, and for a colleague's salary. Each reply should decline and name a team.

Done when

  • The prompt is under 200 words.
  • Both questions get a refusal that names a team to ask, never a figure or a personal detail.
A first conversation
WorkBot on the small model, with no documents yet, declines to state a limit it cannot source and names the team to ask. The /cost command prices the session by the model that answered.
Technical notes
  • You write the source policy before any retrieval exists. On Day 7, tools start giving the model documents to cite.
  • A prompt is a product decision, so keep it in one file with no logic around it.

A chatbot in the terminal

Now put the provider and the prompt to work in a loop you can talk to. A session object holds the conversation and what it cost, and the terminal program is a thin loop around it, which keeps the session easy to test on its own.

Study first

  • Learn Node's readline promises API with for await, which reads one line per pass. The loop awaits each reply before it reads the next line, so WorkBot answers questions in the order they arrive.
  • Learn parseArgs from node:util, which reads the command-line flags.

Build

  1. Write src/lib/session.ts with a Session that holds the provider, the model, the system prompt, the message history and a list of turns with their usage.
  2. Give the session send(text, onText), which streams one reply and keeps both messages, so the next turn sees the whole conversation. A turn that fails leaves nothing in the history.
  3. Add use(provider, model) to switch models mid-conversation, and cost() to add up tokens and dollars, pricing each turn by the model that answered it.
  4. Write cli/chat.ts with the flags --provider, --model, --effort, --temperature and --max-tokens.
  5. Add its commands. /model [provider] [model] switches models, or lists them when you give no arguments, and /system [text] shows or replaces the prompt. /cost reports tokens and dollars, and /help and /exit do what their names say.
  6. Add a chat script that runs the program with Node's --env-file-if-exists=.env.local (Node's command-line options), so it reads your keys from that file.
  7. Test the session with a fake provider, covering the history, the cost, the failed turn and the model switch.

Done when

  • pnpm chat holds a conversation, and /model openai gpt-5.4-mini continues the same conversation on the other provider.
  • /cost reports tokens and dollars, the dollars being tokens times the model's published price per million, and it names any unpriced model.
Technical notes
  • Lines come through the async iterator, so the loop works with piped input as well as typed input. printf 'question\n/exit\n' | pnpm chat runs a scripted session, which is how the screenshot above was made.
  • The program writes its prompt string only when stdin is a terminal, so piped output stays clean.
  • /model accepts a model id that is not on the list. The session uses it anyway and counts it as unpriced.

Live tests on the real API

Unit tests with fake streams prove your code, but they cannot show that the real API still accepts what you send. A live suite fills that gap with a few tests that call the real API on the small model. Run it after you change the prompt or a provider.

Study first

Build

  1. Write vitest.live.config.ts, which includes src/**/*.live.test.ts, reads .env.local into test.env with a 6-line parser and allows 3 minutes per test. Then make the unit config exclude the live files.
  2. Write src/lib/providers.live.test.ts. It sends "What color is a clear daytime sky?" to claude-haiku-5-5, the small model on Anthropic's list, with "Answer in one word." as the system prompt. The test passes when the reply contains "blue" and both usage counts are above zero.
  3. Add pnpm test:live to package.json, and add a line to AGENTS.md that says when to run it.

Done when

  • pnpm test stays offline and green.
  • pnpm test:live passes with your key and costs under $0.01.
The live suite
pnpm test:live runs the provider test against the real API, on the small model. The unit suite never touches the network.
Technical notes
  • The live suite uses the small model on purpose, because it proves that the integration works, not that the answers are good.
  • The config reads .env.local itself, while in CI the secret comes from the environment instead.
  • testTimeout: 180_000 allows those 3 minutes, because a turn that calls tools, from Day 7 on, can take minutes.

Resources