Coding AI Agents in TypeScript: Lesson 1 (Sessions 1–4)
Build a minimal TypeScript CLI agent loop from scratch: env config, pluggable LLM provider, SSE token streaming, interactive chat, and deterministic tests with a local mock server.
This is the first post in a series that builds agent fundamentals the boring-but-essential way: a clean TypeScript CLI baseline, a provider abstraction (so you don’t vendor-lock your entire app), streaming via SSE, and tests that don’t require paid API calls.
Series Navigation
| Lesson | Topics | Status |
|---|---|---|
| Lesson 1 (Sessions 1–4) | CLI + Provider + Streaming + Tests | You’re here |
| Lesson 1 Add-on (Sessions 5–6) | Local Providers → | Ollama + LM Studio |
TL;DR — What You’ll Build
By the end of Session 4, you’ll have a tiny CLI called agent-playground that:
- ✅ Parses commands (
run,chat,--help) and validates env vars - ✅ Streams tokens from an OpenAI-compatible endpoint (SSE)
- ✅ Runs an interactive chat loop with transcript + streaming output
- ✅ Has deterministic tests via
vitestand a local mock SSE server
💡 Mental model that scales: You’ll internalize the pattern of orchestrator + provider + UI — a foundation that makes later agent features (tools, memory, routing, evals) plug-and-play.
The Architecture
A “first agent” isn’t a framework. It’s three small parts that agree on a contract:
flowchart LR
CLI[CLI UI] --> O[Orchestrator]
O -->|ChatRequest| P[LLM Provider]
P -->|LLMEvent stream| CLI
P -->|SSE over HTTP| API[(OpenAI-compatible server)]
| Component | Responsibility |
|---|---|
| UI (CLI) | Reads input, prints output |
| Orchestrator | Decides what messages to send, manages transcripts |
| Provider | Speaks vendor HTTP, exposes normalized stream of events |
⚡ Why this matters: Making these boundaries explicit early is what makes “agent-y” features (tools, memory, routing, evals) feasible later.
Prerequisites
Before diving in, make sure you have:
- Node.js 24+ (or any version with solid
fetchandReadableStreamsupport) - pnpm package manager
- Comfort with TypeScript + ESM
Lesson Structure
Lesson 1 is split into checkpoint projects — each folder is runnable on its own:
learning-paths/coding-ai-agents/lesson-1/
├── session-1/ ← CLI foundation (no AI yet)
├── session-2/ ← Provider abstraction + streaming
├── session-3/ ← Interactive chat loop
└── session-4/ ← Deterministic tests with mock server
Each session keeps the same conceptual architecture while adding one major capability.
🚀 Shortcut: Want the fastest path? Start from Session 4 and read backwards.
Session 1 — TypeScript CLI Foundation
Session 1 is intentionally non-AI. That’s the point. We establish patterns that matter.
Step 1: Install and Run
cd learning-paths/coding-ai-agents/lesson-1/session-1
pnpm install
pnpm dev -- --help
pnpm dev -- run --prompt "hello"
pnpm dev -- chat
You should see:
- A help screen listing commands + env vars
- Placeholders for
runandchat(no provider calls yet)
Step 2: The CLI Entry Pattern
Your CLI should have three things:
- A small command parser
- A single top-level error boundary
- A single place to parse env vars
Reference implementation in src/cli/main.ts:
run().catch((error: unknown) => {
process.stderr.write(`Error: ${formatUnknownError(error)}\n`);
process.exitCode = 1;
});
🎯 Why this matters: Streaming failures, timeouts, and config mistakes should become friendly CLI errors, not stack traces.
Step 3: Validate Env Vars Once, Early
Session 1 defines helpers like getRequiredEnv, getBooleanEnv, and getNumberEnv in src/config/env.ts.
The key pattern:
Read env once → Return typed config object → Pass it down
Session 2 — Provider Abstraction + Token Streaming
Now we add real AI without turning the CLI into vendor-specific glue code.
Step 1: Install and Run
cd learning-paths/coding-ai-agents/lesson-1/session-2
pnpm install
pnpm dev -- --help
# One-shot streaming
pnpm dev -- run --prompt "Say hello"
Requirements:
OPENAI_API_KEYfor real calls, orOPENAI_BASE_URLpointing at a local OpenAI-compatible endpoint
Step 2: The Provider Contract ⭐
This is the big move. Session 2 introduces a tiny interface in src/llm/provider.ts:
export type LLMEvent =
| { type: "token"; token: string }
| { type: "done" }
| { type: "error"; message: string; cause?: unknown };
export interface LLMProvider {
readonly name: string;
streamChat(request: ChatRequest): AsyncIterable<LLMEvent>;
}
🔑 Everything downstream becomes easier when your UI only cares about
LLMEvent.
Step 3: SSE Parsing — Why Streaming Feels Weird
OpenAI-style streaming is SSE: you read a long-lived HTTP response, split frames on \n\n, and parse data: lines.
Session 2’s minimal parser in src/util/sse.ts yields { data: string } frames:
const boundaryIndex = buffer.indexOf("\n\n");
// ...
if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
⚠️ Key insight: One chunk ≠ one token. The network can split events anywhere.
Step 4: The OpenAI Provider Implementation
The provider in src/llm/providers/openai.ts:
- Builds an HTTP request
- Uses
AbortControllerfor timeouts - Iterates SSE frames
- Normalizes them into
LLMEvents
Core loop (simplified):
for await (const ev of parseSSE(res.body)) {
if (ev.data === "[DONE]") {
yield { type: "done" };
return;
}
const chunk = JSON.parse(ev.data) as OpenAIChatCompletionsChunk;
const token = chunk.choices?.[0]?.delta?.content;
if (token) yield { type: "token", token };
}
Step 5: Wire Streaming Into the CLI
Session 2’s CLI prints tokens as they arrive:
process.stdout.write("assistant: ");
for await (const ev of provider.streamChat({ messages })) {
if (ev.type === "token") process.stdout.write(ev.token);
if (ev.type === "done") {
process.stdout.write("\n");
break;
}
}
Session 3 — Interactive Chat Loop
Session 3 keeps the Session 2 provider contract unchanged, and adds a proper chat mode.
Step 1: Run It
cd learning-paths/coding-ai-agents/lesson-1/session-3
pnpm install
pnpm dev -- chat
Exit with /exit or Ctrl+D.
Step 2: The Transcript Pattern
The important design choice: Don’t treat chat like magic — treat it like a transcript that grows.
Session 3’s src/chat/interactive-chat.ts:
- Pushes
{ role: "user" }messages intotranscript - Streams assistant tokens
- Pushes a single
{ role: "assistant" }message when done
transcript.push({ role: "user", content: input });
process.stdout.write("assistant> ");
let assistantText = "";
for await (const ev of provider.streamChat({ messages: transcript })) {
if (ev.type === "token") {
assistantText += ev.token;
process.stdout.write(ev.token);
}
if (ev.type === "done") {
transcript.push({ role: "assistant", content: assistantText });
break;
}
}
✨ That’s the core “agent loop” in its simplest useful form.
Session 4 — Deterministic Streaming Tests
Session 4 is the “you’ll thank yourself later” session. Instead of testing against paid APIs, we introduce a local mock server that streams OpenAI-shaped SSE frames.
Step 1: Run Tests
cd learning-paths/coding-ai-agents/lesson-1/session-4
pnpm install
# If pnpm warns about ignored build scripts (e.g., esbuild):
# pnpm approve-builds
pnpm test
Step 2: The Mock SSE Server
Session 4’s test/mock-openai-server.ts creates an HTTP server that responds on /v1/chat/completions and emits deterministic SSE frames:
sendSSE(
res,
JSON.stringify({
choices: [{ delta: { content: "Hello" }, finish_reason: null }],
})
);
sendSSE(res, "[DONE]");
res.end();
🧪 This is “just enough OpenAI” to validate your parser and event normalization.
Step 3: Assert Token-by-Token Ordering
The test in test/openai-stream.test.ts streams into a string and asserts exact output:
for await (const ev of provider.streamChat({ messages })) {
if (ev.type === "token") out += ev.token;
if (ev.type === "done") break;
}
expect(out).toBe("Hello from mock");
This gives you high confidence that:
- ✅ The SSE parser splits frames correctly
- ✅ Your provider extracts
delta.contentcorrectly - ✅ Token order is stable
Troubleshooting
Missing required environment variable: OPENAI_API_KEY
Expected if you’re not using a local mock. Either set OPENAI_API_KEY or point OPENAI_BASE_URL at a mock server.
OpenAI response had no body (streaming unsupported?)
Usually means you’re talking to an endpoint that doesn’t support streaming the same way. Confirm it’s OpenAI-compatible and that stream: true is supported.
Hanging requests
Session 2 uses AbortController + AI_TIMEOUT_MS. If you set this too low, you’ll abort healthy requests. If it’s too high, the CLI feels frozen.
pnpm "Ignored build scripts" warnings
Session 4 calls out pnpm approve-builds as needed, depending on your environment.
Alternatives and Tradeoffs
| Approach | Tradeoff |
|---|---|
| Use a library for SSE parsing | The minimal parser is great for learning, but production may need a hardened parser for edge cases |
| Use a vendor SDK | SDKs reduce boilerplate, but leak vendor types throughout your app. The provider boundary keeps that under control |
| Switch to local-first providers | Later sessions add Ollama/LM Studio. The provider interface exists to make this a drop-in change |
Next Steps
If you completed Sessions 1–4, you’re ready to expand without changing the core contract:
- 🔌 Add local providers behind the same
LLMProviderinterface - 💾 Add a
--save transcript.jsonflag - 📝 Add structured logging when
AI_DEBUG=true - 🧪 Add more tests: timeouts, non-2xx responses, malformed SSE frames
Further Reading
- Node.js Streams API
- WHATWG Streams (ReadableStream)
- MDN: Server-sent events
- Vitest documentation
- OpenAI API docs
Starting a new series on building AI agents in TypeScript — beginning with the fundamentals: a clean CLI, a provider abstraction, SSE token streaming, an interactive chat loop, and deterministic tests with a local mock server.