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
1
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.
2
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.
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.
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.
3
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.
4
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
Learn Vitest's --config flag, from the command-line guide, and its include option, which together let one project hold two separate suites.
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.
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.