Skip to main content
A guided tour of the open-source FastH3 episodes reference app, which demonstrates the shape any multi-scene FastH3 client takes: a composer that writes scenes, a lazy connection that comes up only when something is queued, an enqueue chain that hands each scene the previous one’s final frame, and a live queue panel that mirrors the model’s state without its own bookkeeping. By the end you’ll know how the typed SDK drives that shape end to end. A second example, fast-h3-livestream, solves the multi-viewer broadcast shape — a Python streamer drives the same model into a LiveKit room while a minimal viewer plays it. This tutorial stays on the single-browser starter; if what you want is a 24/7 channel with many viewers, start from the livestream example instead.

Installation and setup

Get the example running locally before reading further. Every section below points back at code in the repo you just cloned. You will need:
  • Node.js 18+.
  • pnpm (the example pins lockfiles to pnpm; npm or yarn will work but you’ll regenerate the lockfile).
  • A Reactor API key (starts with rk_).
  • Optional: an OpenAI-compatible API key if you want the AI scene writer. The app works fully without one — a curated example episode is bundled.
  • Familiarity with the Next.js App Router.
1

Clone the example

The example lives alongside the other reference apps in reactor-team/js-sdk under examples/.
2

Add your API keys

Your rk_… key must never reach the browser; the example reads it server-side and mints a short-lived JWT for the client. Drop both keys into .env.local:
See a “Setup Required” screen? Your REACTOR_API_KEY isn’t loaded. The check lives in app/page.tsxapp/SetupRequired.tsx.
3

Install dependencies and run

Open http://localhost:3000. Type any episode idea, load the curated example, or write scenes by hand — nothing touches the model until you click Queue episode.

How FastH3 works in this app

The fastest way to read the example is as four self-contained groups of pieces, one per surface. No context provider hides state; every component holds what it renders.
  • FastH3App.tsx wires the <FastH3Provider> at the root with a memoized token resolver and lays out the page. Auth lives here as one server route plus one resolver — nothing else in the app ever sees a key.
  • EpisodeComposer.tsx is the app’s signature flow. It turns an idea into a list of scenes (by hand, by AI writer, or from the curated example) and then queues the episode — connecting on demand, capacity-gating against a fresh snapshot, and enqueuing the scenes chained.
  • QueuePanel.tsx and NowPlaying.tsx are pure mirrors. They subscribe to queue_update and state_update and render what the model says; they keep no local queue state of their own. The metadata echo carries episode titles and scene numbers across broadcasts end to end.
  • SnapClip.tsx, StatusBadge.tsx, CommandError.tsx, Video.tsx are self-contained slices — a clip recorder, a connection indicator, a refusal log, and the video surface — each one file, each one reading from the same hooks.
That decomposition is the app’s stance: each panel renders from the wire and nothing else.

Authentication

FastH3 opens a long-lived WebRTC session, so the browser never holds your rk_… key. The example mints a short-lived, session-scoped JWT server-side — app/api/reactor/token/route.ts exchanges the key for a token limited to reactor/fast-h3 and exactly one concurrent session:
app/api/reactor/token/route.ts
The route is no-store on purpose — a dropped-then-refetched cache entry 403s every later hop, because a session can only be operated by the exact token that created it. The resolver in FastH3App.tsx memoizes the token in module scope and coalesces parallel fetches:
app/FastH3App.tsx
Note the deliberate missing piece: no autoConnect on the provider. Nothing connects until the user has composed something to queue — the connection indicator in StatusBadge shows what the session is doing honestly rather than spinning a mystery.

Writing an episode: the composer

