> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reactor.inc/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Reactor hosts multiple models, each with its own connect slug (modelName) and command/event schema. The catalog of every model — slug, typed SDK package, and links to its schema — is at /model-api-reference/overview. Some models expose one slug per experience (e.g. HappyOyster); always take the slug from the model's own pages, never guess it.
> Fastest path to a working app: `npx create-reactor-app my-app --model=<slug>` scaffolds a complete app with secure auth wired up. Typed TypeScript SDKs are published as @reactor-models/<model>; Python uses the base reactor-sdk package.
> Auth: exchange an API key (rk_...) for a JWT via POST https://api.reactor.inc/tokens from your server. Never put the API key in client-side code.
> Append .md to any docs URL for clean Markdown. Search these docs via the MCP server at https://docs.reactor.inc/mcp.

# FastH3 tutorial

> A guided tour of the open-source FastH3 episodes example — compose, chain, play, snap.

A guided tour of the open-source
[FastH3 episodes reference app](https://github.com/reactor-team/js-sdk/tree/main/examples/fast-h3),
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`](https://github.com/reactor-team/js-sdk/tree/main/examples/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](https://pnpm.io/installation) (the example pins lockfiles to pnpm; `npm` or `yarn` will
  work but you'll regenerate the lockfile).
* A [Reactor API key](/authentication) (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](https://nextjs.org/docs/app).

<Steps>
  <Step title="Clone the example">
    The example lives alongside the other reference apps in
    [`reactor-team/js-sdk`](https://github.com/reactor-team/js-sdk) under `examples/`.

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
    git clone https://github.com/reactor-team/js-sdk
    cd js-sdk/examples/fast-h3
    ```
  </Step>

  <Step title="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`:

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
    cp .env.example .env.local
    # then edit .env.local and set REACTOR_API_KEY to your API key
    # (and OPENAI_API_KEY to an OpenAI-compatible key, if you want the AI writer)
    ```

    <Tip>
      See a "Setup Required" screen? Your `REACTOR_API_KEY` isn't loaded. The check lives in
      `app/page.tsx` → `app/SetupRequired.tsx`.
    </Tip>
  </Step>

  <Step title="Install dependencies and run">
    ```bash theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
    pnpm install
    pnpm dev
    ```

    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**.
  </Step>
</Steps>

## 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:

```typescript app/api/reactor/token/route.ts theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
import { NextResponse } from "next/server";

const TOKEN_LIFETIME_SECONDS = 3600;

export async function POST() {
  const apiKey = process.env.REACTOR_API_KEY!;
  const res = await fetch("https://api.reactor.inc/tokens", {
    method: "POST",
    headers: {
      "Reactor-API-Key": apiKey,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      expires_after: TOKEN_LIFETIME_SECONDS,
      authorization_details: [
        {
          type: "session",
          resources: { models: { match: ["reactor/fast-h3"] } },
          max_sessions: 1,
        },
      ],
    }),
  });
  const { jwt, expires_at } = await res.json();
  return NextResponse.json(
    { jwt, expires_at },
    { headers: { "Cache-Control": "private, no-store" } },
  );
}
```

The route is [`no-store`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)
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:

```tsx app/FastH3App.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
import { FastH3Provider } from "@reactor-models/fast-h3";

const TOKEN_REFRESH_SKEW_MS = 60_000;
let cachedToken: { jwt: string; expiresAtMs: number } | null = null;
let inflightToken: Promise<string> | null = null;

async function fetchToken(): Promise<string> {
  if (cachedToken && Date.now() < cachedToken.expiresAtMs - TOKEN_REFRESH_SKEW_MS) {
    return cachedToken.jwt;
  }
  if (inflightToken) return inflightToken; // coalesce parallel hops
  inflightToken = (async () => {
    try {
      const r = await fetch("/api/reactor/token", { cache: "no-store" });
      const { jwt, expires_at } = await r.json();
      cachedToken = { jwt, expiresAtMs: expires_at * 1000 };
      return jwt;
    } finally {
      inflightToken = null;
    }
  })();
  return inflightToken;
}

export function FastH3App() {
  return <FastH3Provider jwtToken={fetchToken}>{/* … */}</FastH3Provider>;
}
```

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:

```tsx app/components/EpisodeComposer.tsx (queueEpisode, abridged) theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { status, connect, enqueue, setAutoplay, getState } = useFastH3();

async function queueEpisode() {
  // Lazy connect: compose offline; the session starts only now.
  if (status !== "ready") await connect();

  // Capacity-gate against a FRESH snapshot — a partly queued episode is
  // worse than a clear refusal up front.
  const state = await getState();
  if (state) {
    const free = state.generation_capacity - state.generation_queued;
    if (free < scenes.length) {
      throw new Error(`The generation queue has ${free} free slot(s)…`);
    }
    // Autoplay makes the playout queue self-starting, and it is what
    // performs the seamless chained handover between an episode's scenes.
    if (!state.autoplay) await setAutoplay({ enabled: true });
  }

  const episode = crypto.randomUUID().slice(0, 12);
  let previousClipId: string | undefined;
  for (const [index, prompt] of scenes.entries()) {
    const reply = await enqueue({
      prompt,
      metadata: makeTag({ episode, title, scene: index + 1, scenes: scenes.length }),
      // Scene 1 opens from text; every later scene opens on the previous
      // scene's final frame. Hard-cut prompts are what keep the chain sharp.
      ...(previousClipId ? { continue_from_clip_id: previousClipId } : {}),
    });
    if (!reply) {
      // Refused — the broadcast command_error carries the model's reason.
      throw new Error(`Scene ${index + 1} was refused; the episode stops here.`);
    }
    previousClipId = reply.clip.clip_id;
  }
}
```

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](/model-api-reference/fast-h3/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:

```tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { status } = useFastH3();
const [snapshot, setSnapshot] = useState<FastH3StateUpdateMessage | null>(null);
useFastH3StateUpdate((msg) => setSnapshot(msg));

