← Guides
Model API guideUpdated 7 min readBy Windflow Labs

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.

The LTX-2.5 API on Windflow runs Lightricks' 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). Requests go to https://api.windflow.online.

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

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.

// 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 package for WebRTC. Save it as windflow-livekit.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:

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

CommandWhat it does
set_promptChanges what the next window generates; the current window always finishes first
scene_cutStarts an independent window: nothing carries over from the previous one
set_imageSets the reference still the next window conditions on
set_conditioningPrompt and reference still together; both required
start, pause, resume, reset, stop, set_seedRun 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:

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.

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.

MilestoneSignalWhat the viewer should see
Room joinedroom.connect resolvesLoading state
Generatinggeneration_started, after startThe reference still, if you set one, or a loading state
First windowGenerated frames on the video trackThe scene, muted until the sound button
Sound onroom.startAudio() after a clickPicture 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

StageSymptomCause and fix
Token401 invalid_api_keySend the wf_live_ key from your server only
Create403 model_mismatch or token_sessions_exhaustedToken minted for another model, or its max_sessions used up
Create402 insufficient_balance or 429 rate_limitedBalance below the shortest useful session, or too many new sessions; honour Retry-After
CreateYou asked for transport: "ws-h264"LTX-2.5 has an audio track and is delivered over livekit only
Queuequeued for a whileCapacity is being brought up; keep polling and show the position
Queuefailed with scheduler_queue_expiredPolling stopped while queued
Readystart rejected: "prompt is required"Send set_prompt or set_conditioning first
RunningNothing for several seconds after startExpected: the first frame arrives when the first window is done
RunningPicture plays, no soundThe element is still muted; unmute and call room.startAudio() from a click
RunningA prompt change arrives lateIt applies from the next window; the running window always finishes
RunningThe shot shifts at a window boundaryRepeat 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.

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.

Run realtime models inside your product.