Concepts
The AgentEvent stream, the bounded loop, and the ports that make it all swappable.
The AgentEvent stream
Every turn is an async stream of a typed union, AgentEvent. The same type is produced on the server and consumed in the browser:
type AgentEvent =
| { type: 'text'; delta: string }
| { type: 'tool_call'; id: string; name: string; args: unknown; mutates: boolean }
| { type: 'tool_result'; id: string; name: string; ok: boolean; result?: unknown; error?: string }
| { type: 'usage'; usage: Usage; costUsd: number | null }
| { type: 'done'; text: string; usage: Usage; costUsd: number | null }
| { type: 'error'; error: string }
| { type: 'custom'; kind: K; data: C[K] }; // generative UI — see belowIt's serialized to SSE with encodeAgentEvent and parsed back on the client. The union is open: declare your own custom event kinds → payloads and carry generative UI end-to-end, fully typed.
The bounded loop
runAgent (and the createAgent / createAgentHandler facades over it) drives a bounded loop: call the model, stream text, execute any tool calls, feed results back, repeat until the model stops or a turn cap is hit. Unknown tools, invalid arguments, and thrown handlers all come back as { ok: false, error } so the model can self-correct rather than crash the run.
const agent = createAgent({
provider, // an LlmClient
tools, // a tool registry
monitor: { operation: 'chat', provider: 'anthropic', model, sink: (m) => telemetry.recordLlmCall(m) },
});
for await (const ev of agent.stream(messages, { ctx }))
await stream.write(encodeAgentEvent(ev));Ports & adapters
Everything swappable is an injectable interface. Provide your own, or use the built-ins.
| Port | Built-ins | Swap for |
|---|---|---|
LlmClient | Anthropic, OpenAI-compatible, MockLlmClient | any provider / a fake for tests |
ToolSource | Zod ToolRegistry, MCP client | hand-rolled JSON-Schema tools |
ConversationStore | in-memory | Postgres, KV, D1 |
TelemetryStore | in-memory, Drizzle/SQLite | your warehouse |
Tracer | recording, noop | OpenTelemetry, Langfuse |
Because the loop only depends on these interfaces, the same agent code runs against a real provider in production and a scripted MockLlmClient in tests.