> ## 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
> To build and serve your own model, start at /deploy/development/quickstart and /deploy/development/overview. Deploying is the default path: reactor init scaffolds a workspace, reactor auth login authenticates, and reactor model deploy registers the model, publishes the release with the weights/ folder, and activates it on Reactor's GPUs, in one command from that workspace. Docker must be running, because the publish step builds the image locally. Bump model.version in reactor.yaml before redeploying a change, because a release that already has an image is reactivated as it is. Deployment access is granted per account, so contact team@reactor.inc if a deploy is refused. Every key in reactor.yaml is documented at /deploy/platform/reactor-yaml. Model code imports reactor_runtime; Python client code imports reactor_sdk. The runtime overview explains the model interface. Running the model on your own machine with reactor run is optional and needs a GPU you attach with --gpus; /deploy/development/local-testing covers that loop and pairs a complete brightness model with a Python client test in a separate brightness-test workspace.
> Reactor hosts multiple models, each with its own connect slug (modelName) and command/event schema. The video model catalog — slug, typed SDK package, and links to its schema — is at /model-api-reference/overview. Robotics policy documentation starts at /robotics/overview; X-WAM observations, actions, and client integration are under /robotics/xwam/; Cosmos3 Nano Policy DROID is under /robotics/cosmos/nano-policy-droid/. 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.

# XMAX X2.1 Tutorial

> Connect a browser, publish a camera, and send editing and drag commands.

