Skip to main content
A guided tour of building with X2. By the end you’ll know how to publish a source stream (a webcam, a clip, or a still image) and arm generation with a prompt. You’ll also know how to anchor a swap to a reference image, steer the edited subject with a drag pointer, and surface model errors. This page draws its patterns from the X2 reference frontend in the js-sdk repo. To start from that app instead of from scratch, scaffold it:
npx create-reactor-app my-x2-app --model=x2
The scaffolded app’s skill/SKILL.md documents the same patterns for coding agents building on top.

Installation and setup

You will need:
  • Node.js 18+ (and React 18+ if you use the provider and hooks)
  • a Reactor API key (starts with rk_)
Install the typed SDK; it pulls in the base @reactor-team/js-sdk as a dependency:
npm install @reactor-models/x2

How X2 works

The mental model that the rest of this page builds on:
  • You stream video in; the model streams edited video back. The client publishes a source video track, and the model publishes main_video. Everything else is control surface.
  • There is no start command. Generation begins on its own once a non-empty prompt is set and source frames are arriving. To stop and clear, call reset.
  • The model is the source of truth. It broadcasts a full state_update snapshot on connect and after every observable change. Reduce that snapshot into your UI state instead of tracking your own copy of what you sent.
  • Everything lands on block boundaries. X2 generates one block at a time; prompt swaps apply from the next block, and the model samples the pointer once per block.

Authentication

Your rk_… API key must never reach the browser. Keep it server-side and mint a short-lived JWT the client connects with; see Authentication for the token route pattern. Hand the provider a getJwt resolver rather than a static token. The SDK calls it before every Reactor API request (connect, ICE refresh, uploads, clip manifests), so a fresh token is always in reach.
async function fetchToken(): Promise<string> {
  const res = await fetch("/api/reactor/token");
  if (!res.ok) throw new Error(`Token fetch failed: ${res.status}`);
  const { jwt } = (await res.json()) as { jwt: string };
  return jwt;
}

return (
  <X2Provider getJwt={fetchToken} connectOptions={{ autoConnect: true }}>
    {children}
  </X2Provider>
);
Because the resolver runs on every hop, make the token route itself do the caching rather than adding a localStorage layer or parsing the JWT client-side:
  • Expose it as GET, not POST. Browsers do not cache POST responses; a GET route whose handler still POSTs to the Reactor /tokens endpoint lets the browser’s HTTP cache serve repeat calls without a network round trip.
  • Send Cache-Control: private. JWTs are per-user; private keeps CDNs and shared proxies from storing them.
  • Derive max-age from the response’s expires_at, minus a small skew, instead of hardcoding a number. The cache window then always tracks the lifetime the server granted.
An inline getJwt function is fine: the provider stabilizes it through a ref, so a parent re-render does not tear the session down. Do not wrap it in useCallback.

Streaming a source

The model edits whatever arrives on the source track; the app never uploads source media, it streams it. Three source shapes cover most apps, and all three end in the same call, publishing a MediaStreamTrack:
  • For a webcam, call getUserMedia({ video: true }) and publish the video track.
  • For a clip, play a video element (muted, looped) and publish the video track from captureStream().
  • For a still image, draw it to a canvas and publish canvas.captureStream(24). The capturer only emits when the canvas repaints, so keep repainting the same frame on an interval at the stream rate. From the model’s side this is a video of a motionless scene, which is the drag-to-animate setup.
const canvas = document.createElement("canvas");
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext("2d")!;
const draw = () => ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
draw();

const track = canvas.captureStream(24).getVideoTracks()[0];
track.contentHint = "detail"; // hold resolution; adapt framerate instead
setInterval(draw, 1000 / 24); // keep the capturer emitting

await x2.publishSource(track);
keep_backlog: false (the default) reads the newest frames and keeps latency bounded, right for a live webcam. keep_backlog: true consumes every frame in order for smoother motion at the cost of growing delay, right for clips and drag-to-animate.

Starting a run

Generation starts at the first moment a non-empty prompt exists while frames arrive. Set the reference image before the prompt if the first block should already carry it:
const ref = await x2.uploadFile(characterImageFile);
await x2.setReferenceImage({ reference_image: ref });
await x2.setPrompt({
  prompt: "replace the character in the video with the character from the reference image",
});
// Generation starts on its own; generation_started carries the output resolution.
The reference can be replaced mid-run: the stream restarts on its own and the new image conditions the edit from its first block; details in the schema. Render the result by attaching the main_video track to a video element. Use <X2MainVideoView /> in React, or onMainVideo on the typed client.

Reading the event stream

Reduce state_update into your UI state and treat it as authoritative:
useX2StateUpdate((msg) => {
  setUi((s) => ({
    ...s,
    generating: msg.generating,
    activePrompt: (msg.prompt as string | null) ?? null,
    outputWidth: (msg.width as number | null) ?? null,
    outputHeight: (msg.height as number | null) ?? null,
    hasReference: msg.has_reference_image,
    keepBacklog: msg.keep_backlog,
  }));
});
Three discrete events are worth handling on top of the snapshot:
  • reference_image_accepted is the one place to read the decoded image’s width and height; the snapshot only says whether a reference is set.
  • generation_stopped fires on any stop. Check the reason field: reference_image_changed is the automatic restart after a reference swap, and reset means the run is over.
  • command_error reports a rejected command, covered below.
One rendering gotcha: when generation stops, the WebRTC video element freezes on the last transformed frame rather than going black. If your UI should read as “stopped”, blank the output pane on generation_stopped and lift the blank when the next state_update reports generating: true.

Steering with the drag pointer

The pointer steers the edited subject while a drag is held. Wire it to pointer events on the output pane:
  • On pointer-down, send { x, y, active: true } and capture the pointer.
  • On pointer-move, keep sending positions, throttled to about 30 Hz with a trailing send so the last position of a fast gesture lands. The model samples the pointer once per block, so faster sends buy nothing.
  • On pointer-up or cancel, send { active: false }. Also send it if the overlay unmounts or the session leaves ready mid-drag, so the model does not keep steering toward a stale point.
The pointer’s coordinates map to the output frame, not your DOM element. If the pane letterboxes the video (object-fit: contain), map from the pane’s box to the visible content using the aspect ratio from generation_started, and clamp to 0..1. The model echoes the pointer back in every state_update as pointer_x, pointer_y, and pointer_active. These reflect the model’s view of the pointer rather than your local gesture, so surfacing them in a debug readout shows you the payload each set_pointer call delivered. For drag-to-animate, combine the pieces. Stream a still image as the source, write a prompt that binds motion to the drag, such as the character in the video follows the drag trajectory, and set keep_backlog: true for smooth motion.

Surfacing command_error

A rejected command has no effect, and the rejection arrives as a command_error message rather than a thrown error. Surface it as a transient banner and move on; the session stays healthy:
useX2CommandError((msg) => {
  showBanner(`${msg.command}: ${msg.reason}`);
});
One error you may hit in practice is set_reference_image with a file the model cannot decode as an image.

Resetting and disconnecting

reset stops generation and clears the prompt, reference image, and pointer server-side. Mirror it client-side by clearing prompt drafts and reference previews in the same motion (keying those components on a reset counter is a clean way to do it). On a full disconnect, drop all local session state so a reconnect starts clean; the model’s first state_update re-seeds the UI.