useEffect(() => {
  if (status !== "ready") setSnapshot(null);
}, [status]);
```

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:

| Field                       | Type                   | Used for                                                                              |
| --------------------------- | ---------------------- | ------------------------------------------------------------------------------------- |
| `playing`                   | `bool`                 | Now-playing indicator                                                                 |
| `playing_clip_id`           | `string \| null`       | Which clip is on air                                                                  |
| `autoplay`                  | `bool`                 | The autoplay toggle's checked state                                                   |
| `generation_queued`         | `int`                  | The "building" row of the queue panel                                                 |
| `generation_capacity`       | `int`                  | Capacity gate when queueing an episode                                                |
| `playout_queued`            | `int`                  | The "ready" row of the queue panel                                                    |
| `playout_capacity`          | `int`                  | Show when the playout lane is at capacity                                             |
| `clip_seconds`              | `float`                | Default scene length (per-`set_clip_seconds`)                                         |
| `clip_seconds_min/max`      | `float`                | Bounds for a scene-length picker — never hardcode                                     |
| `seed`                      | `int`                  | Current default seed                                                                  |
| `width`, `height`, `aspect` | `int`, `int`, `string` | Canvas geometry for overlays                                                          |
| `flush_on_clip_end`         | `bool`                 | Whether non-handoff boundaries cut to black or hold the last frame                    |
| `clips_played`              | `int`                  | Count of clips finished or stopped                                                    |
| `seconds_sent`              | `float`                | Total streamed seconds since the session began — drive a per-episode timer off deltas |
| `valid_commands`            | `string[]`             | Which buttons to enable right now — read this, don't re-derive the rules client-side  |

## 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`:

```typescript theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
export interface EpisodeTag {
  episode: string;
  title: string;
  scene: number;
  scenes: number;
}
```

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:

```tsx app/components/NowPlaying.tsx (abridged) theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { status, stop, setAutoplay } = useFastH3();
useFastH3ClipStarted((msg) => {
  const tag = parseTag(msg.clip.metadata);
  setLabel(tag ? `${tag.title} — scene ${tag.scene}/${tag.scenes}` : msg.clip.prompt.slice(0, 60));
});

if (!snapshot.playing)
  return <p>The stream holds on black between clips. Queue an episode … to roll.</p>;

return <button onClick={() => void stop()}>{snapshot.autoplay ? "Skip" : "Stop"}</button>;
```

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:

```tsx app/components/SnapClip.tsx (abridged) theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { requestClip, downloadClipAsFile } = useFastH3();

const clip = await requestClip(10); // last 10 seconds of the live stream
await downloadClipAsFile(clip, "fast-h3-clip.mp4");
```

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:

```tsx app/components/CommandError.tsx (abridged) theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
useFastH3CommandError((msg) =>
  setErrors((prev) => [`${msg.command}: ${msg.reason}`, ...prev].slice(0, 3)),
);
```

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](/model-api-reference/fast-h3/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:

| Feature                                                          | Typed method / pattern                                                                               | Where it belongs                 |
| ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------- |
| Per-scene length                                                 | `enqueue({ seconds })` / `setClipSeconds`                                                            | composer, per scene              |
| Per-scene seeds                                                  | `enqueue({ seed })` / `setSeed`                                                                      | composer                         |
| Canvas / aspect change                                           | `setCanvas`                                                                                          | setup only                       |
| Starting-frame image open (image-to-video)                       | `enqueue({ starting_frame })` + `uploadFile`                                                         | a new composer path              |
| Flush behavior (black vs. hold-last-frame between clips)         | `setFlushOnClipEnd`                                                                                  | now-playing                      |
| Reorder the queue                                                | `move`                                                                                               | queue panel                      |
| 640-tier canvas                                                  | `setCanvas`                                                                                          | arrives with the continuity tier |
| Live broadcast version (many viewers, Python streamer + LiveKit) | [`fast-h3-livestream`](https://github.com/reactor-team/js-sdk/tree/main/examples/fast-h3-livestream) | a separate example, not a knob   |

## See also

* [FastH3 overview](/model-api-reference/fast-h3/overview) — model specs, key features, quick start
* [FastH3 schema](/model-api-reference/fast-h3/schema) — every command, event, and ClipInfo field
* [FastH3 prompt guide](/model-api-reference/fast-h3/prompt-guide) — writing clips the model renders
  well, including the hard-cut rule
* [Typed Model SDKs](/sdk-reference/typed-model-sdk) — what the `@reactor-models/<model>` pattern
  gives you on top of the base SDK
