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

# LingBot World 2 tutorial

> An end-to-end LingBot World 2 walkthrough against the open-source reference frontend.

A guided tour of the open-source
[LingBot World 2](https://github.com/reactor-team/js-sdk/tree/main/examples/lingbot-world-2)
reference app, which demonstrates every important pattern in the LingBot World 2 SDK. By the end
you'll know how to start a scene from an image plus a layered prompt, drive it with WASD, look
around with the mouse and arrow keys, trigger world events on hold-keys, jump and crouch with
per-latent camera motion, and surface model errors.

## Installation and setup

Get the example running before reading further. Every section below points back at code in
[the example repo](https://github.com/reactor-team/js-sdk/tree/main/examples/lingbot-world-2). 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 our 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/lingbot-world-2
    ```
  </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 that route below; for now, drop the key 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
    ```

    <Tip>
      See a "Could not get a session token" notice? Your `REACTOR_API_KEY` isn't loaded. The check
      lives in `app/api/token/route.ts`, and the notice in `app/page.tsx`.
    </Tip>
  </Step>

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

    Open `http://localhost:3000`, click **Connect**, then click one of the **Quick Start**
    examples. The app uploads that scene's starting image, sends its composed prompt, starts
    generating, and from there the controls are live: WASD to move, arrows or mouse-look to turn,
    number keys for world events, Space and C for jump and crouch.
  </Step>
</Steps>

## How LingBot World 2 works

Building with LingBot World 2 is different from calling a typical generative API. There's no
image-in / video-out request. You open a long-lived connection, send a seed image plus a prompt, and
the model produces a continuous stream of chunks that you steer in real time. The image anchors the
scene and is locked at start; the prompt and the input channels drive everything after.

Opening the connection isn't instant. Reactor provisions a GPU for your session, so the client moves
through the same four states as every other Reactor model before media flows
(`disconnected → connecting → waiting → ready`). See
[Sessions](/concepts/sessions#connection-lifecycle) for the full breakdown.

Three properties of the LingBot World 2 API are worth internalizing before you read on, since the
rest of this tutorial assumes them:

* **Commands are asynchronous; events are the source of truth.** Calling `setImage` doesn't mean the
  next chunk uses it; the model confirms with `image_accepted` once the upload has been decoded and
  is ready. The example keeps its UI state (`hasImage`, `hasPrompt`, `isGenerating`) from the event
  stream, never from the fact that it sent a command.
* **Errors arrive out-of-band.** A broken precondition like `start` before `setImage` surfaces later
  as a `command_error` event, not as a thrown exception.
* **Input is persistent state, not pulses.** The two movement axes hold their last value until you
  send a new one, and a `set_camera_pose` payload stays active until you send an empty one. Every
  press needs a matching release.

One more idea shapes the whole app: **the prompt is not a string you write once**. The example
authors each scene as layers (base / camera / movement / events / vertical) and recomposes the prose
whenever the input state changes, so the text the model sees always matches the motion it is asked
to render. That harness is the subject of the
[prompt guide](/model-api-reference/lingbot-world-2/prompt-guide); this page shows the wiring.

## Authentication

LingBot World 2 uses the same broker pattern as every browser-side Reactor app: your `rk_…` key
stays on the server and the client receives a short-lived, **session-scoped** JWT minted from it.
The `authorization_details` body locks the token to LingBot World 2 sessions, so a leaked JWT can't
touch anything else on your account. The route is a few lines:

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

// Exchanges the server-side REACTOR_API_KEY for a short-lived session JWT.
// Keeping the exchange on the server means the API key never ships to the
// browser — the client only ever sees the JWT.
export async function POST() {
  const apiKey = process.env.REACTOR_API_KEY;
  if (!apiKey) {
    return NextResponse.json({ error: "REACTOR_API_KEY is not configured" }, { status: 500 });
  }

  const baseUrl = process.env.NEXT_PUBLIC_COORDINATOR_URL || "https://api.reactor.inc";

  const response = await fetch(`${baseUrl}/tokens`, {
    method: "POST",
    headers: { "Reactor-API-Key": apiKey, "Content-Type": "application/json" },
    body: JSON.stringify({
      authorization_details: [
        { type: "session", resources: { models: { match: ["reactor/lingbot-world-2"] } } },
      ],
    }),
  });

  if (!response.ok) {
    const body = await response.text();
    return NextResponse.json(
      { error: `Token request failed: ${response.status} ${body}` },
      { status: response.status },
    );
  }

  const { jwt } = await response.json();
  return NextResponse.json({ jwt });
}
```

The client half lives in `app/page.tsx`: fetch the JWT once on mount, then hand it to
`<LingbotWorld2Provider>`, which owns the connection lifecycle from there (including auto-disconnect
on unmount).

```tsx app/page.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const [jwtToken, setJwtToken] = useState<string | undefined>(undefined);

useEffect(() => {
  fetch("/api/token", { method: "POST" })
    .then(async (r) => {
      const body = await r.json().catch(() => ({}));
      if (r.ok && body.jwt) setJwtToken(body.jwt);
      else setTokenError(body.error ?? `Token request failed (${r.status})`);
    })
    .catch((err) => setTokenError(String(err)));
}, []);

/* No `autoConnect`: the user clicks Connect so they see the
   disconnected -> connecting -> waiting -> ready state machine
   first-hand. The provider owns the connection lifecycle and
   auto-disconnects on unmount, so we never call connect() from
   an effect ourselves. */
<LingbotWorld2Provider apiUrl={API_URL} jwtToken={jwtToken}>
  <StatusBar />
  <MainContent />
</LingbotWorld2Provider>;
```

See [Authentication](/authentication) for the full concept page, including the Express equivalent
and the Python path that has no need for the broker.

## The layered prompt harness

The model only ever sees a single prose string via `set_prompt`, but the app never hand-writes that
string. Each scene is a `StructuredScene` (`lib/lingbot-world-prompts.ts`) with one prose fragment
per concern:

```typescript lib/lingbot-world-prompts.ts theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
export interface StructuredScene {
  base: LayerRegistry<string>; // world identity: subject, environment, style
  camera: LayerRegistry<ShotVariant>; // { static, dynamic } framing, selected by WASD state
  movement: LayerRegistry<ShotVariant>; // { static, dynamic } subject motion, same switch
  events: NamedEvent[]; // detail clauses bound to hold-keys 1..9
  jumpPrompt?: string; // vertical sentences for Space / C held / C release
  crouchPrompt?: string;
  standPrompt?: string;
}

export function composePrompt(
  scene: StructuredScene,
  isMoving: boolean,
  heldSlots: number[],
  verticalPrompt = "",
): string;
```

`composePrompt` flattens the active selection to prose: `base`, then the camera and movement
variants for the current `isMoving` state, then the detail clause of every held event key, then the
jump/crouch sentence if one is engaged. The structure is authoring-time only; the wire format is
plain text.

The controller funnels every prompt-affecting change through one function, which composes the new
string and re-sends it only when it differs from the last one sent:

```tsx components/lingbot-world-2/LingbotWorldController.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const recomputePromptAndSend = useCallback(() => {
  // ...pick the active vertical (jump/crouch/stand) sentence first...
  if (!sceneRef.current) return;
  const isMoving = moveLStackRef.current.length > 0 || moveLatStackRef.current.length > 0;
  const next = composePrompt(sceneRef.current, isMoving, heldSlotsRef.current, vp).trim();
  if (!next) return;
  if (next === lastSentPromptRef.current) return;
  lastSentPromptRef.current = next;
  if (isReadyRef.current) {
    lw2.setPrompt({ prompt: next }).catch(console.error);
  }
}, [sendCommand]);
```

Every input handler that changes what the model should render (pressing W, holding event key 2,
releasing C) ends with a call to `recomputePromptAndSend()`. The model picks the new prompt up at
the next chunk boundary. This is the single most transferable pattern in the example: **derive the
prompt from input state, don't mutate it**.

Three UI surfaces sit on top of the same scene model, and none of them add new SDK concepts:

* The **✎ scene editor** (`LayeredSceneEditor.tsx`) edits any example's layers, events, and vertical
  prompts. Edits persist to `localStorage` as per-example overrides and survive reloads; ↺ reverts
  to the built-in scene. Editing the running scene re-sends the prompt live.
* The **Show prompt inspector** (`LivePromptInspector.tsx`) shows the exact composed string the
  model sees at this moment, colored per layer, with the selection logic mirrored in
  `prompt-segments.ts` so you can audit why each fragment is present.
* The **Custom scene** card runs the same flow from your own image and a from-scratch layered
  prompt.

## Starting a scene

Both the Quick Start examples and the custom scene funnel into one function, `applyScene`, which
runs the canonical LingBot World 2 launch sequence: reset if something is already running, upload
the starting image, send the composed prompt, then start.

```tsx components/lingbot-world-2/LingbotWorldController.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const applyScene = useCallback(async (opts) => {
  // Clear any held control state so stale refs don't linger across the switch
  moveLStackRef.current = [];
  moveLatStackRef.current = [];
  // ...idle both move axes, both look directions, and all held event keys...

  // If currently generating or paused, reset first so the new scene starts clean
  if (isGenerating || isPaused) {
    lw2.reset().catch(console.error);
    // Give the backend time to process the reset before we send new data
    await new Promise((r) => setTimeout(r, 600));
  }

  // Upload the starting image and register it
  const ref = await uploadFile(file);
  await lw2.setImage({ image: ref });

  // Send the scene's composed prompt (idle state: not moving, no keys held)
  sceneRef.current = opts.scene;
  const p = composePrompt(opts.scene, false, []).trim();
  await lw2.setPrompt({ prompt: p });

  // Auto-start after a short delay to let the backend process
  await new Promise((r) => setTimeout(r, 1500));
  await lw2.start();
}, [...]);
```

Two details are worth stealing. First, the input state is cleared **before** switching scenes: a key
held through the switch would otherwise leave a movement axis stuck on in the new world. Second,
`start` is delayed until the image and prompt have had time to land; `start` requires both a
registered prompt and image and fails with `command_error` otherwise. The UI still treats the event
stream as truth: `image_accepted`, `prompt_accepted`, and `conditions_ready` set the `hasImage` /
`hasPrompt` flags that gate the manual Start button.

## Reading the event stream

The example handles every backend message in one typed hook with a `switch`:

```tsx components/lingbot-world-2/LingbotWorldController.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
useLingbotWorld2Message((raw) => {
  // The published schema (0.2.5) doesn't declare `workers_ready` yet, so
  // widen the union locally; every other branch narrows to its typed shape.
  const msg = raw as LingbotWorld2Message | { type: "workers_ready"; tsp_size?: number };
  if (!msg?.type) return;
  switch (msg.type) {
    case "image_accepted":
      setHasImage(true);
      setImageInfo({ w: msg.width, h: msg.height });
      break;
    case "state":
      setHasPrompt(msg.has_prompt);
      setHasImage(msg.has_image);
      setIsGenerating(msg.running && msg.started);
      setIsPaused(msg.paused);
      setCameraPoseActive(msg.camera_pose_active);
      break;
    case "chunk_complete":
      setChunkIndex(msg.chunk_index);
      // ...advance jump arcs, consume crouch dips (see below)...
      sendCameraPoseChunkRef.current(); // drive the camera-pose layer
      break;
    case "generation_reset":
      // ...clear all scene + input state; a reset ends the run...
      break;
    case "command_error":
      setErrorToast(`${msg.command || "?"}: ${msg.reason || "unknown error"}`);
      break;
  }
});
```

Note the `workers_ready` cast: when the backend emits a message the published schema doesn't declare
yet (`workers_ready` remains undeclared as of SDK 0.3.0), widen the union in place rather than
abandoning the typed hook. Every declared message still narrows to its typed shape inside the
`switch`.

`chunk_complete` is more than progress reporting here. It is the **clock** for everything
chunk-granular: the per-latent jump and crouch motion below advances one chunk per tick, and the
camera-pose layer re-sends its current payload so held inputs keep applying. See the
[Messages table](/model-api-reference/lingbot-world-2/schema#messages) for the full list.

## Driving with WASD

Movement uses the model's two persistent axes: `set_move_longitudinal` (W/S) and `set_move_lateral`
(A/D). Both can be non-idle at once, so W+A drives a diagonal. The crucial invariant: **axes hold
their last value until you send a new one.** Every keydown needs a matching keyup, or the world
keeps moving after the user lets go.

The example tracks each axis as a small stack of held keys, so opposing keys resolve the way game
players expect (press W, add S, release S: you're moving forward again, because W is still held):

```tsx components/lingbot-world-2/LingbotWorldController.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const onKeyDown = (e: KeyboardEvent) => {
  if (e.repeat) return; // re-sending the same value is a no-op anyway
  if (isTypingTarget(e.target)) return; // typing in an input must not drive the camera
  const mvL = KEY_TO_MOVE_L[e.key]; // { w: "forward", s: "back" }
  if (mvL) {
    e.preventDefault();
    const stack = moveLStackRef.current;
    if (!stack.includes(mvL)) stack.push(mvL);
    applyMovementStack();
    return;
  }
  // ...lateral axis, look, jump, crouch, event keys follow the same shape...
};

const applyMovementStack = useCallback(() => {
  pushMoveL(moveLStackRef.current.at(-1) ?? "idle"); // top of stack wins
  pushMoveLat(moveLatStackRef.current.at(-1) ?? "idle");
  // Structured prompts depend on whether anything is held; recompose so
  // base + movement[isMoving] + events stays in sync with the input state.
  if (sceneRef.current) recomputePromptAndSend();
}, [pushMoveL, pushMoveLat, recomputePromptAndSend]);
```

That last line is the layered harness earning its keep: pressing W doesn't just send a movement
command, it flips the composed prompt from the scene's `static` variants (an idle subject, an
orbiting camera) to its `dynamic` variants (a traveling subject, a rear-view tracking camera). Two
channels, one story.

Also worth carrying into your own code: the example clears all held input on `window` blur, on
`reset`, and before applying a new scene. Any path where a keyup can be lost needs a sweep back to
idle.

## The camera-pose layer

Everything look-related routes through
[`set_camera_pose`](/model-api-reference/lingbot-world-2/schema#commands), the model's native
motion-delta channel: mouse-look (pointer lock), arrow-key look, Q/E roll, the on-screen joystick,
orbit mode, and the vertical motion of jump and crouch. The example leaves the discrete
`set_look_horizontal` / `set_look_vertical` axes unused; routing arrows through the same channel as
the mouse means they stack, share one code path, and drive orbit the same way.

Four backend facts shape the implementation (`CONTROLS.md` in the repo derives all of this):

1. **Payloads are per-latent deltas.** Each latent is `[rx, ry, rz, tx, ty, tz]` (Euler-radian
   rotation plus camera-local translation). The backend chunk is 3 latents (≈12 pixel frames), so
   the app sends 18 floats per chunk to steer each latent on its own.
2. **Rotation overrides the look axes; translation adds to WASD.** A crouch dip doesn't need to
   re-send forward motion; the backend sums them.
3. **Translation is max-norm normalized per chunk.** Absolute magnitude is erased; only the sign and
   the within-chunk shape survive. A motion's size is its latent count, not its numbers.
4. **The convention is y-down**: up is negative `ty`.

One function builds and sends the payload. It runs on every `chunk_complete` (the clock) and once
when any control engages or releases, so a new input applies without waiting a full chunk:

```tsx components/lingbot-world-2/LingbotWorldController.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const sendCameraPoseChunk = useCallback(() => {
  const active =
    mouseLookRef.current ||
    crouchDip !== null ||
    crouchHolding ||
    rollDirRef.current !== 0 ||
    joyActive ||
    jumpMoving ||
    arrowLooking;
  if (!active) {
    if (poseSentActiveRef.current) {
      lw2.setCameraPose({ camera_pose: [] }).catch(console.error); // hand control back
      poseSentActiveRef.current = false;
    }
    return;
  }
  // Rotation + horizontal translation are UNIFORM across the chunk's latents:
  // yaw/pitch from accumulated mouse motion + held arrows, clamped; roll from Q/E.
  const ry = clampRot(
    pendingDYawRef.current * mouseSensRef.current + lookHDirRef.current * ARROW_LOOK_SPEED,
  );
  // ...rx (pitch), rz (roll), tx/tz (joystick + orbit coupling)...
  // Vertical (ty) is the only PER-LATENT component: the jump arc or crouch dip.
  const camera_pose: number[] = [];
  for (let j = 0; j < CHUNK_LATENTS; j++) {
    const intent = arcActive && pos + j < arc.length ? arc[pos + j] : 0; // +1/0/-1
    const jumpTy = arcActive ? intent * JUMP_SPEED * JUMP_UP_SIGN : uniformJumpTy;
    camera_pose.push(rx, ry, rz, tx, jumpTy + crouchTy, tz);
  }
  lw2.setCameraPose({ camera_pose }).catch(console.error);
  poseSentActiveRef.current = true;
}, [sendCommand]);
```

Patterns to keep:

* **Accumulate between sends.** Mouse deltas add up in a ref and convert to one per-frame rotation
  at send time, clamped so a fast fling can't over-rotate.
* **Release with an empty payload, once.** When the last control disengages, send `camera_pose: []`
  a single time to hand rotation back to the look axes and translation back to WASD, then stop
  sending.
* **Orbit is a coupling, not a mode.** Orbit (the O key) pairs the yaw you're already sending with a
  proportional strafe, so the point R ahead stays centered while the camera circles it. R is the
  only control; `R = 0` is rotate-in-place.

<Tip>
  While a pose is active, its rotation overrides the arrow-look axes even when the pose carries zero
  rotation. Say so in your UI copy for held vertical modes; otherwise "my arrow keys stopped
  working" reads as a bug.
</Tip>

## World events on hold-keys

Each scene binds up to nine detail clauses to the number keys. Holding key 2 weaves that event's
sentence into the composed prompt; releasing it recomposes without it, reverting the world to the
scene's idle. Events stack: any two held together are appended in press order.

```tsx components/lingbot-world-2/LingbotWorldController.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const holdPress = useCallback(
  (slot: number) => {
    const events = sceneRef.current?.events;
    if (!events || slot < 0 || slot >= events.length) return;
    if (!heldSlotsRef.current.includes(slot)) {
      heldSlotsRef.current = [...heldSlotsRef.current, slot];
    }
    setHeldSlots(heldSlotsRef.current);
    recomputePromptAndSend();
  },
  [recomputePromptAndSend],
);

const holdRelease = useCallback(
  (slot: number) => {
    if (!heldSlotsRef.current.includes(slot)) return;
    heldSlotsRef.current = heldSlotsRef.current.filter((x) => x !== slot);
    setHeldSlots(heldSlotsRef.current);
    recomputePromptAndSend();
  },
  [recomputePromptAndSend],
);
```

There is no bespoke "event" wire feature behind this: it's `set_prompt` again, driven by the same
recompose function. All the craft is in the prose. The three shipped scenes
(`lib/lingbot-cases/*.json`) are production-authored examples: the noir alley's key 1 fires a
pistol, its key 3 fires a rocket launcher with a disambiguation guard against the model reading
"RPG" as a handheld gun. The
[prompt guide](/model-api-reference/lingbot-world-2/prompt-guide#events-the-hold-keys) covers the
authoring rules; an event can even swap the whole base layer (a portal world) via layer versions.

## Jump and crouch

Space and C are hold-controls like WASD, but each is a **(camera motion, prompt sentence) pair**
delivered together, so the prose matches the physics. The sentences are per-scene fields
(`jumpPrompt`, `crouchPrompt`, `standPrompt`), editable in the scene editor, and flow through
`composePrompt` as the vertical segment.

The motion half rides the per-latent `ty` channel you saw above. The default jump mode, `charge`,
holds Space to step a discrete meter (1 to 3 chunks), then fires that level's per-latent arc on
release. Arcs are hand-editable grids of `+1 up / 0 still / -1 down` per latent, and the defaults
are **symmetric**: because the backend normalizes magnitude away, equal counts of up-latents and
down-latents is what makes the character land back at launch height. The arc advances on the chunk
clock:

```tsx components/lingbot-world-2/LingbotWorldController.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
case "chunk_complete":
  // Advance the charge-mode jump arc by one chunk (CHUNK_LATENTS latents).
  // The arc auto-plays after release — no hold required during flight.
  if (jumpModeRef.current === "charge" && jumpArcRef.current.length > 0) {
    jumpArcPosRef.current += CHUNK_LATENTS;
    if (jumpArcPosRef.current >= jumpArcRef.current.length) {
      jumpArcRef.current = [];   // landed
      jumpArcPosRef.current = 0;
      recomputePromptAndSendRef.current();   // drop the jump sentence
    }
  }
  sendCameraPoseChunkRef.current();
  break;
```

Crouch mirrors jump with press and release as independent triggers: C-down fires a one-chunk
downward dip plus the `crouchPrompt` for the whole hold, C-up fires the reverse dip plus the
`standPrompt` for one chunk. Jump is locked while an arc is in flight (no double-jump), and crouch
dips fire only on the idle-to-held transition, so key auto-repeat can't spam motion. Both controls
also have simpler modes (`prompt` sends the sentence only; `hold` sustains a straight up or down
translation), selectable next to the pad; `CONTROLS.md` in the repo is the full design writeup.

## Backend knobs

The Advanced panel wires up the remaining typed setters, each a plain push-on-change:

* `setSeed` for reproducible runs (applies to the next `start`).
* `setRotationSpeedDeg` for the discrete look axes.
* `setAttnWindow` (`"auto" | "small" | "large"`) to override the DiT self-attention window.

The KV-cache reset controls, `set_kv_cache_reset` and `trigger_kv_cache_reset`, predate their typed
methods: the example was written against SDK 0.2.5, which did not declare them, so they go through
the raw escape hatch. SDK 0.3.0 declares both as
[`setKvCacheReset` and `triggerKvCacheReset`](/model-api-reference/lingbot-world-2/schema#set_kv_cache_reset),
so on current SDKs call the typed methods and reserve the escape hatch for commands the schema does
not declare yet. The typed surface wraps the base store's `sendCommand`, which stays available for
this case:

```tsx components/lingbot-world-2/LingbotWorldController.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
const pushKvCacheResetMode = (mode: KvResetMode) => {
  setKvCacheResetMode(mode);
  if (isReady) sendCommand("set_kv_cache_reset", { mode }).catch(console.error);
};
```

The example also re-asserts the selected values once on every connect, so a fresh session reflects
the UI state rather than the backend defaults. Sliders and toggles that look set but were never sent
to *this* session are a classic reconnect bug.

## Surfacing command\_error

Every LingBot World 2 command can fail a precondition check (most commonly `start` before both
`setImage` and `setPrompt` have landed). The example never lets one pass without a visible trace:
the `command_error` branch of the message handler feeds a toast that self-dismisses after a few
seconds.

```tsx components/lingbot-world-2/LingbotWorldController.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
case "command_error":
  setErrorToast(`${msg.command || "?"}: ${msg.reason || "unknown error"}`);
  break;
```

A few LingBot-specific failure modes worth knowing about:

* **`start` before conditions are set.** The model rejects `start` unless both a prompt AND a
  reference image have been registered.
* **`setImage` during generation is a silent no-op.** The seed image is locked once a session
  starts; the new image is dropped without a `command_error`.
* **`setPrompt` during generation is fine.** The new prompt takes effect at the next chunk boundary.
  The whole layered harness depends on this.
* **`trigger_kv_cache_reset` while the reset mode is `"off"`** is rejected with `command_error`; the
  example disables the button in that mode instead of letting users find out.

## What's intentionally left out

The demo covers the launch + drive + restyle loop. A few things stay out of scope:

* **Recording**: the base SDK ships `requestClip` and clip playback components that work unchanged
  with LingBot World 2; see [Recordings](/concepts/recordings).
* **Gamepad input**: same shape as the keyboard handler; press = value, release = idle.
* **Authoring new scenes in code**: scenes are plain JSON conforming to `StructuredExample`; drop a
  file into `lib/lingbot-cases/` and list it in `lib/lingbot-cases-examples.ts`. The
  [prompt guide](/model-api-reference/lingbot-world-2/prompt-guide) covers how to write one.

For the deeper design rationale behind the motion system (the camera-pose contract, symmetry,
trigger semantics), read
[`CONTROLS.md`](https://github.com/reactor-team/js-sdk/tree/main/examples/lingbot-world-2/CONTROLS.md)
in the example repo.