This is a self-contained API walkthrough. It uses the typed SDK to edit a webcam stream. The
snippets define their own DOM elements and token route. The
[example template](https://github.com/reactor-team/create-reactor-app/tree/main/templates/xmax-x2-1)
provides a complete interface with source selection.

## Installation and setup

Create a Next.js app with TypeScript and the App Router, then install the typed SDK:

```shell theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
npx create-next-app@latest my-xmax-app --ts --app --eslint --no-tailwind --use-npm --no-src-dir
cd my-xmax-app
npm install @reactor-models/xmax-x2-1@2.0.0
```

Add your [Reactor API key](https://docs.reactor.inc/authentication.md) to `.env.local`:

```ini theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
REACTOR_API_KEY=rk_your_key
```

Keep this key on the server. Restart the development server after changing the environment file.

## How XMAX X2.1 works

The browser publishes a `source` video track and receives `main_video`. Once source frames arrive,
`set_prompt` enables generation. An empty string is a valid prompt. A `null` prompt in
`state_update` means no prompt was set.

Use command replies to show whether an input was accepted. Use `state_update` and received video to
show what the model does. The typed SDK flattens command replies, so `await model.setPrompt(...)`
returns a payload with `.prompt`. Base SDK replies have the envelope `{ type, data }`.

## Authentication

Create `app/api/reactor/token/route.ts`. The route exchanges the server key for a session-scoped
token and returns its expiry. The token can create sessions only for this model.

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

export async function GET() {
  const apiKey = process.env.REACTOR_API_KEY;
  if (!apiKey) {
    return NextResponse.json({ error: "Set REACTOR_API_KEY" }, { status: 503 });
  }
  const response = await fetch("https://api.reactor.inc/tokens", {
    method: "POST",
    headers: { "Reactor-API-Key": apiKey, "Content-Type": "application/json" },
    body: JSON.stringify({
      expires_after: 3600,
      authorization_details: [
        {
          type: "session",
          resources: { models: { match: ["reactor/xmax-x2-1"] } },
          constraints: { max_sessions: 10 },
        },
      ],
    }),
    cache: "no-store",
  });
  if (!response.ok) {
    return NextResponse.json({ error: "Token exchange failed" }, { status: 502 });
  }
  const { jwt, expires_at } = await response.json();
  return NextResponse.json(
    { jwt, expires_at },
    {
      headers: { "Cache-Control": "private, no-store" },
    },
  );
}
```

```python Python token broker theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
import os

import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse

app = FastAPI()

@app.get("/api/reactor/token")
async def token():
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://api.reactor.inc/tokens",
            headers={"Reactor-API-Key": os.environ["REACTOR_API_KEY"]},
            json={
                "expires_after": 3600,
                "authorization_details": [{
                    "type": "session",
                    "resources": {"models": {"match": ["reactor/xmax-x2-1"]}},
                    "constraints": {"max_sessions": 10},
                }],
            },
        )
    if response.status_code != 200:
        raise HTTPException(502, "Token exchange failed")
    body = response.json()
    return JSONResponse(
        {"jwt": body["jwt"], "expires_at": body["expires_at"]},
        headers={"Cache-Control": "private, no-store"},
    )
```

The Python tab is an alternative broker for an existing Python backend. The Next.js app below uses
the route above.

## Publish and view video

Replace `app/page.tsx` with this client component. It creates both video elements, starts on a user
click, and releases the camera on disconnect.

```tsx app/page.tsx theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
"use client";

import { useEffect, useRef, useState } from "react";
import { XmaxX21Model } from "@reactor-models/xmax-x2-1";

export default function Page() {
  const source = useRef<HTMLVideoElement>(null);
  const output = useRef<HTMLVideoElement>(null);
  const model = useRef<XmaxX21Model | null>(null);
  const camera = useRef<MediaStream | null>(null);
  const [busy, setBusy] = useState(false);
  const [connected, setConnected] = useState(false);
  const [prompt, setPrompt] = useState("");
  const [error, setError] = useState("");

  async function stop() {
    const current = model.current;
    model.current = null;
    camera.current?.getTracks().forEach((track) => track.stop());
    camera.current = null;
    if (source.current) source.current.srcObject = null;
    if (output.current) output.current.srcObject = null;
    await current?.disconnect();
    setConnected(false);
  }

  useEffect(
    () => () => {
      const current = model.current;
      model.current = null;
      camera.current?.getTracks().forEach((track) => track.stop());
      void current?.disconnect();
    },
    [],
  );

  async function start() {
    setBusy(true);
    setError("");
    const current = new XmaxX21Model();
    model.current = current;
    current.on("error", (failure) => setError(failure.message));
    current.onMainVideo((_track, stream) => {
      if (!output.current) return;
      output.current.srcObject = stream;
      void output.current.play().catch((failure) => setError(String(failure)));
    });
    try {
      const response = await fetch("/api/reactor/token", { cache: "no-store" });
      if (!response.ok) throw new Error(`Token request failed: ${response.status}`);
      const { jwt } = await response.json();
      // Retain this token for the session that it creates.
      await current.connect(jwt);
      if (model.current !== current) return;
      const stream = await navigator.mediaDevices.getUserMedia({ video: true });
      if (model.current !== current) {
        stream.getTracks().forEach((track) => track.stop());
        return;
      }
      camera.current = stream;
      if (source.current) source.current.srcObject = stream;
      await current.publishSource(stream.getVideoTracks()[0]);
      const accepted = await current.setPrompt({ prompt });
      if (!accepted) throw new Error(current.getLastError()?.message ?? "Prompt rejected");
      setConnected(true);
    } catch (failure) {
      setError(String(failure));
      await stop();
    } finally {
      setBusy(false);
    }
  }

  async function applyPrompt() {
    const current = model.current;
    if (!current) return;
    const accepted = await current.setPrompt({ prompt });
    if (!accepted) setError(current.getLastError()?.message ?? "Prompt rejected");
  }

  return (
    <main>
      <label>
        Editing instruction
        <input
          value={prompt}
          maxLength={1000}
          onChange={(event) => setPrompt(event.target.value)}
        />
      </label>
      <button disabled={busy || connected} onClick={() => void start()}>
        Connect
      </button>
      <button disabled={!connected} onClick={() => void applyPrompt()}>
        Apply prompt
      </button>
      <button disabled={busy || !connected} onClick={() => void stop()}>
        Disconnect
      </button>
      <p role="alert">{error}</p>
      <video ref={source} autoPlay playsInline muted style={{ width: "45%" }} />
      <video ref={output} autoPlay playsInline muted style={{ width: "45%" }} />
    </main>
  );
}
```

Run `npm run dev`, open the local page, and click Connect. Allow camera access. If playback is
blocked, handle the displayed playback error with an explicit play control.

## Add references and drag control

For a reference picker, upload the selected `File`, then await `setReferenceImage`. Keep the
previous selection until the reply confirms acceptance. The new reference applies from the next
block without a stop or restart message. Expect one short pause and a visible transition over the
next few blocks.

```typescript theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
async function applyReference(model: XmaxX21Model, file: File) {
  const reference = await model.uploadFile(file);
  return model.setReferenceImage({ reference_image: reference });
}
```

For drag control, sample positions at the source's frame cadence. Keep the last four samples, oldest
first, and call `setPointers({ frames })`. Each sample contains zero to eight `{ x, y }` points
normalized to the output frame. Account for any letterboxing when converting screen coordinates.

Use pointer capture and `touch-action: none` on the interaction surface. On release, cancellation,
or window blur, send `setPointers({ frames: [[], [], [], []] })`. The
[schema](https://docs.reactor.inc/model-api-reference/xmax-x2-1/schema.md#set_pointers) explains how stale windows hold their
newest positions.

## Surfacing command\_error

Listen for SDK errors for the whole connection lifetime. The example uses `model.on("error", ...)`.
The supplied model schema does not declare a typed `command_error` message or hook.

A typed command can resolve to `undefined` when it fails. Read `getLastError()` and keep the prior
UI selection when a reply is absent. `reset` declares no reply payload, so `undefined` alone does
not indicate failure for that command. Use SDK errors and the next `state_update` to confirm its
effect.

Await replies for the command you sent. A general message listener is not a substitute for the
correlated reply. Keep listeners active for later errors, and remove temporary listeners when their
operation ends.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.