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;
npmoryarnwill 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:3
Install dependencies and run
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.tsxwires 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.tsxis 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.tsxandNowPlaying.tsxare pure mirrors. They subscribe toqueue_updateandstate_updateand 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.tsxare 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.
Authentication
FastH3 opens a long-lived WebRTC session, so the browser never holds yourrk_… 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
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
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)
await connect()inside the queue action. The connection exists only once there is something to build.- A fresh
getState()before the loop.generation_capacityis 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. previousClipId = reply.clip.clip_idthreads the chain. The reply type isclip_queued, and itsclip.clip_idis what the next scene’scontinue_from_clip_idnames. Enqueue order sets queue order, so the source always builds first — and a continuation whose source hasn’t built yet waits for it (nocommand_error), so back-to-back enqueue of source then sequel is safe.
/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:- 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.
- 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.
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: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’smetadata string is the app’s cross-broadcast correlation channel, written as a JSON
tag per scene in app/lib/tag.ts:
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)
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)
Surfacing command_error
A refused command never throws on the typed SDK: the awaited call resolvesundefined 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)
!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
- FastH3 overview — model specs, key features, quick start
- FastH3 schema — every command, event, and ClipInfo field
- FastH3 prompt guide — writing clips the model renders well, including the hard-cut rule
- Typed Model SDKs — what the
@reactor-models/<model>pattern gives you on top of the base SDK