# How to use the LTX-2.5 API

> Stream LTX-2.5 video with generated audio: join the session over WebRTC, play picture and sound in one element, steer window by window, and release.

Source: https://windflow.online/guides/ltx-2-5-api · Updated 2026-11-03 · Windflow Labs

The **LTX-2.5 API** on Windflow runs Lightricks' [LTX-2.5](https://windflow.online/models/ltx-2-5) as a realtime session that generates video and its sound together. It works in windows: each window is 241 frames, about ten seconds at 24 fps, with audio generated alongside the picture. While one window plays, the next is being generated, and a new prompt applies from the next window. Because the output has an audio track, LTX-2.5 is delivered over WebRTC only, through the session's LiveKit room.

## Start from something runnable

You need an approved Windflow account and an API key that begins with `wf_live_` ([request access](https://windflow.online/auth?mode=request)). Requests go to `https://api.windflow.online`.

**1. Check the key (server).** The response lists this model's commands, messages and limitations.

```bash
curl https://api.windflow.online/v1/models/ltx-2-5 \
  -H "Authorization: Bearer $WINDFLOW_API_KEY"
```

**2. Mint a browser token (server).** The account key never reaches the browser.

```ts
// For example app/api/windflow-token/route.ts. Authenticate your own user first.
export async function POST() {
  const res = await fetch("https://api.windflow.online/v1/tokens", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WINDFLOW_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ model: "ltx-2-5", max_sessions: 1 }),
  });
  return Response.json(await res.json(), { status: res.status });
}
```

The token is bound to this model (another model gets `403 model_mismatch`) and can create one session. Read `expires_at` from the response instead of assuming a lifetime.

**3. Open the session and join the room (browser).** This uses the open-source [`livekit-client`](https://github.com/livekit/client-sdk-js) package for WebRTC. Save it as `windflow-livekit.js`:

```js
// windflow-livekit.js: session over fetch, media over LiveKit (npm install livekit-client)
import { Room, RoomEvent, Track } from "livekit-client";

const API = "https://api.windflow.online";

export async function call(path, jwt, body) {
  const res = await fetch(API + path, {
    method: body ? "POST" : "GET",
    headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
    body: body && JSON.stringify(body),
  });
  const data = await res.json();
  if (!res.ok) throw Object.assign(new Error(data.error.message), data.error, { status: res.status });
  return data;
}

export function openLiveKitSession(model, video, onMessage = () => {}) {
  const room = new Room();
  const decoder = new TextDecoder();
  let session, closed = false, released = false;

  const release = () => {
    closed = true;
    room.disconnect();
    if (!session || released) return;
    released = true;
    fetch(`${API}/v1/sessions/${session.session_id}`, {
      method: "POST",
      keepalive: true, // still sent while the page unloads
      headers: { Authorization: `Bearer ${session.jwt}`, "Content-Type": "application/json" },
      body: JSON.stringify({ action: "detach", connection_id: session.connection_id, release: true }),
    });
  };

  // Picture and sound go to the SAME element, so they stay in sync.
  room.on(RoomEvent.TrackSubscribed, (track) => {
    if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) track.attach(video);
  });
  room.on(RoomEvent.DataReceived, (payload) => {
    try {
      onMessage(JSON.parse(decoder.decode(payload)));
    } catch {
      // not a session message
    }
  });
  room.on(RoomEvent.Disconnected, () => onMessage({ type: "room_disconnected" }));

  const ready = (async () => {
    const { jwt } = await (await fetch("/api/windflow-token", { method: "POST" })).json();
    session = await call("/v1/sessions", jwt, { model, transport: "livekit" });
    while (session.status === "queued" && !closed) {
      await new Promise((resolve) => setTimeout(resolve, 2000));
      const poll = await call(`/v1/sessions/${session.session_id}`, session.jwt);
      onMessage({ type: "queued", position: poll.queue_position });
      if (poll.status !== "queued") {
        session = await call("/v1/sessions", session.jwt, { model, session_id: session.session_id, transport: "livekit" });
      }
    }
    const offer = session.transport.offers.find((o) => o.kind === "livekit");
    if (closed || !offer) throw new Error(closed ? "released" : `no room offer (status ${session.status})`);
    await room.connect(offer.url, offer.participant_token,
      offer.ice_servers ? { rtcConfig: { iceServers: offer.ice_servers } } : undefined);
    return session;
  })();
  ready.catch(release); // a failed start must not leave a session running

  return {
    ready,
    release,
    room,
    get session() { return session; },
    command: (type, fields = {}) =>
      call(`/v1/sessions/${session.session_id}/commands`, session.jwt, { command: { type, ...fields } }),
    upload: async (file) => {
      const intent = await call("/v1/uploads", session.jwt, {
        filename: file.name, mime: file.type, size: file.size, session_id: session.session_id,
      });
      const put = await fetch(intent.upload_url, { method: "PUT", headers: intent.headers, body: file });
      if (!put.ok) throw new Error(`upload failed (${put.status})`);
      return call(`/v1/uploads/${intent.file_id}/complete`, session.jwt, {});
    },
  };
}
```

`queued` and `room_disconnected` are messages this file generates; every other message comes from the session over the room's data channel.

**4. Start LTX-2.5.** The page needs a `<video id="out" autoplay muted playsinline>` element and a button for sound:

```js
import { openLiveKitSession } from "./windflow-livekit.js";

const video = document.querySelector("#out");
const ltx = openLiveKitSession("ltx-2-5", video, (msg) => console.log(msg.type));
await ltx.ready;
await ltx.command("set_prompt", {
  prompt: "A handheld walk through a rainy night market, vendors calling from both sides, water dripping from canvas awnings",
});
await ltx.command("start");

// Browsers only allow sound after a user gesture.
document.querySelector("#sound").addEventListener("click", async () => {
  video.muted = false;
  await ltx.room.startAudio();
  await video.play();
});
```

## Wait until the session is ready

Capacity is brought up on demand, so `POST /v1/sessions` can answer `status: "queued"` with a `queue_position` and no transport offers. Keep polling `GET /v1/sessions/{id}` and show the position: a queue ticket nobody polls expires with `error: "scheduler_queue_expired"`. When the status leaves `queued`, `POST /v1/sessions` with the same `session_id` returns the room offer without creating a second session.

The platform starts the model in the room when the session is placed, so the browser only joins. Commands go over HTTP to `POST /v1/sessions/{id}/commands`; the control plane retries the brief moment while the worker is still joining the room.

## Steer window by window

| Command | What it does |
|---|---|
| `set_prompt` | Changes what the **next** window generates; the current window always finishes first |
| `scene_cut` | Starts an independent window: nothing carries over from the previous one |
| `set_image` | Sets the reference still the next window conditions on |
| `set_conditioning` | Prompt and reference still together; both required |
| `start`, `pause`, `resume`, `reset`, `stop`, `set_seed` | Run control; `pause` takes effect after the current window, and each window advances the seed |

Each window is generated as a whole, so a new prompt applies once the running window finishes. Send one prompt per beat; a newer prompt replaces one that is still pending.

By default, each window continues from the tail of the previous one: identity, wardrobe, room and lighting carry across, but the camera steps at the boundary. `scene_cut` drops that tail. It is visibly a cut, and it is cheaper to generate than a continuation, because conditioning on the previous tail is what costs the extra time.

### Open on a reference still

To anchor the opening composition and identity, upload a still and stage it with the prompt before `start`:

```js
const still = await ltx.upload(document.querySelector("#still").files[0]); // image/*
await ltx.command("set_conditioning", { prompt: "She turns toward the window and smiles as rain starts", image: { id: still.id } });
await ltx.command("start");
```

The accepted still is published on the video track while the first window generates, so the viewer sees the opening frame instead of an empty box. Later windows continue from generated frames rather than returning to the still.

For how to word scenes, sound and dialogue, see the [LTX-2.5 prompting guide](https://windflow.online/guides/ltx-2-5-prompting-guide).

## The first frame costs a whole window

LTX-2.5 delivers whole windows, so the first generated frame arrives when the first window is complete. Give it that time rather than a short client timeout, and use the session's state and messages to follow progress.

| Milestone | Signal | What the viewer should see |
|---|---|---|
| Room joined | `room.connect` resolves | Loading state |
| Generating | `generation_started`, after `start` | The reference still, if you set one, or a loading state |
| First window | Generated frames on the video track | The scene, muted until the sound button |
| Sound on | `room.startAudio()` after a click | Picture with sound |

## Direct the sound

Audio is generated together with the picture, so it follows the scene you describe. Name the sounds you want in the prompt. For content that always needs music, you can mix your own bed under the stream.

## Release the session on unmount

Call `ltx.release()` when the component unmounts and on `pagehide`. It leaves the room and sends `{ action: "detach", connection_id, release: true }` with `keepalive`; the control plane stops the run and hands the capacity back, and the response reports `session_released: true`. Paused time is billed, so release is what stops the meter.

## Troubleshooting by the stage where startup stopped

| Stage | Symptom | Cause and fix |
|---|---|---|
| Token | `401 invalid_api_key` | Send the `wf_live_` key from your server only |
| Create | `403 model_mismatch` or `token_sessions_exhausted` | Token minted for another model, or its `max_sessions` used up |
| Create | `402 insufficient_balance` or `429 rate_limited` | Balance below the shortest useful session, or too many new sessions; honour `Retry-After` |
| Create | You asked for `transport: "ws-h264"` | LTX-2.5 has an audio track and is delivered over `livekit` only |
| Queue | `queued` for a while | Capacity is being brought up; keep polling and show the position |
| Queue | `failed` with `scheduler_queue_expired` | Polling stopped while queued |
| Ready | `start` rejected: "prompt is required" | Send `set_prompt` or `set_conditioning` first |
| Running | Nothing for several seconds after `start` | Expected: the first frame arrives when the first window is done |
| Running | Picture plays, no sound | The element is still muted; unmute and call `room.startAudio()` from a click |
| Running | A prompt change arrives late | It applies from the next window; the running window always finishes |
| Running | The shot shifts at a window boundary | Repeat continuity cues in adjacent prompts |

Rejected commands return `422` with a `command_…` code over HTTP and a `command_error` message on the data channel.

## FAQ

### Does LTX-2.5 generate audio?

Yes. Each window carries generated audio on the `main_audio` track, aligned with the picture. Attach it to the same element as the video so the two stay in sync.

### Can I change the prompt mid-window?

No. Every command lands at the next window boundary, about every ten seconds. For a model that responds within a chunk, see [Helios](https://windflow.online/guides/helios-api).

### Can I use the WebSocket transport with LTX-2.5?

No. The WebSocket transport carries frames without an audio channel, so LTX-2.5 is offered over `livekit` only.
