# 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

<!-- capability-contract:core-routes -->
| Route | Maturity | Native transport | Built-in adapter | Required env |
|---|---|---|---|---|
| `veo-useapi` | production | `native-veo.ts` (drives the local `vclaw-cli` Bun package) | `vclaw-provider-adapter --route veo-useapi` | `USEAPI_API_TOKEN`, `USEAPI_ACCOUNT_EMAIL` |
| `seedance-direct` | production | `native-seedance.ts` (uses `SUTUI_API_KEY`) | `vclaw-provider-adapter --route seedance-direct` | `SUTUI_API_KEY` |
| `runway-useapi` | production | `native-runway.ts` (pure Node fetch+fs) | `vclaw-provider-adapter --route runway-useapi` | `USEAPI_API_TOKEN`, `USEAPI_ACCOUNT_EMAIL` |
| `dreamina-useapi` | production (registered, not pursued since 2026-09-09) | `native-dreamina.ts` (pure Node fetch+fs) | `vclaw-provider-adapter --route dreamina-useapi` | `USEAPI_API_TOKEN`, `VCLAW_DREAMINA_ACCOUNT` (optional `VCLAW_DREAMINA_REGION` default `CA`, `VCLAW_DREAMINA_MODEL` default `seedance-2.0`) |
| `magnific-rest` | production | `native-magnific.ts` (pure Node fetch, Magnific/Freepik REST) | `vclaw-provider-adapter --route magnific-rest` | `MAGNIFIC_API_KEY` (optional `VCLAW_MAGNIFIC_API_URL`, `VCLAW_MAGNIFIC_MODEL`) |
| `seedance-modelark` | production (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-modelark` | `ARK_API_KEY` (optional `VCLAW_MODELARK_MODEL` default `dreamina-seedance-2-5-260628`, `VCLAW_MODELARK_BASE_URL`) |
| `reapi-seedance` | production (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-seedance` | `VCLAW_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 |
<!-- /capability-contract -->

`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 `dreamina-seedance-2-0-fast-260128` / `dreamina-seedance-2-0-mini-260615` (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 — run such jobs a few minutes 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. 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 <n> --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**.

| | Reach | Use 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:

| Stage | Input (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?: [...] }` |

`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 seven 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, anything else → the submit shim. 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 seven 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`. 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-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`) 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.