EpisodeComposer is the tutorial’s main course. An episode is 1–6 scenes; each scene is one clip; continue_from_clip_id is what turns the whole list into one continuous video. The composer’s enqueue loop is worth reading end to end:
app/components/EpisodeComposer.tsx (queueEpisode, abridged)
Three load-bearing patterns to notice:
  1. await connect() inside the queue action. The connection exists only once there is something to build.
  2. A fresh getState() before the loop. generation_capacity is deployment-configured; never hardcode that the queue can hold 20 clips. If the gate says no, the whole episode is refused before any scene gets in.
  3. previousClipId = reply.clip.clip_id threads the chain. The reply type is clip_queued, and its clip.clip_id is what the next scene’s continue_from_clip_id names. Enqueue order sets queue order, so the source always builds first — and a continuation whose source hasn’t built yet waits for it (no command_error), so back-to-back enqueue of source then sequel is safe.
The AI writer behind /api/upsample is one POST away from the same place — the route’s system prompt carries the scene rules so the LLM emits prompts the model can take verbatim; the writeWithAi and writeByHand paths land you at the same editable scene list before any wire call happens.

The hard-cut rule

Chained scenes share one continuous video surface: each clip opens on the previous clip’s final frame. That is powerful and easy to get wrong. The example enforces the same discipline across the curated example, the composer’s guidance text, the AI writer’s system prompt, and the prompt guide:
  1. Every scene is fully self-contained. The model reads only this scene’s text; setting, subjects, style, and light are re-described verbatim in every scene.
  2. Every scene after the first opens on a described hard cut — a new shot with a clearly different camera angle, distance, or location, written with the cut itself: “Hard cut to a wide shot of …”. A chain written as one continuous take degrades scene over scene until the picture smears and repeats.
The example’s curated episode (app/lib/prompts.ts, The Clockmaker’s Storm) demonstrates the shape: scene 1 establishes, scenes 2 and 3 each open “Hard cut to …” and re-describe everything from a new angle.

Rendering: the state-snapshot pattern

Every panel that shows session state holds the snapshot itself:
The useEffect is mandatory: the SDK sends no final snapshot on disconnect, so without it a panel shows the previous session’s content after a reconnect. QueuePanel reads queue_update the same way for the two lists, with FastH3QueueUpdateMessage typed likewise. Nothing aggregates clip_started / clip_generated / clip_finished into local queue state — those broadcasts exist for one-shot reactions (labels, toasts, sounds), not for UI bookkeeping. Each of those lifecycle hooks carries a full ClipInfo (clip_id, prompt, metadata, and the continuation / starting-frame fields from the schema’s ClipInfo payload), so a per-type hook gives you the whole clip, not just its UUID.

What’s in the snapshot

FastH3StateUpdateMessage is the model’s full observable state. The fields most components read:

The metadata echo

The composer’s metadata string is the app’s cross-broadcast correlation channel, written as a JSON tag per scene in app/lib/tag.ts:
The model never reads metadata; it echoes it back untouched on every clip-referencing message. QueuePanel uses parseTag(clip.metadata) to render “The Clockmaker’s Storm · scene 2/3”; NowPlaying uses it on clip_started to show what’s on air. A reconnect loses nothing because the tag lives on the wire, not in component state.

Now playing and autoplay

The most instructive small component in the app is 78 lines:
app/components/NowPlaying.tsx (abridged)
One stop button renders as Skip when autoplay is on, because that is what stop does in that configuration — it cuts the current clip and autoplay immediately starts the next built one. This label honesty is the point: state drives what the button means.

Snap a clip

The recording surface is model-agnostic (requestClip / downloadClipAsFile come from the base SDK and the typed hook re-exports them), so no extra package is needed:
app/components/SnapClip.tsx (abridged)
The returned clip URL is short-lived — the download is the artifact, not the URL.

Surfacing command_error

A refused command never throws on the typed SDK: the awaited call resolves undefined and the rejection arrives as a broadcast command_error carrying { command, reason }. The example listens for it and keeps the last few on screen:
app/components/CommandError.tsx (abridged)
In practice: when an enqueue is refused (queue full, prompt over 800 chars, both picture fields set), the composer’s loop sees !reply, surfaces the message next to the episode, and stops — and the CommandError panel shows the model’s own reason. FastH3’s full rejection table lives in the schema.

What’s intentionally left out

The example is opinionated about what viewers need for a first app; every one of these is one small component away once you want it, and the typed methods are ready:

See also