Genace

How to call a video generation API without blocking your agent

A video render takes minutes. Three things tend to go wrong if you treat that as a normal HTTP request, and all three get worse inside an agent loop:

  1. The connection dies before the render finishes. Proxies, serverless function timeouts and mobile networks all give up long before a two-minute job does.
  2. The agent’s context window fills while it waits. A blocked tool call is dead weight in the transcript.
  3. The session ends and the result has nowhere to go. The user closed the tab; the render completes into the void, already paid for.

The fix is not a longer timeout. It is to stop pretending the call is synchronous.

Submit returns a job, not a video

A generation request returns immediately with a job id and a state. The state machine has five states, three of which are terminal:

queued → running → succeeded
                 → failed
                 → canceled

Your agent gets its tool result in milliseconds and moves on. Collecting the output is a separate concern.

Four ways to collect the result

Different call sites want different shapes, so all four exist:

Webhook — you supply a URL, we POST to it on terminal state. Best for server-side pipelines where there is somewhere durable to receive the result. Nothing has to stay connected.

SSEGET /v1/jobs/{id}/stream holds a connection open and pushes state changes as they happen. Best for a UI that wants a progress bar. The stream polls upstream on your behalf and emits an event each time the state moves.

Long pollGET /v1/jobs/{id}?wait=N blocks for up to N seconds and returns whatever state it reached. Best when the caller is a script that just wants the answer and can retry. With wait=0 it returns the current state immediately.

Cron backstop — jobs nobody is watching still get advanced on a schedule. This is the one that matters for the agent case: if the session ended, the job still reaches a terminal state and the result is still there when anyone asks for it later.

Failure and cancellation refund the credits

Credits are deducted when the job is submitted and refunded in full if it ends in failed or canceled:

if (update.state === 'failed' || update.state === 'canceled') {
  await refundForJob({ /* … */ });
}

This matters more than it sounds. Without it, the correct way to handle a flaky upstream is to build your own bookkeeping — track what you spent on attempts that produced nothing, and reconcile it later. Refund-on-failure removes that entire category of work.

POST /v1/jobs/{id}/cancel triggers the same path deliberately, which makes “abandon this render, I changed my mind” a safe operation rather than a write-off.

Concurrency is a limit, not a queue

Each account has a concurrency ceiling — 1 in-flight job on the free tier, 5 on Pro. Submitting past it returns 429 CONCURRENCY_LIMIT_EXCEEDED rather than queueing.

That is a deliberate choice: a silent queue makes a runaway agent loop look healthy right up until the bill arrives. A 429 makes it visible on the first extra call. See CONCURRENCY_LIMIT_EXCEEDED.

The short version

If you are writing a tool for an agent to call, the tool should return a job id, and something other than the agent should be responsible for the result. Everything above exists to make that arrangement the easy path rather than the one you have to build yourself.

Models covered on this page

Where these facts come from

  • codebase: src/ai/jobs/http.ts — pollUntilTerminal, createJobSseStream
  • codebase: src/ai/jobs/service.ts — job state machine, refund on failure/cancel
  • codebase: src/app/api/v1/jobs/ — job, stream and cancel routes