Skip to content

Provider Platform ​

This doc describes videoclaw's provider/transport architecture as of 2026-09-23, through the seedance-modelark route (#603–#636, the official Seedance 2.5 API on BytePlus ModelArk) and reapi-seedance (#605). Earlier milestones it still carries: the Phase 1c schema upgrade, the Phase 5b Runway port, and the Dreamina route (Seedance 2.0 through a CapCut account).

Routes at a glance ​

RouteMaturityNative transportBuilt-in adapterRequired env
veo-useapiproductionnative-veo.ts (drives the local vclaw-cli Bun package)vclaw-provider-adapter --route veo-useapiUSEAPI_API_TOKEN, USEAPI_ACCOUNT_EMAIL
seedance-directproductionnative-seedance.ts (uses SUTUI_API_KEY)vclaw-provider-adapter --route seedance-directSUTUI_API_KEY
runway-useapiproductionnative-runway.ts (pure Node fetch+fs)vclaw-provider-adapter --route runway-useapiUSEAPI_API_TOKEN, USEAPI_ACCOUNT_EMAIL
dreamina-useapiproduction (registered, not pursued since 2026-09-09)native-dreamina.ts (pure Node fetch+fs)vclaw-provider-adapter --route dreamina-useapiUSEAPI_API_TOKEN, VCLAW_DREAMINA_ACCOUNT (optional VCLAW_DREAMINA_REGION default CA, VCLAW_DREAMINA_MODEL default seedance-2.0)
magnific-restproductionnative-magnific.ts (pure Node fetch, Magnific/Freepik REST)vclaw-provider-adapter --route magnific-restMAGNIFIC_API_KEY (optional VCLAW_MAGNIFIC_API_URL, VCLAW_MAGNIFIC_MODEL)
seedance-modelarkproduction (certified 2026-09-21: one paid 4 s 720p job, 87,300 tokens ≈ USD 0.93)native-modelark.ts (pure Node fetch, BytePlus ModelArk contents/generations/tasks)vclaw-provider-adapter --route seedance-modelarkARK_API_KEY (optional VCLAW_MODELARK_MODEL default dreamina-seedance-2-5-260628, VCLAW_MODELARK_BASE_URL)
reapi-seedanceproduction (paid per second; opt-in only, never default-routed)native-reapi.ts (pure Node fetch+fs; hosts local references at submit time)vclaw-provider-adapter --route reapi-seedanceVCLAW_REAPI_SEEDANCE_VIA (required: treg needs TREG_TOKEN, optional TREG_ORG; direct needs REAPI_API_KEY + GO_BANANAS_API_KEY); optional VCLAW_REAPI_SEEDANCE_RESOLUTION=480p|720p|1080p for a cheap probe
minimax-useapiproduction (Hailuo-3.0 certified 2026-10-03: 768p 4 s, 28 credits; opt-in only, never default-routed)native-minimax.ts (pure Node fetch+fs; uploads references to the MiniMax account)vclaw-provider-adapter --route minimax-useapiUSEAPI_API_TOKEN, VCLAW_MINIMAX_ACCOUNT (optional VCLAW_MINIMAX_MODEL default Hailuo-3.0, VCLAW_MINIMAX_RESOLUTION=768|1440)

minimax-useapi (2026-10-03) is MiniMax's Hailuo video on a hailuoai.video plan registered on useapi.net, on the same USEAPI_API_TOKEN as runway-useapi and dreamina-useapi; the design contract is docs/design/specs/2026-10-03-minimax-useapi-route.md. Version 1 renders Hailuo-3.0 only (the one model a live job has certified, on a Pro plan,): 768p or 1440p (2K), whole seconds 4–15, native stereo sound, mapped from the profile (720p → 768, 1080p → 1440) or set with VCLAW_MINIMAX_RESOLUTION. VCLAW_MINIMAX_ACCOUNT is required and always sent — a MiniMax fileID belongs to one account, and an unnamed create lets useapi pick an account at random — and, with the model and resolution, it binds the run-contract approval. The Seedance family through MiniMax is refused by name (the Pro plan answered HTTP 422 Content generation error, please regenerate (2400001|0) twice on 2026-10-03, no charge; render Seedance on seedance-modelark or reapi-seedance), and Veo and Sora through MiniMax are out of version 1. Every scene is planned, and every audio and video reference measured (2–15 s each, ≤ 15 s per kind), before the first upload. A lone keyframe is a first frame (fileID, plus end_frame_fileID from the end keyframe); several images, a character sheet, or any video or audio reference is an omni request whose references the prompt names @Image1…, @Video1…, @Audio1… (missing markers are prepended; an exact prompt keeps its own and an unresolved one is refused, since MiniMax answers 400). promptOptimization is always false. Credits are debited AT SUBMIT (measured: 28 for 4 s at 768p), so a create's intent is on file before its POST: a 429 means nothing was submitted and is the one answer sent again (after a wait); 412 (out of credits), 596 (account on hold) and 401 stop the run; any other refusal fails the scene unbilled; a lost answer leaves it submit-unknown, returns the job, and is never re-submitted. The poll finds a lost create in the scheduler (running jobs) or the history (finished ones) only when its creation time falls in the window AND its prompt is the exact prompt sent — the useapi token may be shared by another machine, and GET scheduler/ does not echo replyRef despite the docs — and binds only a single unowned match — and, when other unowned jobs of the account and model share the window, only one that started within 15 s of the create. "Nothing was created" (failed, unbilled) is said only when NO unowned job of the account and model sits in the window in either list whatever its prompt, every running job's prompt and model could be read, both lists were complete and the window has closed. The intent time is re-stamped before every POST attempt, so a create that lands after long 429 waits is searched from its own time. An ordinary submitted scene downloads over outputs/scene-N.mp4 as every route does, but a job BOUND after a lost answer (automatically or with execute-bind) never replaces a scene-N.mp4 this job did not write, and a bind is refused while one is there. Job state carries a revision: a poll that finds the file changed since it read it re-reads it and re-applies only its own changes (bounded retries; an abandon that landed meanwhile keeps its status and suppresses the cost), and any other stale write is refused. The lookup window opens 120 s before the intent, allowing that much clock skew against MiniMax's own timestamps; a scheduler answer that is not a list, a running job without a parseable start, or a history item without a creation time or model makes "nothing was created" unprovable. vclaw video execute-bind --task <videoId> and execute-abandon are the exits; after an abandon with work in flight the job never states an actualCost again. Statuses 5, 7, 14 and 16 are moderation (16: an input reference rejected, e.g. a face in a VIDEO reference — a real face in an IMAGE reference is allowed), and a 404 on a known job means it was moderated and removed. Only downloadURL, the no-watermark file, is downloaded; a finished job without it fails rather than saving the watermarked videoURL. actualCost is stated in minimax-credits once nothing is pending or lost, and labelled as table-derived (duration × the per-second rate; credits are debited at submit), not read back from MiniMax. execute-cancel POSTs videos/cancel for running jobs (whether MiniMax refunds the credits is not known). Not in the batch lane and not a Cinema route in version 1; the plan's readable credit balance (GET features) is what would make an honest exact quote possible later.

