This repo is one of two small experiments written up in Don't take the model's word for it.
A small Next.js app that turns a streamed LLM response into live UI. You type a prompt; the model streams back a short intro and then emits typed tool-calls that render as on-screen artifacts — a chart, a table, a metric card, a prose block — as they arrive, over a single connection.
▶ Live demo — runs the deterministic stub adapter, so it shows the full streaming-and-render pipeline with canned output (no API key, no token spend). See Deploy for why.
The whole thing is built around one idea worth learning from:
A single typed registry is the source of truth for every artifact. The model's tool definitions, the runtime validation, and the renderer all derive from one object — so there are no parallel lists to keep in sync.
It's a focused example, not a product: no database, no auth, no chat history. Read it to see how streaming + typed tool-calls + a component registry fit together.
pnpm install
cp .env.local.example .env.local # add your ANTHROPIC_API_KEY
pnpm dev # http://localhost:3000Try a prompt like:
"show me a quarterly sales chart, a one-paragraph summary, and a short intro"
You'll see the intro stream in word-by-word, then a bar chart draw itself, then a markdown summary — each appearing as soon as the model emits it.
No API key? Run the deterministic stub adapter — same streaming pipeline, canned output, zero API calls:
MODEL_ADAPTER=stub pnpm devThe repo ships a vercel.json that pins MODEL_ADAPTER=stub, so a
public deploy runs the deterministic stub adapter by default — no API key on the
server, no live token spend, nothing to abuse on the open endpoint. The full
streaming-and-render pipeline still works; only the model output is canned.
- Import the GitHub repo at vercel.com/new.
- Framework preset auto-detects as Next.js — accept the defaults.
- Deploy. No environment variables are required for the stub demo.
To run the live model on a deployment instead, set MODEL_ADAPTER=anthropic and
ANTHROPIC_API_KEY in the Vercel project's environment variables. Only do this behind
rate-limiting or auth — the /api/generate endpoint is otherwise open to the world.
The UI has a mode switcher at the top (deep-linkable via ?mode=):
- LIVE — the prompt-and-render demo above.
- X-RAY — a stepped walkthrough of the render pipeline on one example artifact, so you can watch each stage (wire event → lookup → validate → render).
- REGISTRY — browse every artifact kind alongside its live example, its registry entry, and its schema.
Everything flows out of lib/registry.ts:
lib/schemas.ts Zod schemas — the structural source of truth
│
▼
lib/registry.ts { kind → schema + Component + description }
│
├──▶ lib/tools.ts schemas → tool defs (z.toJSONSchema) for the model
│
└──▶ lib/renderArtifact kind → lookup → safeParse → render (or fallback)
model stream ──▶ app/api/generate ──SSE──▶ useArtifactStream ──▶ renderArtifact
(tool-calls) (ReadableStream) (client) (UI)
The request path, end to end:
- The client POSTs
{ prompt }to/api/generate. - The route asks a
ModelAdapterto stream the response. The adapter normalises provider-specific events into a small set:text-delta,tool-use,done. - The route re-emits those as Server-Sent Events on the wire:
text-delta,artifact,done,error(seelib/wire.ts). useArtifactStream(afetch+ manual SSE reader) builds an ordered timeline.renderArtifact(kind, props)looks thekindup in the registry, validatespropswith the schema'ssafeParse, and renders the matched component.
Because the wire carries untyped data, validation happens at the render boundary. Two fallbacks make failure graceful instead of fatal:
- Unknown
kind(not in the registry) →<UnknownArtifact>. safeParsefails →<InvalidArtifact>, showing the actual Zod issues.
It's two files, and the renderer never changes:
- Add a schema to
lib/schemas.ts. - Add one entry to
lib/registry.ts(look for the// adding another artifactmarker), pairing the schema with a component.
The tool definition, the type union (ArtifactKind), and the runtime validation all
derive automatically. The metric kind was added exactly this way.
| Route | Purpose |
|---|---|
/ |
The live demo. |
/dev |
Static fixtures rendered through renderArtifact() — every artifact plus both fallbacks, no model involved. (dev-only) |
/api/generate |
POST { prompt } → SSE stream of text-delta / artifact / done. |
/api/dev/smoke |
GET → runs the adapter against a fixed prompt, reports per-tool-call schema validity. (dev-only) |
The choices that shape the code, and why each is the way it is:
-
The registry is built with a generic
entry<S>()helper, not a flatRecord. A flatRecord<string, { schema, Component }>collapsesz.infer<schema>tounknownand breaks React's component-prop typing.entry<S>()threads each schema's type through to its component, so the registry refuses to pair (say)TextArtifactwithchartSchemaat compile time. -
Everything derives from the registry.
ArtifactKind, the tool definitions, and runtime validation all read from the same object — the schema the model is prompted with is provably the schema it's validated against. (The honest limit: the wire isunknownuntilsafeParse, so the guarantee is single-source derivation + runtime validation, not a compile-time-proven type all the way from wire to component.) -
The renderer is a lookup, not a
switch.renderArtifactdoes a registry lookupsafeParse. Aswitchwould give compile-time exhaustiveness; the lookup trades that for single-source derivation, and a deliberate runtime fallback for unknown kinds (the model can emit any string).
-
Fallbacks, not exceptions. Validation lives only at the render boundary — not on the wire. Validating server-side would stop
<InvalidArtifact>from ever firing, which would defeat the point of showing graceful failure. -
ModelAdapteris a deliberately narrow interface.stream({ system, messages, tools }) → AsyncIterable<NormalisedEvent>. The adapter accumulates partial tool-call JSON internally so the route never sees half-formed input. Narrow enough to implement twice — the realAnthropicAdapterand theStubAdapter. -
Streaming is plain SSE over
POST.EventSourceis GET-only, which would force the prompt into the URL. A manualfetch+ tiny SSE parser is a few lines, supportsAbortControllercancellation, and stays observable incurl -N. -
Schemas live in their own non-
"use client"module. When schemas were co-located with client components, Next.js replaced their exports with client- reference proxies once a server module imported the registry — soschema.safeParsewasundefinedat runtime. Keepinglib/schemas.tsserver-safe fixes that and letslib/tools.tsderive tool defs on the server. -
The chart is a custom inline SVG component, not a chart library. A small
Chart.tsxdriven by a purelib/chartGeometry.ts(data → SVG coordinates) gives the line-draw stream-in animation and the minimal aesthetic without a heavy dependency.
In: four artifact kinds (chart, table, metric, text); the registry and its derived types; Zod validation; tool derivation; streamed tool-use via the Vercel AI SDK; the SSE route and client; progressive render; the two fallbacks; a swappable stub adapter; a Vitest + React Testing Library suite.
Out (on purpose): persistence, auth, rate-limiting, multi-turn chat, multi-agent orchestration, an eval harness, and any artifact needing a third-party API (e.g. maps).
app/
api/generate/route.ts SSE handler: POST prompt → text-delta/artifact/done
api/dev/smoke/route.ts Dev: validates each tool call against its schema
dev/page.tsx Dev: static fixture page (no model)
page.tsx The live demo (mode switcher)
components/
artifacts/ Chart, Table, Metric, Text + Unknown/Invalid fallbacks
plate/ The shell, mode switcher, and X-RAY / REGISTRY views
hooks/
useArtifactStream.ts fetch + manual SSE reader; ordered timeline state
lib/
schemas.ts Zod schemas — single source of structural truth
registry.ts The registry. Adding an artifact = one entry here.
renderArtifact.tsx Lookup + safeParse + fallbacks (no switch)
tools.ts Tool defs derived from the registry; system prompt
wire.ts Shared SSE WireEvent type (server + client)
chartGeometry.ts Pure data → SVG coordinate maths
model/
adapter.ts ModelAdapter interface + event types
anthropic.ts Real adapter — wraps the Vercel AI SDK (streamText)
stub.ts Canned-events adapter for offline demos
- Next.js 16 (App Router) + TypeScript (
strict) + Turbopack - Vercel AI SDK Core (
ai+@ai-sdk/anthropic) for streaming + tool use - Zod 4 (native
z.toJSONSchema— no third-party converter) - Tailwind v4 +
@tailwindcss/typography; custom inline SVG chart; react-markdown - Model:
claude-haiku-4-5by default, set viaANTHROPIC_MODEL
MIT — see LICENSE.

