# How to use the Hy-Nomas API

> Stream a Hy-Nomas world from a text prompt: open it with set_prompt, steer it with held keys and text events, and plan each world as a 60-second take.

Source: https://windflow.online/guides/hy-nomas-api · Updated 2026-11-03 · Windflow Labs

The **Hy-Nomas API** on Windflow runs [Hy-Nomas](https://windflow.online/models/hy-nomas), Tencent Hunyuan's open world model, as a streaming session. A text prompt opens a world at 832 × 480 and 24 fps; held keys move and turn the view, and later prompts change what happens in the same place. The model builds the world two seconds at a time, each segment continuing from the ones before it, so keys and prompts take effect from the next segment. There is no seed image.

Hy-Nomas is offered over the WebSocket and LiveKit transports; the code below uses WebSocket. The weights are open under Apache-2.0, and the [directory entry](https://windflow.online/world-models/hy-nomas) lists the sources for running it yourself.

## 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/hy-nomas \
  -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: "hy-nomas", 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 draw frames (browser).** Save this as `windflow-session.js`. It is the same file the [Helios guide](https://windflow.online/guides/helios-api) walks through line by line.

```js
// windflow-session.js: plain fetch and WebSocket, no dependencies
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 openSession(model, canvas, onMessage = () => {}) {
  const ctx = canvas.getContext("2d");
  let session, ws, closed = false, released = false;
  let painting = Promise.resolve();

  const paint = async (bytes) => {
    const frame = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" }));
    if (canvas.width !== frame.width) Object.assign(canvas, { width: frame.width, height: frame.height });
    ctx.drawImage(frame, 0, 0);
    frame.close();
  };

  const release = () => {
    closed = true;
    ws?.close();
    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 }),
    });
  };

  const ready = (async () => {
    const { jwt } = await (await fetch("/api/windflow-token", { method: "POST" })).json();
    session = await call("/v1/sessions", jwt, { model, transport: "ws-h264" });
    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: "ws-h264" });
      }
    }
    const offer = session.transport.offers.find((o) => o.kind === "ws-h264");
    if (closed || !offer) throw new Error(closed ? "released" : `no stream offer (status ${session.status})`);
    const url = new URL(offer.relay_path, API);
    url.protocol = "wss:";
    ws = new WebSocket(url);
    ws.binaryType = "arraybuffer";
    return new Promise((resolve, reject) => {
      ws.onopen = () => {
        ws.send(JSON.stringify({ type: "authenticate", token: offer.launch_token }));
        ws.send(JSON.stringify({ type: "client_hello", codecs: ["jpeg"] }));
      };
      ws.onmessage = ({ data }) => {
        if (typeof data !== "string") {
          painting = painting.then(() => paint(data)).catch(console.warn);
          return;
        }
        const msg = JSON.parse(data);
        if (msg.type === "worker_ready") resolve(msg);
        if (msg.type === "failed") reject(new Error(msg.message ?? msg.error));
        onMessage(msg);
      };
      ws.onclose = (event) => {
        onMessage({ type: "socket_closed", code: event.code });
        reject(new Error(`socket closed (${event.code})`));
      };
    });
  })();
  ready.catch(release); // a failed start must not leave a session running

  return {
    ready,
    release,
    get session() { return session; },
    get socket() { return ws; },
    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 `socket_closed` are messages this file generates; every other message comes from the session.

**4. Describe the place, then start.** The first prompt builds the world, so send it before `start`:

```js
import { openSession } from "./windflow-session.js";

const world = openSession("hy-nomas", document.querySelector("canvas"), (msg) => console.log(msg.type));
await world.ready; // worker_ready: commands are accepted from here
await world.command("set_prompt", {
  prompt: "A sunlit stone courtyard with a large central fountain, terracotta pots, a red wooden door on the left and a blue arched door on the right. Clear daylight, no people.",
});
await world.command("start");
```

**5. Send held keys as one snapshot.** WASD moves and the arrow keys turn the view. Send the complete held set as a `set_input_state` snapshot with a rising `input_seq`, on the open socket:

```js
const KEYS = {
  KeyW: "forward", KeyA: "left", KeyS: "back", KeyD: "right",
  ArrowUp: "look_up", ArrowLeft: "look_left", ArrowDown: "look_down", ArrowRight: "look_right",
};
const held = new Set();
let inputSeq = 0;

function sendKeys() {
  world.socket.send(JSON.stringify({
    type: "set_input_state", input_seq: ++inputSeq, pressed_keys: [...held], client_ts: Date.now(),
  }));
}
addEventListener("keydown", (event) => {
  const key = KEYS[event.code];
  if (!key || held.has(key) || event.target.closest?.("input, textarea")) return;
  event.preventDefault(); // keep the arrow keys from scrolling the page
  held.add(key);
  sendKeys();
});
addEventListener("keyup", (event) => {
  if (held.delete(KEYS[event.code])) sendKeys();
});
addEventListener("blur", () => {
  held.clear();
  sendKeys();
});
```

**6. Change what happens with a text event.** A prompt sent during the run continues the same world from the next segment:

```js
await world.command("set_prompt", {
  prompt: "The same courtyard as light rain begins, wet stone, grey sky",
});
```

`prompt_accepted` acknowledges the new text at once. `prompt_applied` arrives with the first frames generated from it, which is the moment to update a caption or an event log.

## 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}`: 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 WebSocket offer without creating a second session.

On the socket, `authenticate` with the offer's `launch_token` must be the first message; `client_hello` with `codecs: ["jpeg"]` asks for one JPEG per binary message. Commands are accepted after `worker_ready`. Before it they fail with `409 queued` or `409 no_active_session`.

## How segments shape the controls

Hy-Nomas does not read the keyboard every frame. At the start of each two-second segment it takes the held keys and the current prompt, generates that stretch of the world, and streams it. Four things follow:

- **A change shows a few seconds after it is sent.** Show the press in your interface as soon as `input_accepted` arrives, and let the picture follow at the next segment. A snapshot whose `input_seq` is not ahead of the last applied one comes back with `stale: true` and is not applied.
- **Hold a key through a segment.** Keys are read once per segment, so a press and release that both fall between two reads is not seen. Steady keys give the clearest motion.
- **Movement is persistent state.** It continues through later segments until it is released, which is why the example sends an empty snapshot on `keyup` and on window `blur`.
- **Text events continue the same place.** A mid-run `set_prompt` keeps the world and changes what happens in it. To go somewhere else, send `reset`, describe the new place with `set_prompt`, and `start` again.

| Command | What it does |
|---|---|
| `set_prompt` | Before `start`, the opening scene; during the run, a text event in the same world |
| `set_input_state` | The complete held-key set: `forward`, `back`, `left`, `right`, `look_left`, `look_right`, `look_up`, `look_down` |
| `set_movement`, `set_look_horizontal`, `set_look_vertical` | Single-axis alternatives to the snapshot |
| `set_seed` | The generation seed for the world; send it before `start` |
| `start`, `pause`, `resume`, `reset` | Run control; `reset` clears the world, the prompt and the controls |
| `stop` | Ends generation and discards frames not yet delivered |

The stream carries video only. Read `video_config` rather than hard-coding the 832 × 480 canvas, size the canvas with CSS, and add any sound on the page.

## Write prompts as direct scene descriptions

Prompts reach the model in its native structured prompt format as written, with no rewriting or expansion. Name the place, the main objects and where they sit, the light and the viewpoint; concrete nouns and positions steer it more reliably than style adjectives. For a text event, restate the place and describe the change:

| Intent | Prompt pattern |
|---|---|
| Open a world | A sunlit stone courtyard with a large central fountain, terracotta pots, a red wooden door on the left and a blue arched door on the right. Clear daylight, no people. |
| Something appears | A large bright red balloon rises above the central fountain in the sunlit stone courtyard |
| Weather turn | The same courtyard as light rain begins, wet stone, grey sky |
| Time shift | The same courtyard at dusk, lanterns lit beside both doors |

## Plan each world as a 60-second take

Each world runs for up to 60 seconds, 1,440 frames at 24 fps, and then the session sends `generation_complete`. A session's time covers one full world, so open a new session for the next one. Start a countdown at the first painted frame, and plan a longer piece as a sequence of worlds.

## Track arrived is not first frame visible

| Milestone | Signal | What the viewer should see |
|---|---|---|
| Ready | `worker_ready` | The prompt box, with a loading state |
| Generating | `generation_started`, after `start` | Loading state |
| Stream arrived | `video_config`, then the first binary message | Loading state |
| First frame visible | The first `drawImage` returns | The world; enable the keys and start the countdown |
| Key acknowledged | `input_accepted` | Immediate local feedback |
| Event visible | `prompt_applied` | The caption for the new event |

Enable the keys at the first painted frame, not at `worker_ready`. The session's `transport.fallback_policy.first_frame_timeout_ms` (10 seconds by default) is how long the platform's own client waits for media after `generation_started`.

## Release the session on unmount

Call `world.release()` when the component unmounts and on `pagehide`. It sends `{ action: "detach", connection_id, release: true }` with `keepalive`, which ends the session at the worker and hands the capacity back; the response reports `session_released: true`. A world nobody is steering is still a ready session, and paused time is billed, so release is what stops the meter. If a tab dies without releasing, the worker keeps the session for a reconnect grace (30 seconds by default). For drops, see [recovering a dropped stream](https://windflow.online/guides/recover-a-dropped-realtime-video-stream).

## 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` |
| Queue | `failed` with `scheduler_queue_expired` | Polling stopped while queued |
| Socket | Closes with `4401` or `4408` | `authenticate` not sent first; or the session already hit its time limit |
| Ready | `start` rejected: "prompt is required" | Send `set_prompt` before `start` |
| Ready | `set_image` or `set_conditioning` rejected | Hy-Nomas opens a world from text alone |
| Running | `set_prompt` rejected: "Hard prompt transitions are unavailable for this model" | Leave `transition` unset; to change place, `reset` and start a new world |
| Running | A quick tap does nothing | Keys are read once per segment; hold the key through one |
| Running | The view keeps moving after the player lets go | A key release was missed; send an empty snapshot on `blur` |
| Running | `input_accepted` with `stale: true` | `input_seq` went backwards; keep one counter for the whole session |
| Running | The stream ends after a minute | Each world is 60 seconds; open a new session for the next one |

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

## FAQ

### Does Hy-Nomas need a seed image?

No. A text prompt opens the world, and held keys and later prompts steer it. For world models that start from your own picture, see the [seed image and prompt guide](https://windflow.online/guides/world-model-seed-images-and-prompts).

### Can I use WebRTC with Hy-Nomas?

Yes. Hy-Nomas is offered over both `ws-h264` and `livekit`. Request `transport: "livekit"` and join the room with the offer's `url` and `participant_token`; commands go to `POST /v1/sessions/{id}/commands` on both transports.

### How long is one Hy-Nomas world?

Up to 60 seconds, 1,440 frames at 24 fps. Open a new session for the next world.

### Can I run Hy-Nomas myself?

Yes. The weights are published on Hugging Face under Apache-2.0. The [directory entry](https://windflow.online/world-models/hy-nomas) summarises the official setup steps and links the sources.