seedance-modelark is the official Dreamina Seedance 2.5 API on BytePlus ModelArk (the international Volcengine Ark), billed per second of output to your BytePlus account. It is a separate route from seedance-direct on purpose (ADR 0007): different host (ark.ap-southeast.bytepluses.com), different key (ARK_API_KEY, never SUTUI_API_KEY), different request shape (an ordered content[] of text, image_url, video_url and audio_url items with a role each) and a different biller. VCLAW_MODELARK_MODEL switches between dreamina-seedance-2-5-260628 (default: 30 reference images, 10 videos, 10 audio clips, 4–30 s) and the cheaper 2.0 family — dreamina-seedance-2-0-260128 (480p–1080p; the cheapest 1080p Seedance on any route, ≈ USD 0.37 a second against 0.57 on 2.5) and dreamina-seedance-2-0-fast-260128 / dreamina-seedance-2-0-mini-260615 (480p–720p) — (9 / 3 / 3, 4–15 s, no audio-only input). The transport plans every scene before the first paid create: a lone keyframe is a strict first_frame (plus last_frame from the end keyframe) and the vendor then requires ratio: adaptive; anything else is an omni reference-to-video request whose references the prompt names as @Image 1, @Video 1, @Audio 1. The two task types cannot share one request, so a keyframe that arrives with a voice clip is sent as @Image 1 in omni mode with the prompt told it is the first frame — a soft lock, reported as an issue. Duration must be a whole number inside the model's range (the route never sends the vendor's -1 "you pick"); the resolution is the profile's (720p by default, 1080p when it says so) unless the scene's packet carries its own (480p, 720p or 1080p — checked against the route's capability list, since the per-task value bypasses the profile gate; there is no CLI flag for it); 4k is refused. Every reference video, local or remote, is held before upload to the shape limits both vendor pages state — each side 300–6000 px, 407,696–8,295,044 pixels, aspect 0.4–2.5, 23.9–60 fps (nominal, and the average for variable-frame-rate footage — more than 2% off nominal, so timebase rounding is not VFR; the vendor's pages say 24–60, but 23.976, the standard film cadence, was measured accepted on 2026-09-23, so the floor follows the measurement), .mp4/.mov with H.264/H.265 video and AAC/MP3 audio (PCM in .mov too, on the Seedance 2.5 tutorial's word; the API reference lists AAC/MP3 only) — with the resolution tier (the pages disagree: 480p/720p or up to 4k) left to the vendor. Local video/audio references are hosted on the same temporary public host finish and lipsync use (~3 h); small images are inlined as base64; https:// and ModelArk asset:// sources pass through — a remote http(s):// video or audio clip is still measured with ffprobe over the URL (20 s probe timeout) so it counts toward the model's reference-seconds budget; one that cannot be measured — unreachable, timed out, not media — is refused before submit as seedance-modelark scene N: could not measure the length of remote … (reason), never as a bare ffprobe error (asset:// cannot be probed and is trusted as given). On the render-scenes fallback ladder every local refusal escalates to the next route, this one included, so a reference only the vendor could reach is better kept local. A create is one POST, never retried, and its INTENT is written to the job state before the POST: a lost answer (network error, timeout, 5xx, 408/499, a 2xx with no id) leaves the scene submit-unknown — the task may exist and be billing — with the scenes not yet attempted recorded as failed-and-unbilled and the job returned as submitted (never thrown, so the run keeps its job id), and the next execute-status looks for it in ModelArk's own task list by model and creation window (two minutes, plus clock skew), binds it only when exactly one task not already owned by a job in that output directory is there (a second project rendering on the same key in the same two minutes is the one case this cannot tell apart — and so is another machine or tool rendering on the same key, which this machine cannot see at all: give each machine its own ModelArk API key, created in the BytePlus console, rather than spacing jobs apart), marks the scene failed-and-unbilled when none has appeared after the window and the list page provably reached back past it, and otherwise leaves it ambiguous, names the candidates, and re-submits nothing (ADR 0008) — an ambiguity that cannot be resolved keeps the job pending indefinitely (a failed sibling does not end it, unlike an ordinary failure) until vclaw video execute-bind --task <taskId> names the task from the ModelArk console (one GET, no submission: bound only if it exists, names this job's model, is not already owned by a job in that output directory, and the scene's scene-N.mp4 is not already another run's clip; the task's own resolution is recorded, when this version prices it, so the bill is priced at what ran; --confirm-bind writes it) or vclaw video execute-abandon stops waiting; nothing times it out; a 4xx is the vendor refusing and is recorded as failed. Job state is written after every create and checked on every read: a file of another job or route, a field of the wrong type (a task id that is not a string, a cost that is not a number) or a scene index used twice is refused before anything reaches ModelArk, naming each problem and the file, and the file is left as it was — narrowly, since a refused file strands the task it holds: the model and resolution need only be text, so a later table edit cannot strand a past job. Submit runs the same check on the file it is about to write, so a storyboard that repeats a scene index, or has one below 0, is refused before any upload or create; a succeeded task's video_url (valid 24 h, 100 downloads) is fetched in the same poll, and the vendor's output token count on it (usage.completion_tokens, else total_tokens) is priced at the list rate for (model, resolution, video input) into the poll's actualCost (USD) and onto the job-state file — stated only when every completed scene has a cost, never guessed, and a list-rate figure only: the vendor's minimum-token floors for video input are not modelled (the 2026-09-21 acceptance job: 87,300 tokens for 4 s at 720p, USD 0.934). The 2.0 fast/mini models sell 480p and 720p only; 1080p on them is refused before submit (standard 2.0 sells 1080p; 4k is refused on every model). Every produce / execute run that completes — a dry run or a live submission; a blocked or failed run writes nothing — freezes the exact ModelArk create body per scene into artifacts/run-contract.json (submittedProviderWire, from the transport's own planner; references as their source paths, since the hosted URL is not knowable before the upload), or the planner's refusal verbatim, and the run dashboard renders it per card as "Exact JSON → native-modelark"; a refused scene is also a blocker on the dry-run report. The body the vendor parses is the one reviewed (same planner, same .env.local); the reference-file checks and content-filter warnings run only at submit, so a body in the contract is not a promise the submit will be accepted. --require-contract binds VCLAW_MODELARK_MODEL and VCLAW_MODELARK_BASE_URL along with the contract, since the model sets the price and the host decides which account is billed. execute-cancel DELETEs queued tasks only — ModelArk cannot stop a running task, and the answer says it is still billing; execute-abandon stops waiting on it. On the direct paths (produce, render-scenes) a chained scene continues the previous one by its last frame (first_frame) unless VCLAW_MODELARK_CHAIN_MODE=extend, which sends the previous clip itself as @Video 1 with omni_reference_task_type: extend and ratio: adaptive on the 2.5 model only (the field is documented for 2.5; a 2.0 model is refused), prepends Continue @Video 1: when the prompt lacks the extension intent the vendor requires, bills the previous clip's seconds as input on top of the output, and — measured on the 2026-09-22 acceptance job (vendor task cgt-20260922213730-rmzl9, 172,800 tokens = USD 1.1059 for 4 s + 4 s at 720p, exactly the vendor formula) — began at the previous clip's last frame without replaying its tail (the vendor's "usually only includes the tail footage of the original video" leaves room for another draw to repeat more); extend mode is refused at 1080p (the Seedance 2.5 tutorial takes reference videos at 480p/720p only; the API reference lists up to 4k — on 2026-09-23 a 1920×1080 reference video was measured accepted in OMNI mode, task cgt-20260923052923-r5f10, which is evidence the tutorial's tier is not the whole story, but an extension at 1080p is its own task type and is still untested, so the stricter reading stands there), and the mode — read from .env.local as well as the shell — is part of the --require-contract fingerprint. The durable queue (produce --auto-chain → cinema-work) follows the same rule: the previous clip's last frame unless the mode is extend, taken once per source take into artifacts/chain-seeds/ so the quote, the revalidation and the submit read the same bytes, and a task already submitted polls without it. On both paths a chained scene that also carries an image of its own sends the frame as @Image 1 in omni mode (profile ratio), a soft first-frame lock. In the overnight batch lane since #604 item 5 (batch-submit --route seedance-modelark enqueues exact-quote tasks after asking this transport's planner about every job); not a Cinema route yet, and no ModelArk quote adapter ships because an honest one cannot be built (2026-09-22): no BytePlus credential exposes an account balance — ARK_API_KEY has no balance call and the Billing OpenAPI (ListBillDetail, ListBillOverviewBy*) lists bills, not a balance — so the quote's balance envelope would have to be typed in by hand, and the vendor's token formula (input + output seconds) × width × height × frame rate / 1024 (24 fps on these models) is published as an estimate that usage.completion_tokens overrides (both measured 4 s 720p jobs billed 97 frames, 87,300 tokens, where the formula gives 96 — see docs/audits/2026-09-22-live-acceptance.md). So a ModelArk scene renders directly with render-scenes --method seedance-modelark --confirm-spend; the queue itself drains only through cinema-work --quote-adapter <exe> with an adapter you supply and stand behind.

reapi-seedance is ByteDance Seedance 2.5 on reAPI's "Less Restriction" row (content_filter:false): a photograph of a real person is accepted as the subject reference and a voice clip as the speech reference — the one API-keyed route with both (the free Higgsfield engine behind seedance-direct also takes a face plus a voice, through a browser session, at $0) — and the clip carries native speech, sound effects and music. Two credential paths, chosen explicitly and never inferred from whichever key is set (two credentials are two bills): treg relays through the treg catalog (reapi.video-gen.seedance-2-5.unrestricted, billed to the treg team balance, references hosted on treg's media host for 7 days) and direct calls https://reapi.ai/api/v1 on your own key (references hosted on Go Bananas R2). reAPI takes only public https URLs, so the transport hosts local references immediately before the HTTP call — after the approval and quote hashes, which bind to the bytes on disk. Submit POST /videos/generations, poll GET /tasks/{id} (free, every 10 s); there is no cancel, so execute-abandon is how you stop waiting. Priced per second of output (read it at treg catalog get reapi.video-gen.seedance-2-5.unrestricted before any --confirm-spend); a reference VIDEO is billed on top of the output, so a voice rides audio_urls (free) — a voice clone contributes its source audio, not its black-frame mp4, whether it is bound to a character or named by an @VoiceName tag. A tagged voice is not an identity reference, so it no longer replaces the scene's own references either: every asset attached to that scene survives, a kind: 'video' one included, and a video reference is reported per scene as billed rather than dropped. A chain link reuses the last frame the poll already downloaded beside the previous clip (scene-3-last-frame.png) rather than re-deriving one, so the seed is the frame the provider ended on; a frame older than the clip beside it is ignored, because a re-render leaves the previous one in place. Whole-second durations 4–30, 480p/720p/1080p, up to 30 image / 10 video / 10 audio references (audio and video each ≤ 30 s combined). Illegal content is still refused, and refunded. The poll states actualCost from the task's own settled credits. A create is one POST, never retried, and its INTENT (with the exact body) is written to the job state before the POST. When the answer is lost — a network error, a timeout, a 5xx that is not treg's treg_saturated 503, a 2xx with no id — the scene becomes submit-unknown, the scenes not yet attempted are recorded as failed-and-unbilled, and the job is RETURNED rather than thrown, so the run keeps a job id and execute-status / execute-abandon have something to act on. Unlike seedance-modelark this can never be resolved automatically: reAPI publishes no endpoint that LISTS tasks (only GET /tasks/{id}, which needs the id the create never returned), so the poll makes no lookup call, re-submits nothing (ADR 0008), keeps the job pending even beside a failed sibling, withholds actualCost while any scene is ambiguous, and repeats that the task may exist and may be billing — never that nothing was billed — naming the intent time to look for in the reAPI dashboard (or treg's call history). vclaw video execute-abandon is the only exit; nothing times it out (in the batch lane, batch-monitor --stall-minutes &lt;n&gt; --fail-wedged is what ends such a queue's own wait — --auto-resubmit is already refused on a paid route), and once a scene has been abandoned the job states no actualCost at all, because that task may still settle and nobody will collect its credits. A 4xx (and treg's 402 out-of-balance or treg_saturated 503, which say nothing was billed — a 503 WITHOUT that marker stays ambiguous) is a refusal: the scene is recorded failed. Whether a refusal THROWS depends on what is already in flight, on this route and on seedance-modelark alike: with nothing billed yet it throws, so the caller sees a rejection and render-scenes' ladder escalates to the next route; once an earlier scene is billing the job is RETURNED with the refusal named and the rest recorded as not attempted, because a throw would leave the run blocked with no job id and strand the paid task.

magnific-rest reaches a whole catalog of other companies' models through one account — it proxies a set of image-to-video models (MiniMax Live, PixVerse V5, Runway Gen4 Turbo, Kling Standard, LTX 2.0 Pro, Kling O1 Pro) through the Magnific/Freepik REST API and is cost-aware (defaults to the cheapest catalog model that fits the operation; the premium model — Kling O1 Pro — is opt-in via VCLAW_MAGNIFIC_MODEL, and unknown ids throw). Seedance is not on this REST catalog: seedance-pro-1080p 404s, so Seedance generation stays on dreamina-useapi/seedance-direct. The same native-magnific.ts client also backs vclaw video image-ops (still-image upscale) and finish --backend magnific-precision. Not every finish backend is a provider route at all: finish --backend ffmpeg-upscale is a purely local ffmpeg pass (src/video/finish-ffmpeg.ts) with no account, no transport and no spend gate — it is the right default when the source simply needs resampling rather than a model inventing detail.

dreamina-useapi reuses the same USEAPI_API_TOKEN as runway-useapi (no new token). Like Runway, Seedance content moderation rejects real human faces — describe stylized/illustrated characters or seed from a generated start frame.

Magnific: REST route vs. the OAuth MCP server ​

Magnific exposes two surfaces, and they are not interchangeable. Which one is usable is decided entirely by who is calling.

ReachUse for
REST (magnific-rest, native-magnific.ts)Anything — scripts, detached drivers, launchd jobs, vclaw itself. Key-based (MAGNIFIC_API_KEY).Everything automated. This is the only surface the codebase can call.
MCP (https://mcp.magnific.com)An interactive agent session only. OAuth-only remote HTTP — there is no key-based headless path.Ad-hoc work you drive by hand.

A detached render driver or an overnight batch cannot use the MCP. That is why this route exists and why it stays, even though the MCP surface is larger.

Two things to know before planning against Magnific:

  • Their published docs lag their API badly. docs.magnific.com lists ~30 MCP tools; the live server exposes ~97. video_upscale — a tool that works — appears zero times in their documentation. Read tools/list (or probe REST directly), never the docs page. The same applies to the model catalog: seed magnific/models.ts from live probes only.
  • The MCP reaches things REST does not, including a working Precision video upscale, image relight/retouch/expand, audio generation, and the Seedance 2.0 family (bytedance-seedance-pro-2.0 and friends, with audio references for lip-sync). None of that is reachable from this route.

src/mcp/ is a read-only MCP server that vclaw exposes — it is not a client and there is no client machinery in the tree. Note that @modelcontextprotocol/sdk is already a direct dependency and ships client classes, so calling Magnific's MCP from vclaw would be new code plus an OAuth flow, not a new dependency. Nothing depends on that today.

Descriptor schema ​

src/video/provider-platform/registry.ts defines DEFAULT_PROVIDER_REGISTRY as an array of VideoProviderDescriptor (from ./types.ts). Each route descriptor has:

typescript
interface VideoProviderDescriptor {
  id: ProviderRouteId;                     // 'veo-useapi' | 'seedance-direct' | ...
  provider: VideoProvider;                 // 'veo' | 'runway' | 'seedance'
  displayName: string;
  path: ProviderPath;                      // 'direct' | 'useapi' | 'aggregator'
  summary: string;
  controls: ProviderControl[];             // 'audio', 'first-frame', 'last-frame',
                                           // 'reference-images', 'camera-grammar', ...
  operationSupport: Array<{
    operation: VideoOperationKind;         // 'text-to-video', 'image-to-video', ...
    aspectRatios: NormalizedAspectRatio[];  // 'landscape' | 'portrait'
    notes?: string[];                      // per-operation gotchas
    maxReferenceImages?: number;
  }>;
  routingHints: {
    latencyClass: 'low' | 'medium' | 'high';
    costClass: 'free' | 'paid' | 'premium';
    trustClass: 'direct' | 'aggregated';
    preferredWorkflows: VideoWorkflowKind[];
  };
  escapeHatches?: Array<{
    name: string;
    description: string;
    options: Array<{ name: string; description: string }>;
  }>;
  notes?: string[];                        // free-form per-route notes
}

This rich schema came from videoclaw during the Phase 1c merge. It replaced the flat supportedOperations[] shape that vclaw-video-core had, and lets the router make capability-aware decisions per operation × aspect ratio rather than per route as a whole.

Routing ​

src/video/provider-platform/router.ts exposes chooseVideoProviderRoute(request, policy). Given a routing request with operation kind + aspect ratio + capability requirements, it filters routes that satisfy the operation × aspectRatio combination, ranks the remainders by the policy's preference (trust-first, capability-first, or balanced), and returns a VideoProviderRouteDecision with the chosen route + rationale.

Routes marked scaffold in provider-status.ts:ROUTE_MATURITY are labeled availability: 'degraded' in the status report and the router will skip them under default policy unless explicitly requested.

Adapter contract ​

Live execution goes through src/video/execution-runtime.ts, which calls resolveAdapterCommand(routeId, env):

  1. Look for the user override env var (VCLAW_<ROUTE>_ADAPTER). If set, that command runs as the adapter — vclaw invokes it with JSON on stdin and expects JSON on stdout.
  2. On seedance-direct only, refuse when the bundled free Higgsfield engine is not usable and nothing explicit points anywhere else — no adapter override, no submit shim, and no VCLAW_SEEDANCE_DIRECT_NATIVE=1. The route carries two materially different providers (free bundled engine, paid Ark/xskill API), so an expired browser session must not migrate renders onto the paid one: execution_blocked_by_readiness, reported as activeTransport: blocked (ADR 0007, ADR 0001).
  3. Otherwise check builtinAdapterCommandForRoute(routeId). Currently returns a built-in adapter command for seedance-direct, veo-useapi, runway-useapi, and dreamina-useapi (the bundled dist/cli/provider-adapter.js binary invoked with --route <id>).
  4. Otherwise throw — the route doesn't have a usable adapter. (All four live routes ship a built-in adapter; the removed veo-direct route was the lone exception and no longer exists.)

resolveAdapterCommand derives its answer from resolveActiveTransport, so for the same environment the command that runs and the transport vclaw video providers names cannot drift. The two read different environments on purpose: the report merges the workspace .env.local, while the runtime has no dotenv autoloader and reads the shell environment only (the adapter is also SPAWNED with that environment, so a command named only in the file would run without the variables it needs). The report names that gap instead of promising a transport the render will not pick.

Only submit is gated. A poll, cancel or lookup reaches a provider someone already chose and cannot spend, so refusing it would strand the charge rather than prevent it.

The adapter protocol:

StageInput (stdin)Output (stdout)
submit{ scenes: [{ sceneIndex, prompt, ... }], outputDir, ... }{ externalJobId, rawResult: {...} }
poll{ action: 'poll', outputDir, externalJobId }{ status: 'pending' | 'completed' | 'failed', outputs?: [...], issues?: [...], actualCost?: { currency, amount } }
cancel{ action: 'cancel', outputDir, externalJobId, workspaceRoot }{ status: 'cancelled' | 'unsupported', externalJobId?, issues?: [...] }
bind (seedance-modelark only){ action: 'bind', outputDir, externalJobId, workspaceRoot, taskId, sceneIndex?, dryRun?, siblingOutputDirs? }{ externalJobId, sceneIndex, taskId, applied, task: { status, model, createdAt, resolution }, createdInsideWindow, contestedBy, ownershipComplete, issues }

bind is vclaw video execute-bind's transport call for a caller that keeps its own job records (the rap lane's paid_take.py --bind): it reads the named task once, creates nothing, and binds it only when it is on the job's model and no job owns it (dryRun must be a boolean, sceneIndex a whole number). Its result names any other lost create that could equally have made the task (contestedBy: binding it leaves a waiting create's window empty, and its next poll settles it as never accepted; for an abandoned one, the bind may collect that create's clip and cost as this scene's) and whether every job file could be read (ownershipComplete); both are reported, not enforced. On seedance-modelark, siblingOutputDirs (poll and bind, optional) names other output dirs whose jobs own tasks on the same key: a caller that gives each job its own dir (the rap lane gives each window one) lists the others, or a window's lost create would take the next window's task, created seconds later on the same model, for its own. The poll also never takes a task that another lost create on the same model (in any of those dirs) could equally have made — including one whose wait was abandoned (execute-abandon records abandonedAt; its task may still exist) and one with no intent time: when every such rival can still answer, it waits until the last rival's window closes, then reports the scene; an abandoned rival, or one with no intent time, never answers, so the scene is reported at once. Job files of other routes in the same dir are skipped. A seedance-modelark poll names the lost creates no later poll will settle by itself in rawResult.undecidedScenes ({ sceneIndex, reason, taskIds }, reason one of no-intent-time, task-without-id, output-path-taken, window-not-searched, several-tasks, another-lost-create, owners-unreadable, list-refused), so a caller can stop waiting; a window still open, or a task list that failed to load (network, 5xx, 408, 429), is not one of them. rawResult.lostCreateScenes names every scene still waiting on a lost create after the poll, decidable or not (#747).

