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.

12 min read tutorial Series: coding-ai-agents-in-typescript
ai-agentstypescriptnodejsclistreamingssevitesttesting

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

LessonTopicsStatus
Lesson 1 (Sessions 1–4)CLI + Provider + Streaming + TestsYou’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 vitest and 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)]
ComponentResponsibility
UI (CLI)Reads input, prints output
OrchestratorDecides what messages to send, manages transcripts
ProviderSpeaks 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 fetch and ReadableStream support)
  • 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 run and chat (no provider calls yet)

Step 2: The CLI Entry Pattern

Your CLI should have three things:

  1. A small command parser
  2. A single top-level error boundary
  3. 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_KEY for real calls, or
  • OPENAI_BASE_URL pointing 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 AbortController for 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:

  1. Pushes { role: "user" } messages into transcript
  2. Streams assistant tokens
  3. 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.content correctly
  • ✅ 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

ApproachTradeoff
Use a library for SSE parsingThe minimal parser is great for learning, but production may need a hardened parser for edge cases
Use a vendor SDKSDKs reduce boilerplate, but leak vendor types throughout your app. The provider boundary keeps that under control
Switch to local-first providersLater 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 LLMProvider interface
  • 💾 Add a --save transcript.json flag
  • 📝 Add structured logging when AI_DEBUG=true
  • 🧪 Add more tests: timeouts, non-2xx responses, malformed SSE frames

Further Reading


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.