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

# Visko Orbis Dynamic tutorial

> An end-to-end Visko Orbis Dynamic walkthrough against the open-source reference frontend.

A guided tour of the open-source
[Visko Orbis Dynamic reference app](https://github.com/reactor-team/js-sdk/tree/main/examples/visko-orbis-dynamic),
which demonstrates every important pattern in the typed SDK. By the end you'll know how to start
scenes from prompts or images, steer a scene live with per-chunk prompts, snap clips, and surface
model errors.

## 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_`).
* 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/visko-orbis-dynamic
    ```
  </Step>

  <Step title="Add your API key">
    Your `rk_…` key must never reach the browser; the example reads it server-side and mints a
    short-lived JWT for the client. We'll cover the broker pattern below; for now, drop the key
    into `.env`:

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
    cp .env.example .env
    # then edit `.env` and set REACTOR_API_KEY to your API key
    ```

    <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`, click **Connect**, and pick a starting point: a curated prompt,
    an example image, or your own text.
  </Step>
</Steps>

## How Visko Orbis Dynamic works

Building with Visko Orbis Dynamic is different from calling a typical generative API. There's no
prompt-in / video-out request. You open a long-lived connection, send a prompt, and the model begins
producing a continuous stream of 33-frame chunks (\~1.833 s each). You **steer** it by sending
`set_prompt` while it runs, and the picture morphs at the next chunk boundary.

Opening the connection isn't instant — placing a Visko Orbis Dynamic session takes longer than a
typical stateless API call, and the client moves through four states before media starts flowing:

<Frame>
  <img src="https://mintcdn.com/reactortechnologiesinc/3wrpLd7R1K3eK0X3/diagrams/connection-lifecycle.svg?fit=max&auto=format&n=3wrpLd7R1K3eK0X3&q=85&s=af84147d94f8e1a3282c87c2a54ba29f" alt="Connection lifecycle: disconnected → connecting → waiting → ready" width="760" height="148" data-path="diagrams/connection-lifecycle.svg" />
</Frame>

The `waiting` state is when the session is being placed. The reference app's `StatusBadge` labels it
honestly rather than spinning a mystery. Once the status reaches `ready`, commands take effect and
chunks start arriving. See [Sessions](/concepts/sessions#connection-lifecycle) for the full
breakdown.

At `ready` the model is connected but idle; it won't produce frames until you set a prompt and call
`start`. From there a small set of SDK methods
([`setPrompt`](/model-api-reference/visko-orbis-dynamic/schema#set_prompt),
[`start`](/model-api-reference/visko-orbis-dynamic/schema#start),
[`pause`](/model-api-reference/visko-orbis-dynamic/schema#pause) /
[`resume`](/model-api-reference/visko-orbis-dynamic/schema#resume),
[`reset`](/model-api-reference/visko-orbis-dynamic/schema#reset)) drives it through the rest of its
lifecycle. Two things to note about those methods:

* **They're asynchronous.** Awaiting `setPrompt` registers the prompt with the model and confirms it
  landed; errors surface as `command_error` rather than thrown exceptions.
* **Errors arrive out-of-band.** A broken precondition like `start` with no prompt surfaces later as
  a `command_error` event, not as a thrown exception.

## Authentication

Visko Orbis Dynamic opens a long-lived WebRTC connection that the server needs to trust for hours.
Shipping a raw `rk_…` key to the browser would hand full account access to anyone with devtools
open, so instead the Reactor SDK presents a [JWT](https://jwt.io/introduction) minted server-side
from your API key. Mint it **session-scoped** (`authorization_details` with `type: "session"`): the
token can only start and operate its own Visko Orbis Dynamic sessions, it is short-lived (1 hour by
default, capped at 6), and it is safe to hand to the client. Your `rk_…` key stays on the server.

That means every Visko Orbis Dynamic frontend needs one server-side route that mints JWTs. In the
example, that route is `app/api/reactor/token/route.ts`, a
[Next.js route handler](https://nextjs.org/docs/app/building-your-application/routing/route-handlers)
that exchanges your `rk_…` key for a JWT and hands the JWT back alongside its server-reported
expiry so the client can memoize it:

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

  // How long the minted token should live, in seconds (Reactor caps this at its
  // server max, currently 6h).
  const TOKEN_LIFETIME_SECONDS = 3600;

  export async function POST() {
    const apiKey = process.env.REACTOR_API_KEY!;
    // ...error handling omitted...

    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,
        // Scope the JWT to Visko Orbis Dynamic sessions: it cannot touch other
        // models, other sessions, or any account API.
        authorization_details: [
          { type: "session", resources: { models: { match: ["reactor/visko-orbis-dynamic"] } } },
        ],
      }),
    });

    const { jwt, expires_at } = (await res.json()) as {
      jwt: string;
      expires_at: number;
    };

    // The client memoizes the token in memory and refreshes it near expiry;
    // no shared or browser cache may serve it. `private, no-store` keeps a
    // per-user JWT out of CDNs, proxies, and the browser HTTP cache.
    return NextResponse.json(
      { jwt, expires_at },
      { headers: { "Cache-Control": "private, no-store" } },
    );
  }
  ```

  ```python server.py theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
  import asyncio
  import os

  from fastapi import FastAPI
  from fastapi.responses import JSONResponse
  from reactor_sdk import DEFAULT_API_URL, fetch_jwt

  app = FastAPI()


  # Mint a short-lived, session-scoped JWT server-side and hand it to the
  # browser. The rk_… key never leaves the server, and the JWT can only
  # start and operate Visko Orbis Dynamic sessions.
  @app.post("/api/reactor/token")
  async def token():
      # fetch_jwt is synchronous — run it in a thread so it doesn't block the event loop.
      jwt = await asyncio.to_thread(
          fetch_jwt,
          api_key=os.environ["REACTOR_API_KEY"],
          api_url=DEFAULT_API_URL,
          models=["reactor/visko-orbis-dynamic"],
      )
      return JSONResponse(
          {"jwt": jwt},
          headers={"Cache-Control": "private, no-store"},
      )
  ```
</CodeGroup>

`ViskoOrbisDynamicApp.tsx` passes a memoizing resolver to `<ViskoOrbisDynamicProvider>`. The SDK
re-invokes the resolver on every Coordinator HTTP hop — uploads, clip manifests, ICE refreshes —
so the resolver has to return the *same* token for the whole life of a session rather than mint a
fresh one per hop. It memoizes `{ jwt, expires_at }` in module scope and only re-fetches when the
current token is about to expire:

```tsx app/ViskoOrbisDynamicApp.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
import { ViskoOrbisDynamicProvider } from "@reactor-models/visko-orbis-dynamic";

let cached: { jwt: string; expiresAt: number } | null = null;

// Memoized in module scope: returns the same JWT until shortly before its
// server-reported expiry. A session-scoped token can only operate sessions it
// created, so every Coordinator hop of a session must present the JWT that
// created the session — a resolver that re-mints per hop fails with a 403
// naming `authorization_details.resources.sessions.bind`.
async function fetchToken(): Promise<string> {
  const nowSeconds = Math.floor(Date.now() / 1000);
  // 60-second skew so we never hand the SDK a near-expired token.
  if (cached && cached.expiresAt - 60 > nowSeconds) {
    return cached.jwt;
  }
  const r = await fetch("/api/reactor/token", { method: "POST", cache: "no-store" });
  if (!r.ok) {
    const body = (await r.json().catch(() => ({}))) as { error?: string };
    throw new Error(body.error ?? `Token fetch failed: ${r.status}`);
  }
  const { jwt, expires_at } = (await r.json()) as { jwt: string; expires_at: number };
  cached = { jwt, expiresAt: expires_at };
  return jwt;
}

export function ViskoOrbisDynamicApp() {
  return (
    <ViskoOrbisDynamicProvider jwtToken={fetchToken}>
      {/* ...app tree... */}
    </ViskoOrbisDynamicProvider>
  );
}
```

The browser HTTP cache is *not* the place to memoize this. A `Cache-Control: max-age` strategy
misses under DevTools "Disable cache", under cache eviction, and behind a shared proxy, and the
miss mints a fresh token with no sessions bound — which the next upload or clip request then
rejects with a 403 naming `authorization_details.resources.sessions.bind`. The in-memory memoize
above is the only cache that holds the token stable for a session's whole life.

<Tip>
  The broker pattern (server mints, client consumes) is the standard for any browser-side Reactor
  app, not just Visko Orbis Dynamic. See [Authentication](/authentication) for the full concept
  page. `jwtToken` accepts either a static string or a resolver; pass the resolver form so the SDK
  never holds a stale string.
</Tip>

## Starting from a prompt

Generation kicks off in two SDK calls: `setPrompt` registers the prompt, then `start` begins
producing chunks. `PromptComposer.tsx` exposes a grid of curated presets and a free-text input, but
every button routes through the same `send` function:

```tsx app/components/PromptComposer.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { setPrompt, start } = useViskoOrbisDynamic();
const [text, setText] = useState("");
// ...status / ready guard omitted...

// The two-call flow every entry point in this panel shares:
//   1. setPrompt registers the prompt
//   2. start begins generation
async function send(prompt: string) {
  await setPrompt({ prompt: prompt.trim() });
  await start();
}
```

The preset text comes from `app/lib/prompts.ts`, a curated scene library that's the single source of
truth for both these prompts and the mid-stream evolutions covered later in this page. Its header
comment is worth reading: each prompt is a full paragraph for a reason, and that style is what lets
the model hot-swap prompts smoothly later.

<Tip>
  `start` requires a prompt. Skip the `setPrompt` call and you'll get a `command_error` event back.
  See [`start`](/model-api-reference/visko-orbis-dynamic/schema#start) for the full precondition
  list; how the example surfaces those errors is covered later in this article.
</Tip>

## Starting from an image

Visko Orbis Dynamic can start from **text alone** — the model invents the opening frame from the
prompt. But when you have a reference image, `setImage` matters.

Image-to-video adds a second piece of conditioning, and chaining `setImage → setPrompt → start`
directly hides a subtle race. `setImage` carries an upload that the runtime has to decode before the
model dispatches it; `start` carries nothing and sails past on the same data channel. The first
chunk is then generated from the prompt alone, no image conditioning at all. The image lands a tick
later and only applies from chunk 1 onward, so the user sees the scene "correct itself" at the first
chunk boundary.

There is no combined image+prompt command at all. The safe order is just `await uploadFile` →
`await setImage` → `await setPrompt` → `await start` — the resolution of `setImage` carries the
decode, so no broadcast to wait on:

```tsx app/components/ImageStarter.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { uploadFile, setImage, setPrompt, start } = useViskoOrbisDynamic();

const ref = await uploadFile(file);
await setImage({ image: ref }); // resolves once decoded
await setPrompt({ prompt: scene.initial.text });
await start();
```

If anything in the chain fails (missing piece, non-image MIME, undecodable bytes), the model emits
`command_error` and mutates nothing.

The example's second image path is for custom uploads, where the user's prompt arrives later from a
separate action. It's shorter still: `uploadFile`, then `setImage`. The user types their own prompt
in the composer above and clicks Start, at which point `PromptComposer` fires `setPrompt + start`.
No ack wait is needed: by the time the human has typed and clicked, the upload has long since been
decoded.

<Warning>
  Non-16:9 starting images squash. The model resizes the reference to 832×480 **with no crop** — use
  a 16:9 frame (every curated image in the library is).
</Warning>

## Going live

Once generation starts, the UI flips from the setup panel to its Live phase. The example wires three
small components into the right-hand sidebar and main pane: a status badge that tracks the
connection lifecycle, a "now playing" panel that mirrors the state snapshot and exposes transport
controls, and the video pane itself.

`StatusBadge.tsx` is the user's window into the four-state connection machine. Every state —
including the `waiting` step where the session is being placed — gets a visible label and color. It
also holds a separate "priming" state that fires on `generation_started` and clears the moment the
video track actually delivers:

```tsx app/components/StatusBadge.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const TONE = {
  disconnected: { dot: "bg-zinc-500", label: "Disconnected" },
  connecting: { dot: "bg-amber-400 animate-pulse", label: "Placing session…" },
  waiting: { dot: "bg-amber-400 animate-pulse", label: "Placing session…" },
  priming: {
    dot: "bg-amber-400 animate-pulse",
    label: "Priming stream — first frames incoming…",
  },
  ready: { dot: "bg-active", label: "Connected" },
};

// Set by generation_started, cleared when videoTrack arrives.
const priming = false;
const effective = priming && status === "ready" ? "priming" : status;
```

`NowPlaying.tsx` is the canonical example of how the rest of the app reads model state: subscribe
once with `useViskoOrbisDynamicState`, hold the latest snapshot in `useState`, read fields off it.
No event aggregation, no derived booleans, no `useReducer` over `chunk_complete` events.

```tsx app/components/NowPlaying.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { status, pause, resume, reset } = useViskoOrbisDynamic();
const [snapshot, setSnapshot] = useState<ViskoOrbisDynamicStateMessage | null>(null);

useViskoOrbisDynamicState((msg) => setSnapshot(msg));

// Clear on disconnect. The SDK doesn't emit a final `state` on shutdown,
// so without this the next session inherits the old snapshot.
useEffect(() => {
  if (status !== "ready") setSnapshot(null);
}, [status]);

// Phase switch: while not started (or after reset), render null and
// let the setup panel take over.
if (status !== "ready" || !snapshot?.started) return null;

return (
  <>
    <p>{String(snapshot.current_prompt ?? "")}</p>
    <span>chunk {snapshot.current_chunk}</span>
    {snapshot.running ? (
      <button onClick={() => pause()}>Pause</button>
    ) : (
      <button onClick={() => resume()}>Resume</button>
    )}
    <button onClick={() => reset()}>Reset</button>
  </>
);
```

The panel also renders a "Run finished" call-to-action when `generation_complete` fires, because the
model does NOT auto-restart after a run reaches `max_chunks`:

```tsx app/components/NowPlaying.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const [finished, setFinished] = useState(false);
useViskoOrbisDynamicGenerationComplete(() => setFinished(true));

// Clear the "finished" flag the moment a new run starts (started flips true).
useEffect(() => {
  if (snapshot?.started) setFinished(false);
}, [snapshot?.started]);
```

The video pane is one typed component:

```tsx app/components/Video.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
import { ViskoOrbisDynamicMainVideoView } from "@reactor-models/visko-orbis-dynamic";

export function Video() {
  return (
    <div className="rounded-lg border bg-black">
      <ViskoOrbisDynamicMainVideoView className="h-full w-full" videoObjectFit="contain" />
    </div>
  );
}
```

`<ViskoOrbisDynamicMainVideoView />` is a typed wrapper around `<ReactorView track="main_video">`
that handles `<video>` element setup, `srcObject` binding, and browser autoplay policy quirks. Style
the outer container; never reach for the underlying element.

## Hot-swapping prompts mid-stream

**This is the model's hero feature.** Once `started === true`, calling `setPrompt({ prompt })` is a
morph on the next chunk boundary (\~1.8 s) — no restart, no `start()` again, no cut, no ack wait.
From the user's perspective the scene just keeps going; from the model's perspective the prompt
schedule was updated for the next chunk boundary.

`EvolveScene.tsx` matches the active prompt against the scene library and offers one-click
continuations:

```tsx app/components/EvolveScene.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const { status, setPrompt } = useViskoOrbisDynamic();
const [snapshot, setSnapshot] = useState<ViskoOrbisDynamicStateMessage | null>(null);
useViskoOrbisDynamicState((msg) => setSnapshot(msg));

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

if (status !== "ready" || !snapshot?.started) return null;

// Match the active prompt against `initial` and every `evolutions[i].text`
// in the curated library. If nothing matches (free-text prompt), bail.
const scene = findSceneForPrompt(String(snapshot.current_prompt ?? ""));
if (!scene) return null;

return (
  <>
    <label>Steer the scene live</label>
    {scene.evolutions.map((evolution) => (
      <button key={evolution.title} onClick={() => setPrompt({ prompt: evolution.text })}>
        {evolution.title}
      </button>
    ))}
  </>
);
```

Each button is a single `setPrompt` call. No `start`, no `reset`, no acknowledgment wait. The model
is already generating, and the next 33-frame chunk picks up the new prompt automatically.

## Snapping a clip

The SDK ships recording primitives so you don't have to wire up `MediaRecorder` yourself. The
example's `SnapClip.tsx` captures the last 10 seconds of the live stream and opens a modal with the
SDK's built-in preview player and a download button.

```tsx app/components/SnapClip.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
import {
  ClipDownloadButton,
  ClipPlayer,
  RecordingError,
  useReactor,
  type Clip,
} from "@reactor-team/js-sdk";

const { status, requestClip } = useReactor((s) => ({
  status: s.status,
  requestClip: s.requestClip,
}));
const [clip, setClip] = useState<Clip | null>(null);

async function snap() {
  try {
    setClip(await requestClip(durationSeconds));
  } catch (e) {
    if (e instanceof RecordingError) {
      // render e.code + e.reason (omitted)
    }
  }
}

return (
  <>
    <button onClick={snap}>Snap last {durationSeconds}s</button>
    {clip && (
      <Modal onClose={() => setClip(null)}>
        <ClipPlayer clip={clip} />
        <ClipDownloadButton clip={clip} filename={filename} />
      </Modal>
    )}
  </>
);
```

Notice how **the imports are from `@reactor-team/js-sdk`, not
`@reactor-models/visko-orbis-dynamic`**. Recording is a base-SDK feature: it works the same way for
every Reactor model, and the typed model packages don't re-export the recording surface. So direct
base-SDK imports are idiomatic in this one place, and you can drop the file into any other Reactor
example unchanged. The clip components inherit the `jwtToken` resolver from the provider via React
context, so no auth plumbing is needed at the component level.

<Tip>
  Clip preview in Chromium and Firefox requires `hls.js`, already in the example's `package.json`.
  See [Recordings](/concepts/recordings) for the full feature page, including continuous recording,
  programmatic capture, and retention policies.
</Tip>

## Surfacing command\_error

Every Visko Orbis Dynamic command can fail a precondition check (e.g. `start` before a prompt, a
`set_resolution` value not on `available_resolutions`). The example never lets these fail silently.

```tsx app/components/CommandError.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const [error, setError] = useState<{ command: string; reason: string } | null>(null);

useViskoOrbisDynamicCommandError((msg) => {
  setError({ command: msg.command, reason: msg.reason });
});

// Clear on the next state snapshot. Any state change implies the user
// has moved on from whatever triggered the error.
useViskoOrbisDynamicState(() => {
  setError(null);
});

if (!error) return null;

return (
  <div>
    <span>{error.command} failed</span>
    <p>{error.reason}</p>
  </div>
);
```

`useViskoOrbisDynamicCommandError` is the typed wrapper for the `command_error` message: it fires
when the model rejects a command, carrying the failing command name and a human-readable reason. The
component sits in the sidebar, renders nothing until an error arrives, and clears itself when the
next state snapshot lands so a stale banner can't pile up.

<Tip>
  `command_error` is one of several messages the model emits. See the
  [Messages](/model-api-reference/visko-orbis-dynamic/schema#messages) table for the full list,
  including `chunk_complete`, `conditions_ready`, `image_accepted`, and `state`.
</Tip>

## Going further

Not every Visko Orbis Dynamic feature is surfaced in this demo. See below for a list of what's
missing and the one-line addition that wires each one in.

| Feature                          | How to add it                                                                                                                                                                                                                                                                                                                                                                  |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Custom audio prompt**          | `useViskoOrbisDynamic().setAudioPrompt({ prompt })`. Deliberately not exposed: the measured guidance is that feeding a scene description here makes the audio **worse** than leaving it unset. If you really want one, write one sentence describing the **sound**, not the scene. See [`set_audio_prompt`](/model-api-reference/visko-orbis-dynamic/schema#set_audio_prompt). |
| **Resolution switching mid-run** | Applies at the **next** `start`, not mid-run — the running generation's track geometry never jumps mid-shot. To run at a different size, call `set_resolution`, then `start` again. See [`set_resolution`](/model-api-reference/visko-orbis-dynamic/schema#set_resolution).                                                                                                    |
| **Reproducible runs**            | Already exposed via `SessionOptions`: `useViskoOrbisDynamic().setSeed({ seed })`. Same seed + same prompts reproduces the same video. See [`set_seed`](/model-api-reference/visko-orbis-dynamic/schema#set_seed).                                                                                                                                                              |
| **Recording / clip persistence** | The SDK's recording primitives are base-SDK (`requestClip`, `requestRecording`) and work today; there's no server-side clip store on this deployment yet, so clips are captured but not persisted beyond the session. See [Recordings](/concepts/recordings).                                                                                                                  |