actualCost on a completed poll is what the render cost, stated by the transport from the provider's own price table; the cinema queue's paid worker refuses a completion without it, while free routes and legacy adapters simply omit it.

The runtime is strict about the returned status: pollExecutionPayload throws unless poll status is exactly pending / completed / failed, and cancelExecutionPayload throws unless cancel status is exactly cancelled / unsupported. The cancel result is read into VideoExecutionCancelResult{ status, externalJobId, issues, rawResult } — note the field is issues, not warnings.

cancelled is a claim about the PROVIDER: answer it only when the provider confirmed the job stopped. An adapter whose provider has no cancel verb answers unsupported and changes nothing locally, because execute-cancel treats unsupported as read-only and keeps collecting the render, while a false cancelled closes the run over a job that is still running and still billed.

The built-in adapter (src/cli/provider-adapter.ts) also honors per-route command shims for each of its eight routes — *_SUBMIT_CMD, *_POLL_CMD, *_CANCEL_CMD (e.g. VCLAW_DREAMINA_USEAPI_SUBMIT_CMD / VCLAW_DREAMINA_USEAPI_POLL_CMD / VCLAW_DREAMINA_USEAPI_CANCEL_CMD). The action is chosen from input.action: poll → the poll shim, cancel → the cancel shim, lookup → the lookup shim, no action → the submit shim; bind never goes to a shim (it is the transport's own and creates nothing), and any other action is refused. Setting any shim (or the full *_ADAPTER override) marks the route as an execution override, which suppresses the runtime dependency probes for that route in the status report.

Native in-process transports ​

All eight routes have native TypeScript transports that bypass the adapter subprocess hop:

  • src/video/native-veo.ts — defaults to <workspace>/vclaw-cli/flow.ts via bun. Looks for cookie.json for Google Labs Flow auth. Customizable via VCLAW_VEO_CLI_ROOT, VCLAW_VEO_OUTPUT_DIR, VCLAW_VEO_BUN_BIN, VCLAW_VEO_COMMAND_TIMEOUT_MS (the sidecar is told when that limit will kill it, VCLAW_VEO_KILL_AT_MS, and a clip download gives up 15 s before then (a quarter of a shorter limit), failing as a download instead of being killed with no error; #710). Forwards optional omni-flash fields to flow.ts when present (byte-identical when absent): executionProfile.veoModel → -m (omni-flash unlocks audio/V2V), per-scene voicePreset → --voice (omni-flash-only, gated in route-capabilities.ts), allowlisted durationSeconds (4/6/8/10) → --duration, per-scene referenceVideoMediaId → --ref-video (omni-flash-only V2V edit, distinct from the scene-chaining seed), and executionProfile.flowResolution (360p|720p) → --video-resolution (omni-flash-only generation tier; 360p ≈ half the credits, set with --veo-resolution, env fallback VCLAW_FLOW_RESOLUTION). Every submit prices itself first from the account's own Flow model table (src/video/flow-account.ts, GET /accounts/{email}) and records the result as actualCost in the job state the poll returns — a combination the table does not list refuses before anything is rendered; with no USEAPI_* credentials in the environment no cost is recorded (as before). The same lookup powers the shipped cinema-queue quote adapter, dist/cli/flow-quote-adapter.js, so what the queue authorizes and what the transport reports are one number. The sidecar also applies Google's free 1080p upscale to every clip it generates on the useapi backend — it is the only layer holding the mediaGenerationId the endpoint requires. --no-upscale / VCLAW_FLOW_UPSCALE=0 opts out; a failed upscale keeps the generated clip and warns. The direct/Puppeteer backend never upscales, so a clip rendered there ships at the resolution it was generated at.

    That gap is a transport gap, not a missing id — an earlier version of this paragraph got it wrong. generation.ts passes Google's raw operations[] straight through from aisandbox-pa.googleapis.com, so the direct backend does hold operation.metadata.video.mediaGenerationId; what it lacks is the useapi client that reaches POST /videos/upscale. Cross-wiring useapi is not the fix (that endpoint only accepts ids from a Google account registered with useapi — the very account whose owner would be on --backend useapi). The correct wiring is Google's own upsample on aisandbox-pa, through the same browser session that generated the clip. Its request shape is undocumented and must not be guessed — the one-pass capture recipe that would settle it is written up in docs/design/flow-native-upsample-discovery.md.

Why a Flow generation failed (response.failureReasons) ​

When every operation in a job fails, useapi's top-level error reads All operations failed for all of them — the real reason sits in response.failureReasons, a de-duplicated list of what Google said. Two string kinds arrive, and a job can carry either or both:

  • terminal codes — PUBLIC_ERROR_UNSAFE_GENERATION, PUBLIC_ERROR_PROMINENT_PEOPLE_FILTER_FAILED, PUBLIC_ERROR_AUDIO_FILTERED, PUBLIC_ERROR_MINOR, PUBLIC_ERROR_SEXUAL, PUBLIC_ERROR_DANGER_FILTER, PUBLIC_ERROR_IP_INPUT_IMAGE
  • classifier labels naming which filter fired, e.g. IP_PROHIBITED — note these carry no PUBLIC_ERROR_ prefix

The list is Google's and open-ended: match on substrings, never on an exhaustive set. The field is absent when Google gave no reason at all, which is common — around a third of V2V edit failures arrive with nothing attached. An absent failureReasons is not an error in itself and those jobs are usually worth one retry. A job that failed before any operation started (a rejected request, a moderated prompt) never reaches this path and returns the Google error verbatim instead.

IP_PROHIBITED is the one refusal that never clears on retry. Google's intellectual-property classifier flagged an input image as copyrighted, branded or recognizable — and since the image is unchanged between draws, every retry is flagged again. The fix is to replace the reference image: original photos of non-famous subjects pass where celebrity photos, film stills, product shots and copyrighted characters do not. The name is a trap worth repeating — the "IP" is intellectual property, nothing to do with network addresses. isIpProhibitedFailure() in motion-overlay/v2v-transport.ts short-circuits the retry loop on it rather than spending the whole --v2v-retries budget, and the sidecar's describeFlowFailures() checks it before the probabilistic "resubmit" advice so a co-occurring *_BLOCKED cannot mislead.

Flow account media, not yet wired ​

useapi added three account-media endpoints on 2026-09-01 that nothing here calls yet. Recorded so they are not rediscovered:

  • GET /assets/projects/{email} — every project holding media on an account, with media counts, a type breakdown and a date range. A Flow account accumulates projects and only one is the project the API currently writes to.

  • GET /assets/media/{email} — one project's contents. Files sent to POST /assets/{email} stay on the account after the generation finishes, so they accumulate on a long-running integration; likelyUpload marks each and likelyUploads counts them.

  • DELETE /assets/{email} — permanently removes media by mediaGenerationId, max 100 per call, explicit ids only (no wildcard, no project purge). The batch is validated for format, ownership and account before anything is deleted. Irreversible — download anything worth keeping first. Any wiring of this needs a confirmation gate, like every other destructive path here.

  • src/video/native-seedance.ts — direct Seedance API calls via SUTUI_API_KEY. No subprocess hop, no external CLI dependency. Paid, and reached only when VCLAW_SEEDANCE_DIRECT_NATIVE=1 selects it.

  • src/video/native-runway.ts — direct UseAPI REST calls via USEAPI_API_TOKEN + USEAPI_ACCOUNT_EMAIL. Pure Node fetch + fs. Supports both Gen-4.x (firstImageAssetId for i2v) and Seedance-2.0 (startFrameAssetId for keyframe-driven) modes via the unified /runwayml/videos/create endpoint. Cancel marks scenes failed locally and warns about remote tasks UseAPI free-tier cannot cancel server-side.

  • src/video/native-dreamina.ts — direct UseAPI Dreamina REST calls via USEAPI_API_TOKEN + VCLAW_DREAMINA_ACCOUNT. Pure Node fetch + fs. Reference routing: referenceRole === 'character', OR more than one image, OR any video/audio reference → Omni Reference (omni_N_imageRef/videoRef/audioRef, multi-character lock); exactly one image keyframe → firstFrameRef (first_frame image-to-video mode); else text-to-video with the requested ratio. References are capped at 9 image / 3 video / 3 audio (assertDreaminaReferenceBudget), preflighted across ALL tasks fail-fast before any upload. Asset:// URIs are skipped with a warning (those are ARK avatars, not Dreamina assetRefs). It reuses seedance-content-filter: on a content-violation submit error it retries with a level-1 then level-2 sanitized prompt. Cancel has no UseAPI verb, so it marks local scenes failed, warns that the remote job keeps running and consuming credits, and returns status: 'cancelled'.

  • src/video/native-modelark.ts — the official Seedance 2.5 / 2.0 API on BytePlus ModelArk via ARK_API_KEY, pure Node fetch + fs. Plans every scene before the first paid create (first-frame vs omni, whole-second durations, per-model reference caps); holds every reference video to the shape gate in providers/modelark-references.ts; writes an INTENT to the job state before each POST so a lost answer is recoverable, and checks that file on every read and before every write. execute-bind names a lost task; execute-cancel reaches a queued task only.

  • src/video/native-reapi.ts — Seedance 2.5 "Less Restriction" via reAPI, on TREG_TOKEN (relay) or REAPI_API_KEY (direct); the selector is never inferred, because two credentials are two bills. Hosts local references immediately before the call, content-hash cached.

  • src/video/native-minimax.ts — Hailuo 3.0 on a hailuoai.video plan via useapi.net (USEAPI_API_TOKEN + VCLAW_MINIMAX_ACCOUNT). Uploads references to the named account, records the create intent before the POST (credits are taken at submit), and downloads only the no-watermark downloadURL.

  • src/video/native-magnific.ts — the Magnific REST route (magnific-rest) and the finish / image-ops backends built on it.

The pure-fetch native transports (native-runway, native-dreamina, native-seedance, native-magnific, native-modelark, native-reapi, native-minimax) accept an optional fetchImpl parameter for test injection. native-veo is the exception — it drives the vclaw-cli Bun subprocess rather than HTTP fetch, so it has no fetchImpl. The provider-level adapter functions in src/video/providers/runway-useapi.ts and src/video/providers/dreamina-useapi.ts also accept fetchImpl, so tests can mock the entire HTTP layer end-to-end.

Adding a new route ​

When you want to add a working new provider route (for example a future kling-useapi):

  1. Descriptor. Add a VideoProviderDescriptor entry to DEFAULT_PROVIDER_REGISTRY in src/video/provider-platform/registry.ts. Use videoclaw's rich schema (controls, operationSupport, routingHints, escapeHatches).
  2. Provider HTTP code. Add src/video/providers/<route>.ts with the submit/poll/fetchResult functions. Accept fetchImpl?: FetchLike in each input interface for test injection.
  3. Native transport. Add src/video/native-<route>.ts mirroring native-runway.ts: own workspace/env/job-state, call into providers/ for HTTP. Accept fetchImpl?: FetchLike in the options.
  4. Declare the id. Add it to PROVIDER_ROUTE_IDS in src/video/provider-platform/types.ts — every Record<ProviderRouteId, …> in the tree then fails to compile until the route lands in it, which is the checklist enforcing itself: ROUTE_PREREQUISITES (route-prerequisites.ts: env vars, dependencies, maturity, the ..._ADAPTER / ..._SUBMIT_CMD names, lane flags; a route with two credential paths declares credentialAlternatives), ROUTE_CAPABILITIES (route-capabilities.ts), NATIVE_TRANSPORTS and adapterEnvVarForRoute in src/video/execution-adapter.ts (plus a new ActiveTransport literal in src/video/types.ts), routeCommandEnvVars in src/cli/provider-adapter.ts, and the submit/poll/cancel dispatch in src/video/provider-adapter-runner.ts. Add the id to DEFAULT_ROUTING_POLICY.providerOrder (an omitted id scores 0) and to the routeId enums in schemas/video/artifacts/execution-plan.schema.json and execution-report.schema.json.
  5. Lanes and families, only where they apply. lanes.batchQueue also means BatchRouteId / BATCH_ROUTE_IDS (batch-queue.ts), the poll dispatch and --route allowlist in src/cli/handlers/batch.ts, MOGRAPH_BATCH_ROUTES and the batch manifest schema enum. A Seedance-family route joins SEEDANCE_ROUTE_FAMILY (show-bible-attach.ts), the generateAudio default (execution-profile.ts) and the Seedance prompt guidance (prompt-guidance.ts). A route-local env var that changes the spend goes into submitEnvironmentFingerprint (run-contract-approval.ts). A route with no provider-side cancel joins ABANDON in execution-abandon.ts.
  6. Tests. Add src/tests/<route>.test.ts (provider) and src/tests/native-<route>.test.ts (native wrapper with a scripted fetch mock), then update the hand-written literals that pin the declaration: capability-contract.test.ts (REGISTRY_ORDER, EXPECTED_ROUTES, the specialist-lane membership arrays, SKILL_TOKEN_PATTERN), route-capabilities.test.ts, veo-runtime.test.ts, cli-providers.test.ts, provider-status.test.ts, verify-env.test.ts.
  7. Docs. The fenced core-routes tables here and in docs/CAPABILITIES.md, the route ∈ {…} line in README.md and the routing diagram in docs/DIAGRAMS_SOURCE.md / docs-site/diagrams/src/docs-routing.mmd; then npm run docs:sync. capability-contract.test.ts fails until every one of them names the route.

reapi-seedance (2026-09-21) is the most recent worked example — its src/video/providers/reapi-seedance.ts + src/video/providers/treg-client.ts

  • src/video/native-reapi.ts are the freshest reference for this checklist, including a route with two explicitly chosen credential paths and references hosted at submit time. dreamina-useapi (providers/dreamina-useapi.ts + native-dreamina.ts) and runway-useapi (Phase 5b, 6e99443) remain good canonical references. Read those files for the canonical structure.

Built to be driven by agent hosts like Claude Code, Claude Desktop, or Codex · Source-available, commercial use requires a paid license.