← FFmpeg Desk / API
Get a token

Driving FFmpeg Desk from your own code

Everything the web app's four AI lanes do is available over HTTP. Send a task, the ffmpeg command line you are about to run, and the parse the browser made of it, and get back one JSON object. The command-line reader the browser runs for free — the shell-accurate tokeniser, the option arity table, the split into globals, per-input and per-output regions, the -filter_complex graph with its labelled pads, every -map resolved, the credential detector and all thirty-two checks — is not re-run server-side. If you drive the API directly you must send your own facts object, because that object is the only thing the model is held accountable to.

What this is

One app, four lanes, one command line. You paste (or POST) a single ffmpeg command; a lane reads it, walks its filter graph, sorts out its subtitle path or rewrites it for a delivery target. Every lane returns a runnable command as its artifact, and in the web app that returned command goes straight back through the same reader before it is shown — a rewrite that still trips a check says so under the rewrite. If you build your own consumer, that re-parse is yours to reproduce; see the output contract.

Three things this API does not do, said once at the top so nothing below surprises you. It runs no ffmpeg. Nothing here executes, probes, or touches your media, and no reply may claim otherwise. It has not seen your source. Resolution, duration, frame rate, bitrate and output size are unknown unless your command or your note states them, and the prompt forbids inventing them. It does not remember. Every run is independent; continuity between lanes is something you pass in through prior_read.

Base URL and headers

ThingValue
Base URLhttps://api.skillsafe.ai/v1/app-api
AuthAuthorization: Bearer <token>
BodyContent-Type: application/json. The body is the input object — there is no {"input": ...} wrapper, and wrapping it returns 200 while hiding task from the model
App identitycarried by the token. There is no X-App-Slug header. The one place the slug ffmpeg-desk appears is the body of POST /guest
IdempotencyIdempotency-Key: <string> on /run and /run-stream. See the closing section
CallPathCosts
Mint a guest tokenPOST /v1/app-api/guestfree, and the only call whose body is not a lane input
Who am IGET /v1/app-api/mefree
Price an inputPOST /v1/app-api/estimatefree, creates no job
Run a lanePOST /v1/app-api/runmetered; reserves hold_credits
Run a lane, streamedPOST /v1/app-api/run-streammetered; the same run as /run
Poll a jobGET /v1/app-api/jobs/{job_id}free

The response envelope

Every response, success or failure, is the same shape. Read ok before you touch data.

{"ok": true,  "data":  { ... }}
{"ok": false, "error": {"code": "payment_required", "message": "...", "details": { ... }}}

Do not skip the check. An error response is still HTTP-shaped JSON with a 200-looking body structure, and ignoring ok turns a 402 into a confusing null three lines later.

Error codes

HTTPerror.codeWhat it means and what to do
400VALIDATION_ERRORThe body was not a JSON object, or a field was the wrong type — command as an array, facts as a string, context_note as null. Fix the body; a retry will not help. Note what this is not: an unknown task is not a validation error, because the model infers a lane and reports task_inferred. Neither is an unknown target — the browser normalizes anything outside the eight values to general before it sends, and you should too.
400invalid_requestPOST /guest without a slug. The slug is what binds the token to this app.
401unauthorizedNo token, a malformed one, or one that has expired. Mint a new guest token (step 2), or sign in on the token page for a personal one.
402payment_requiredThe balance cannot cover min_credits. Check /estimate against /me before submitting, which is exactly what the app does so this never fires: its run button is disabled with the shortfall named rather than submitting into a 402.
404not_foundA job id that does not exist, one belonging to another subject, or a slug on /guest that is not a deployed app.
409idempotency_conflictThe same Idempotency-Key was reused with a different body. Keys must be derived from the body, not from a counter.
429rate_limitedBack off and retry with the same idempotency key. Never tight-loop; a polling loop with no sleep is the usual cause.
500, 502, 503internalAnything 5xx is retryable. Retry once with the same idempotency key, which is exactly what the key is for — a 5xx after the job was created returns the same job rather than starting a second charged run.

truncated: true is not an error. It comes back on a successful job, alongside charged_credits, and it means the run started with a reduced output cap because the balance covered min_credits but not hold_credits. The lane ran, it was charged, and the reply is real but cut short: the JSON may end mid-document, and if it does, the client's own parse fails and it retries once with a reformat instruction in retry_note. Treat truncated: true as "this answer is incomplete, say so to your user" — never as "this answer is finished". A deliver reply whose command stops halfway through a -filter_complex is the failure mode to expect, and a half-written ffmpeg command is worse than none.

The one field that decides everything: task

Document this first because it selects the entire rest of the contract. task is one of four lanes, each with its own prompt, its own body shape and its own idea of what verdict means. They are meant to run in sequence over one command, and each one hands the next its conclusions through prior_read.

taskThe app's own labelThe question it answersbody keys
readRead this commandWhat does this line actually do, option by option, before anyone runs it. Where does each stream go, which options are in a position where they do nothing, and what dominates the wall-clock.what_it_does, walkthrough, streams, cost_shape
graphWalk the graphThe -filter_complex wiring: pads, labels and order. What each node contributes, what each label carries, and which reorderings make it cheaper.graph_reads_as, nodes, uncovered_nodes, wiring_notes, simplifications
subsSort the subtitlesBurned in, muxed as a soft track, or absent and needing to be added — and whether the text will actually appear on the player the user cares about.path_call, steps, compat, styling
deliverRewrite it for deliveryThe command to ship for the chosen target, with the diff against the user's own, the trade-offs it makes, and how to verify the result.target_call, changes, ladder, tradeoffs, verify

Every lane's body also carries command and command_note — the runnable ffmpeg command is the artifact of all four lanes, not just deliver. Absent or unrecognised, task is not an error: the model picks the closest lane, sets task_inferred to true, and sets task to the lane it actually answered, rather than blending two contracts into one reply. A reply naming no known lane at all is a parse failure, not a lane, and the client throws reply did not name a known task.

The request body is the input object

There is no {"input": ...} wrapper. Wrapping it returns 200 while hiding task from the model, which is the single most expensive mistake available here — you get a fluent answer to a question you did not ask. There is no X-App-Slug header either: app identity rides on the token, and the only place the slug ffmpeg-desk appears is the body of POST /guest.

Worked request — task: "read"

A lecture recording being clipped with -c copy, which is the mistake this lane exists to catch: the seek is on the wrong side of -i and a filter is asked for alongside a stream copy. Everything under facts was parsed in the browser from the complete command; rules is trimmed here for length but on the wire every check that ran is present.

POST /run
{
  "task": "read",
  "command": "ffmpeg -i lecture.mkv -ss 00:12:30 -t 90 -c copy -vf scale=1280:720 -movflags +faststart clip.mp4",
  "target": "general",
  "context_note": "I only want a 90 second clip out of the middle of a lecture recording, and I assumed copying would make it instant.",
  "facts": {
    "readable": true,
    "binary": "ffmpeg",
    "token_count": 14,
    "globals": [],
    "inputs": [
      {"id": "in0", "url": "lecture.mkv", "ext": "mkv", "protocol": "", "format": "",
       "is_image": false, "is_subtitle": false, "is_stream": false, "is_device": false,
       "is_concat_demuxer": false, "loop": "", "framerate": "", "hwaccel": "", "options": []}
    ],
    "outputs": [
      {"id": "out0", "url": "clip.mp4", "ext": "mp4", "format": "",
       "container": "mp4", "container_label": "MP4",
       "vcodec": "copy", "acodec": "copy", "scodec": "copy",
       "crf": "", "video_bitrate": "", "audio_bitrate": "", "preset": "", "pix_fmt": "",
       "movflags": "+faststart", "vf": "scale=1280:720", "af": "", "maps": [],
       "no_video": false, "no_audio": false,
       "options": ["-ss 00:12:30", "-t 90", "-c copy", "-vf scale=1280:720",
                   "-movflags +faststart"]}
    ],
    "filter_graph": null,
    "maps": [],
    "rules": [
      {"id": "R03", "title": "Overwrite behaviour is decided", "severity": "medium",
       "state": "FAIL",
       "detail": "neither -y nor -n is set - in a script this blocks forever on \"File exists. Overwrite?\""},
      {"id": "R05", "title": "Stream copy is not combined with filtering", "severity": "high",
       "state": "FAIL",
       "detail": "out0: video is set to copy while -vf asks for filtering - filtering requires re-encoding, so ffmpeg refuses the combination"},
      {"id": "R06", "title": "Seeking is on the right side of -i", "severity": "high",
       "state": "FAIL",
       "detail": "out0: -ss sits AFTER the input while the stream is copied, so ffmpeg decodes and throws away everything before the cut - move -ss in front of -i"},
      {"id": "R07", "title": "The pixel format is one every player decodes", "severity": "high",
       "state": "not-applicable",
       "detail": "no H.264 or HEVC output into an MP4-family container"},
      {"...": "one row per check that ran, pass, FAIL or not-applicable, with the evidence"}
    ],
    "flags": [
      {"id": "FF-001", "severity": "high", "location": "out0", "rule": "R05",
       "label": "Stream copy is not combined with filtering",
       "why": "out0: video is set to copy while -vf asks for filtering - filtering requires re-encoding, so ffmpeg refuses the combination"},
      {"id": "FF-002", "severity": "high", "location": "out0", "rule": "R06",
       "label": "Seeking is on the right side of -i",
       "why": "out0: -ss sits AFTER the input while the stream is copied, so ffmpeg decodes and throws away everything before the cut - move -ss in front of -i"},
      {"id": "FF-003", "severity": "medium", "location": "command", "rule": "R03",
       "label": "Overwrite behaviour is decided",
       "why": "neither -y nor -n is set - in a script this blocks forever on \"File exists. Overwrite?\""}
    ],
    "required_flag_ids": ["FF-001", "FF-002"],
    "secrets_found": 0,
    "stats": {"token_count": 14, "input_count": 1, "output_count": 1, "filter_count": 0,
              "chain_count": 0, "label_count": 0, "map_count": 0, "global_count": 0,
              "container": "mp4", "container_label": "MP4",
              "vcodec": "copy", "acodec": "copy", "scodec": "copy",
              "rules_run": 15, "rules_failed": 3, "flag_count": 3, "required_flag_count": 2,
              "secret_count": 0, "has_graph": false, "is_transcode": false,
              "is_copy_only": true},
    "note": "Every item above was parsed in the user's browser from the COMPLETE command line. Treat it as authoritative about what the command contains. You must answer for every id in required_flag_ids."
  }
}

Worked request — task: "graph"

A different command, because this lane needs a graph to walk: a watermark overlay that also tries to take a thumbnail crop out of the same scaled pad. The new field is prior_read — the previous read reply's own conclusions, handed forward so this lane builds on the reading instead of re-deriving it. Send the whole facts object again; runs are stateless and nothing is remembered between them.

POST /run
{
  "task": "graph",
  "command": "ffmpeg -i clip.mp4 -i logo.png \\\n  -filter_complex \"[0:v]scale=1280:-1[base];[1:v]scale=140:-1[wm];[base][wm]overlay=W-w-24:H-h-24[v];[base]crop=1280:600:0:60[thumb]\" \\\n  -map \"[v]\" -map 0:a \\\n  -c:v libx264 -crf 21 -preset slow -b:v 4M \\\n  -c:a copy \\\n  out.mp4",
  "target": "web",
  "context_note": "The watermark is for a client review cut. I also wanted a thumbnail crop out of the same render, which is why the crop is in there.",
  "facts": {
    "readable": true,
    "binary": "ffmpeg",
    "token_count": 22,
    "globals": ["-filter_complex [0:v]scale=1280:-1[base];[1:v]scale=140:-1[wm];[base][wm]overlay=W-w-24:H-h-24[v];[base]crop=1280:600:0:60[thumb]"],
    "inputs": [
      {"id": "in0", "url": "clip.mp4", "ext": "mp4", "is_image": false, "options": []},
      {"id": "in1", "url": "logo.png", "ext": "png", "is_image": true, "options": []}
    ],
    "outputs": [
      {"id": "out0", "url": "out.mp4", "ext": "mp4", "container": "mp4",
       "container_label": "MP4", "vcodec": "libx264", "acodec": "copy",
       "crf": "21", "video_bitrate": "4M", "preset": "slow", "pix_fmt": "", "movflags": "",
       "maps": ["[v]", "0:a"],
       "options": ["-map [v]", "-map 0:a", "-c:v libx264", "-crf 21", "-preset slow",
                   "-b:v 4M", "-c:a copy"]}
    ],
    "filter_graph": {
      "chains": 4,
      "filters": [
        {"id": "f1", "chain": 1, "name": "scale", "args": "1280:-1",
         "in_labels": ["0:v"], "out_labels": ["base"], "expected_pads": null},
        {"id": "f2", "chain": 2, "name": "scale", "args": "140:-1",
         "in_labels": ["1:v"], "out_labels": ["wm"], "expected_pads": null},
        {"id": "f3", "chain": 3, "name": "overlay", "args": "W-w-24:H-h-24",
         "in_labels": ["base", "wm"], "out_labels": ["v"],
         "expected_pads": {"in": 2, "out": 1}},
        {"id": "f4", "chain": 4, "name": "crop", "args": "1280:600:0:60",
         "in_labels": ["base"], "out_labels": ["thumb"], "expected_pads": null}
      ],
      "labels_defined": ["base", "wm", "v", "thumb"],
      "terminal_pads": ["v", "thumb"],
      "problems": ["[base] is consumed 2 times (f3, f4) - one pad can only feed one place, so this needs a split"]
    },
    "maps": [
      {"spec": "[v]", "output": "out0", "kind": "label", "resolves": true, "detail": ""},
      {"spec": "0:a", "output": "out0", "kind": "input", "resolves": true, "detail": ""}
    ],
    "rules": [{"...": "one row per check that ran"}],
    "flags": [
      {"id": "FF-001", "severity": "critical", "location": "command", "rule": "R18",
       "label": "The filter graph's labels are wired correctly",
       "why": "[base] is consumed 2 times (f3, f4) - one pad can only feed one place, so this needs a split"},
      {"id": "FF-005", "severity": "high", "location": "f4", "rule": "R17",
       "label": "Every labelled filter output is mapped",
       "why": "[thumb] (from f4) never reach an output - ffmpeg refuses to start with \"Output with label ... does not exist in any defined filter graph\" or silently drops the work"},
      {"...": "FF-002 through FF-004 and FF-006, FF-007, in severity order"}
    ],
    "required_flag_ids": ["FF-001", "FF-002", "FF-003", "FF-004", "FF-005"],
    "secrets_found": 0,
    "stats": {"input_count": 2, "output_count": 1, "filter_count": 4, "chain_count": 4,
              "label_count": 4, "map_count": 2, "rules_run": 21, "rules_failed": 7,
              "flag_count": 7, "required_flag_count": 5, "has_graph": true,
              "is_transcode": true}
  },
  "prior_read": {
    "verdict": "fix-first",
    "what_it_does": "Scales the clip to 1280 wide, scales the logo to 140 wide, overlays it 24 px in from the bottom right, and encodes to H.264 in an MP4 alongside a copied audio track. A second chain crops a thumbnail out of the scaled video and goes nowhere.",
    "cost_shape": "The x264 encode at -preset slow dominates; the two scales and the overlay are cheap by comparison, and the audio is copied so it costs nothing.",
    "ignored_options": [
      {"group": "out0", "option": "-b:v 4M", "state": "suspect"}
    ],
    "streams": [
      {"stream": "video", "from": "in0", "to": "out0", "action": "re-encoded to libx264 crf 21"},
      {"stream": "video", "from": "in1", "to": "out0", "action": "overlaid as a watermark"},
      {"stream": "audio", "from": "in0", "to": "out0", "action": "copied"}
    ],
    "findings": [
      {"id": "FFD-001", "severity": "critical", "location": "f4",
       "title": "[base] feeds two filters, which one pad cannot do"}
    ]
  }
}

prior_read is exactly the subset the app forwards, and nothing else from the read reply travels: verdict, what_it_does, cost_shape, an ignored_options list built from the walkthrough rows whose state was ignored or suspect and trimmed to {group, option, state}, a streams list trimmed to {stream, from, to, action}, and a findings list trimmed to {id, severity, location, title}.

Worked request — task: "subs"

The subtitle lane, on the classic failure: a subtitles= path containing a space, unquoted. The shell split it before ffmpeg saw the line, so what the user thinks is a filter argument is parsed as a second output — and the browser's parse says so, which is why facts.outputs has an out0 nobody meant to write. target: "social" matters here: on a feed, burn-in is usually the only path that works.

POST /run
{
  "task": "subs",
  "command": "ffmpeg -y -i talk.mp4 -i talk.srt \\\n  -vf subtitles=/Users/me/My Videos/talk.srt:force_style='FontSize=24' \\\n  -c:v libx264 -crf 20 -pix_fmt yuv420p \\\n  -c:a copy -c:s srt \\\n  -movflags +faststart talk-subbed.mp4",
  "target": "social",
  "context_note": "The captions have to be visible in a feed where nobody turns subtitles on, and the file lives in a folder with a space in the name.",
  "facts": {
    "readable": true,
    "binary": "ffmpeg",
    "token_count": 22,
    "globals": ["-y"],
    "inputs": [
      {"id": "in0", "url": "talk.mp4", "ext": "mp4", "is_subtitle": false, "options": []},
      {"id": "in1", "url": "talk.srt", "ext": "srt", "is_subtitle": true, "options": []}
    ],
    "outputs": [
      {"id": "out0", "url": "Videos/talk.srt:force_style=FontSize=24", "ext": "",
       "container": "", "container_label": "", "vcodec": "", "acodec": "", "options": []},
      {"id": "out1", "url": "talk-subbed.mp4", "ext": "mp4", "container": "mp4",
       "container_label": "MP4", "vcodec": "libx264", "acodec": "copy", "scodec": "srt",
       "crf": "20", "pix_fmt": "yuv420p", "movflags": "+faststart",
       "vf": "subtitles=/Users/me/My",
       "options": ["-vf subtitles=/Users/me/My", "-c:v libx264", "-crf 20",
                   "-pix_fmt yuv420p", "-c:a copy", "-c:s srt", "-movflags +faststart"]}
    ],
    "filter_graph": null,
    "maps": [],
    "rules": [{"...": "one row per check that ran"}],
    "flags": [
      {"id": "FF-001", "severity": "critical", "location": "out1", "rule": "R12",
       "label": "The container will actually hold these codecs",
       "why": "out1: MP4 cannot carry subtitle srt (srt) - the muxer rejects this before the first frame is encoded"},
      {"id": "FF-002", "severity": "high", "location": "out0", "rule": "R28",
       "label": "ffmpeg can tell what to write",
       "why": "out0 (Videos/talk.srt:force_style=FontSize=24): no file extension and no -f, so ffmpeg cannot pick a muxer"},
      {"id": "FF-003", "severity": "high", "location": "out0", "rule": "R32",
       "label": "Every bare word on the line really is an output",
       "why": "out0 (\"Videos/talk.srt:force_style=FontSize=24\") sits directly after the -vf value, so an unquoted space split that argument and the rest of it became an extra output"}
    ],
    "required_flag_ids": ["FF-001", "FF-002", "FF-003"],
    "secrets_found": 0,
    "stats": {"input_count": 2, "output_count": 2, "filter_count": 0, "map_count": 0,
              "rules_run": 17, "rules_failed": 3, "flag_count": 3,
              "required_flag_count": 3, "has_graph": false, "is_transcode": true}
  }
}

Worked request — task: "deliver"

The lane where target stops being metadata and starts changing the answer. Same overlay command as the graph example, same facts, but now the question is what to ship. target: "web" asks for H.264 High, -pix_fmt yuv420p, a CRF in the low twenties and -movflags +faststart; target: "hls" would ask for segments, a pinned keyframe interval and a ladder. Change nothing but target and you get a materially different command back.

POST /run
{
  "task": "deliver",
  "command": "ffmpeg -i clip.mp4 -i logo.png \\\n  -filter_complex \"[0:v]scale=1280:-1[base];...\" \\\n  -map \"[v]\" -map 0:a -c:v libx264 -crf 21 -preset slow -b:v 4M -c:a copy out.mp4",
  "target": "web",
  "context_note": "Goes on the marketing site. It has to play in Safari on an iPhone and start before it finishes downloading.",
  "facts": { "...": "the identical parsed object shown above, verbatim" },
  "prior_read": { "...": "the same trimmed read-lane object shown above" }
}

Two fields not shown above appear only in specific circumstances, and both are documented in full in the input table: clip_note, which the app sends when the command was too long to send whole, and retry_note, which it sends when a previous reply failed to parse.

The input object, field by field

This is the whole contract on the way in. It is what readForm() in the app's own app.js builds, field for field, so what the browser sends and what you send are the same object.

FieldTypeMeaning
taskstring, requiredDocumented first, above, because it selects everything else. One of read, graph, subs, deliver. Absent or unrecognised, the model picks the closest lane, sets task_inferred to true and sets task to the lane it actually answered, rather than blending two contracts into one reply.
commandstring, requiredThe ffmpeg command line, verbatim. Send it exactly as the user would run it — the quoting is the bug in a large share of broken ffmpeg lines, so normalising it before you send destroys the evidence. Backslash-newline continuations are fine and are read as continuations, not as arguments. The browser clips this at 16,000 characters, cutting from the middle on whole token boundaries with roughly 62% of the budget given to the head and the rest to the tail, and inserting a marker comment where the cut fell; when it clips, it also sends clip_note. The app requires at least 8 characters before it will run at all.
targetstring, requiredThe delivery target. One of exactly eight, listed below. Anything outside that set normalizes to general rather than erroring, so a typo does not fail the call — it quietly changes the answer, which is worse. It is read by every lane but it decides the deliver lane: the same command with target: "web" and target: "archive" comes back as two different commands.
context_notestring, optionalFree text. What the user is trying to do, or what went wrong. The single most useful optional field: it outranks the model's assumptions about intent, and it is what turns a flagged -b:v alongside a CRF into a deliberate bitrate cap. It never outranks a parse — the browser's reading of the command always wins.
factsobject, optional but the point of the whole thingEverything the browser parsed out of the command. See below — this is the field that decides whether you get an audited answer or an unaudited one. It is always present in the app's own requests, even when the parse failed, in which case it is the honest failure shape {"readable": false, "reason": "...", "note": "..."}.
prior_readobject, optionalThe handoff, sent by the three later lanes when the user came from read. Exactly {verdict, what_it_does, cost_shape, ignored_options[], streams[], findings[]}, where ignored_options[] is the walkthrough rows whose state was ignored or suspect, trimmed to {group, option, state}; streams[] is trimmed to {stream, from, to, action}; and findings[] is trimmed to {id, severity, location, title}. With it present, the later lane agrees with, refines or explicitly disagrees with the reading instead of starting over; without it, the four lanes read as four unrelated tools over one command.
clip_notestring, optionalPresent only when command was clipped. It says how many tokens there were and how many were sent, states that the cut fell on whole token boundaries with both ends kept, and states that every item in facts was parsed over the complete command — so the model treats facts as authoritative about structure and the excerpt as a sample it may quote from. Without it, a model that sees six options in the text and twenty in facts has no way to know which to believe, and the instruction "do not claim anything about the tokens you were not given" has nothing to attach to.
retry_notestring, optionalOnly when a previous reply failed to parse. The app puts the exact reformat instruction here — name the envelope, task set to the lane, every array present even when empty, findings ids sequential from FFD-001, one reconciliation entry per required flag id sent, the lane's body exactly as specified including command and command_note, no prose and no code fence — and it bumps the attempt counter in the idempotency key, so the retry is a deliberate second run rather than an accidental duplicate. It retries once and once only, then shows the raw text.

The eight target values

These are the exact strings in TARGETS in ffscan.js. The label column is what the app shows a user, and it is a fair summary of what the deliver lane is being asked for.

targetWhat it means
generalNo particular target — just make it correct. Keep the user's intent, fix what is wrong, change nothing that cannot be justified.
webWeb MP4, progressive download in a browser. H.264 High, -pix_fmt yuv420p, CRF in the low 20s, -movflags +faststart, AAC audio. Compatibility beats cleverness.
hlsHLS, segmented adaptive streaming. Keyframe interval pinned to the segment duration, and a ladder whose rungs are far enough apart to be worth switching between. This is the only target for which ladder is expected to be non-empty.
socialA vertical or square cut for a feed. The platform re-encodes whatever you send, so send something clean and conservative; framing usually means scale plus crop or pad, and captions burned in rather than muxed.
archiveVisually lossless or truly lossless intermediate or master: high-bit-depth 4:2:2 or 4:4:4, ProRes, DNxHR, FFV1, or x264 CRF in the low teens. Size is not the constraint.
audioAudio only. Drop the video with -vn, pick the codec for where it is going, and do not resample without a reason.
gifAnimated GIF or WebP: palettegen and paletteuse in a two-pass filtergraph with fps and scale ahead of them.
streamLive RTMP or SRT ingest: -f flv for RTMP, a fixed GOP, a fast preset, -g at twice the frame rate, and CBR-ish rate control with -maxrate and -bufsize.

Sending your own facts

Be clear about what this object is, because it is easy to mistake it for decoration. facts is what a real ffmpeg command-line reader running in the user's own tab parsed: the binary and the token count; every global option; one row per -i input with its URL, extension, protocol, forced format and the flags that say whether it is an image, a subtitle file, a network stream, a capture device or a concat demuxer list; one row per output with its container, its video, audio and subtitle codecs, its CRF, bitrates, preset, pixel format, movflags, -vf, -af and every option assigned to it; the -filter_complex graph as chains, nodes with ids f1, f2, ..., their arguments, their in and out pad labels, the pad counts they are expected to have, the labels defined, the terminal pads and the structural problems found; every -map resolved against the inputs and the labels with a resolves boolean; one row per rule with a state of pass, FAIL or not-applicable; a numbered flags array; the required_flag_ids list; how many credentials were found; and a stats block.

Three consequences follow, and they are the reason to send it:

So: omit facts and the model works from your raw command text alone. It will still answer, and the answer will read exactly as confident. But nothing holds it to the parse, no reconciliation is possible, no citation can be grounded, and the app's own honesty machinery — the ungrounded-id marking, the unanswered-flag count, the uncovered-node list on graph — has nothing to compare against. That is a much weaker answer, and it is weaker in a way that is invisible unless you know to look for it. A model asked to eyeball a 400-character one-liner will happily tell you that -b:v in front of the first -i sets the video bitrate.

And fabricating a facts object is worse than omitting it. You would not be decorating a prompt; you would be lying to a model that has been instructed to trust you over its own reading, about a command that is about to run on a real render. If you did not parse it, do not assert it. If the input did not parse at all, send the honest failure shape and the model returns verdict: "unreadable" instead of inventing a reading:

{
  "readable": false,
  "reason": "the paste did not read as an ffmpeg command line",
  "note": "The browser could not parse this as an ffmpeg command, so there are NO measured facts to hold you to. Work from the raw text and say plainly that the parse failed."
}

A minimal hand-written facts that is already worth sending

You do not need the whole object. Everything below is either derivable with a short argument walk or already known to you, and it is enough for the model to be held to the parse, for a rule to be reviewed and for a flag to be reconciled. Omit any key you did not compute rather than guessing at it.

{
  "readable": true,
  "binary": "ffmpeg",
  "globals": ["-y"],
  "inputs": [
    {"id": "in0", "url": "lecture.mkv", "ext": "mkv", "options": []}
  ],
  "outputs": [
    {"id": "out0", "url": "clip.mp4", "ext": "mp4", "container": "mp4",
     "vcodec": "copy", "acodec": "copy", "vf": "scale=1280:720",
     "options": ["-ss 00:12:30", "-t 90", "-c copy", "-vf scale=1280:720"]}
  ],
  "filter_graph": null,
  "maps": [],
  "rules": [
    {"id": "R05", "title": "Stream copy is not combined with filtering",
     "severity": "high", "state": "FAIL",
     "detail": "out0: video is set to copy while -vf asks for filtering"}
  ],
  "flags": [
    {"id": "FF-001", "severity": "high", "location": "out0", "rule": "R05",
     "label": "Stream copy is not combined with filtering",
     "why": "filtering requires re-encoding, so ffmpeg refuses the combination"}
  ],
  "required_flag_ids": ["FF-001"],
  "secrets_found": 0
}

Ids are the citation vocabulary and they are structural, not decorative. Inputs run in0, in1, ... in the order the -i options appear; outputs out0, out1, ...; filter nodes f1, f2, ... across the whole graph rather than per chain; graph labels are cited either bare (base) or bracketed ([base]) and both are accepted; rules are R01 through R32; flags are FF-001, FF-002, ... assigned after sorting by severity, so FF-001 is always the most severe thing found. Option flags are citable exactly as they appear on the line: -crf, -c:v, -movflags. The bare word command is always citable and means the line as a whole. Any id scheme works as long as your own grounding check agrees with it, but if you renumber flags after sorting, sort first — the app does.

Whose command is this

An ffmpeg command line routinely carries a live credential, and that changes what sending one means. Three things follow, and they are different from the web app's situation because you are now the one assembling the request.

First, in the browser nothing leaves the tab until a lane runs. The tokeniser, the region split, the filter graph, the map resolution, all thirty-two checks and all eight exports are JavaScript in the page and need no account at all. If all you want is the parse, use the app and send nothing anywhere.

Second, the credential detector is client-side too, and it runs before anything else. An RTMP ingest URL ends in a live stream key; a signed URL carries its signature in the query string; an inline password is a password. The browser counts what it found into facts.secrets_found and raises a critical flag at command, and the prompt requires the model to name that the credential is there, tell the user to rotate it, and substitute a placeholder rather than repeat the value — it writes rtmp://live.example.com/app/STREAM_KEY and notes the substitution. That protects the reply. It does not protect the request: the command you send is the command you sent. Rotate the key.

Third, the run is stateless. No command you send is retained beyond the run that used it, and continuity between lanes is something you pass in via prior_read.

The output contract, lane by lane

One JSON object, and nothing else — no prose before it, no prose after it, no code fence. The envelope is identical across all four lanes, which is what lets one renderer, one history writer and one export path serve every lane; only body differs. Every key is present on every reply, and every array is present even when it is empty: an omitted key never means "none".

{
  "task": "read",                   // one of read | graph | subs | deliver
  "task_inferred": false,           // true when the lane was chosen, not given
  "title": "short name for what this command does, under 70 characters",
  "verdict": "runs-clean | fix-first | will-fail | unreadable",
  "summary": "two to four sentences a person can act on",
  "assumptions": ["what had to be assumed because the command does not say"],
  "open_questions": ["what you would need to know to be sure"],
  "findings": [
    {"id": "FFD-001",
     "severity": "critical | high | medium | low",
     "location": "out0",
     "title": "one line",
     "why": "what actually goes wrong, in terms of what the user will observe",
     "fix": "the specific change, naming the option"}
  ],
  "reconciliation": [
    {"flag_id": "FF-001",
     "status": "confirmed | noted | set-aside | superseded",
     "note": "one line"}
  ],
  "next_lane": {"lane": "graph", "reason": "why that lane, on this command"},
  "body": { ... }                   // the lane-specific half, below
}
Envelope keyMeaning
taskThe lane that was actually answered. Compare it to what you sent. A reply naming no known lane throws rather than rendering.
task_inferredtrue only when task was missing or unrecognised and the lane was chosen. summary then says which and why. Two lanes are never blended.
titleA short name for what this command does, under 70 characters. Defaults to Untitled command if the model returns nothing.
verdictFour values and nothing else. runs-clean = ffmpeg will start and produce what was asked for. fix-first = it runs, but the result is wrong, unplayable or wasteful. will-fail = ffmpeg exits with an error, or the shell mangles the line before ffmpeg ever sees it. unreadable = the paste is not an ffmpeg command. Anything unrecognised normalizes to unreadable, so a missing verdict fails loud rather than reading as a pass.
summaryTwo to four sentences a person can act on: the answer, the reason, the next move.
assumptionsWhat had to be assumed because the command does not say it. Empty is a legitimate answer.
open_questionsWhat only the user can settle — the source resolution, what player has to play this, whether the stream key is still live.
findingsIds run FFD-001, FFD-002, ..., and the array is re-sorted critical first by the client. location must be a citable id; an unrecognised severity normalizes to medium. An entry with no id gets one assigned from its position.
reconciliationExactly one entry per id in facts.required_flag_ids, keyed by that flag's own id. An entry with no flag_id is dropped; an unrecognised status normalizes to confirmed.
next_lane{lane, reason}, the honest next step across read, graph, subs and deliver. Empty when there is genuinely nothing to hand off, and cleared outright if it names the lane that just ran.

reconciliation[].status is the audit vocabulary and each word means something specific: confirmed agrees the flag is a real problem and the findings cover it; noted agrees it is real but says it is minor here; set-aside says it does not apply to this command and must say why; superseded says a bigger finding in the same reply replaces it.

Two things the client does to this object before it renders, worth knowing if you are writing your own consumer. It normalizes, as the table above describes. And it grounds: every location, walkthrough group, stream from and to, node id and wiring-note label is checked against the ids your facts actually contained, and a companion boolean (location_ungrounded, group_ungrounded, from_ungrounded, to_ungrounded, id_ungrounded, label_ungrounded) is added next to it. A label may be cited with or without its brackets and both ground. It also adds command_scan to every body, and uncovered_nodes to the graph body. Those keys are client-side additions, not model output: do not expect them on the wire, and do add your own equivalent if you are reusing this contract, because an invented filter node that nobody marks is an invented filter node nobody notices.

What every body carries, whatever the lane

body keyTypeMeaning
commandstringThe artifact. A complete, runnable, single ffmpeg command — the binary, every option, every input, the output — using the same file names the user gave. Written as a shell command with \ line continuations, one logical group per line, with any -filter_complex, -vf or -af value quoted when it contains ;, [, ', * or a space. If a lane has nothing to change, it returns the user's own command reformatted and says so. A credential is never repeated here: the model substitutes a placeholder and notes it.
command_notestringWhat changed and why, or that nothing changed.
command_scanobject (client-added)The returned command put straight back through the same reader that produced facts. {checked, reason, flags_remaining, remaining_rules[], cleared, introduced[], stats}: checked is false with a reason when the returned command was empty or did not parse; remaining_rules is the failing rule ids after the rewrite, cleared counts the ones the rewrite fixed, and introduced is the ones it created that were not failing before. This is the highest-value four lines in the whole client: a rewrite that still trips a check says so under the rewrite, rather than at 3am on a render farm.

task: "read" — Read this command (what does this line actually do)

Assume the user inherited the command from a blog post, a colleague or a model, and does not trust it. The browser has parsed the line; this lane resolves what it means.

body keyTypeMeaning
what_it_doesstringOne paragraph: the whole command in plain language, start to finish.
walkthrough[]array{group, option, means, state}, plus a client-added group_ungrounded. group is global, an input id or an output id. state is one of correct, suspect, redundant, ignored, deprecated; anything else normalizes to correct. Covers every option group the command has, in the order they appear. ignored is the important one: an output option sitting in front of -i is read as a demuxer option and does nothing at all, and the user needs to be told that in those words.
streams[]array{stream, from, through, to, action}, plus client-added from_ungrounded and to_ungrounded. stream is one of video, audio, subtitle, data; anything else normalizes to video. through is a filter node id or - when nothing filters it. Covers every stream that reaches an output, plus any deliberately dropped by -an, -vn or a negative map.
cost_shapestringWhat dominates the wall-clock and why — the encoder, the filter, the seek, the container. Not a duration, and not a file size: this app has not seen the media.
command, command_notestringThe user's own command, reformatted onto readable lines, with nothing changed except quoting that was outright broken. This lane explains; it does not redesign. If you want the redesign, that is deliver.

Worked response — the read request from further up

{
  "task": "read",
  "task_inferred": false,
  "title": "90-second clip out of a lecture, copied rather than re-encoded",
  "verdict": "will-fail",
  "summary": "This command will not run. It asks for a stream copy and a scale
              filter at the same time, and ffmpeg refuses that combination
              outright. The -ss is also on the output side, so even without the
              filter it would decode and throw away the first twelve and a half
              minutes rather than seeking instantly, which is the opposite of
              what you assumed.",
  "assumptions": ["That the 1280x720 scale is wanted at all - if the source is
                   already 720p, dropping -vf makes the copy work as intended."],
  "open_questions": ["Whether lecture.mkv is already 1280x720, which decides
                      whether this is a copy job or a transcode job."],
  "findings": [
    {"id": "FFD-001", "severity": "high", "location": "out0",
     "title": "-c copy and -vf cannot both apply to the same stream",
     "why": "Filtering means decoding, filtering and re-encoding. Copying means
             moving the compressed packets through untouched. ffmpeg exits with
             \"Filtergraph ... was defined for a stream that is being copied\"
             before it writes a frame.",
     "fix": "Drop -vf if the copy is what you want, or drop -c copy and encode
             with -c:v libx264 -crf 20 -preset veryfast if the scale is."},
    {"id": "FFD-002", "severity": "high", "location": "-ss",
     "title": "the seek is after -i, so it decodes everything before the cut",
     "why": "An -ss in front of -i is an input seek and lands on the nearest
             keyframe almost instantly. After -i it is an output seek: ffmpeg
             reads and discards every packet before 00:12:30 first.",
     "fix": "Move -ss 00:12:30 in front of -i lecture.mkv and keep -t 90 where
             it is."},
    {"id": "FFD-003", "severity": "medium", "location": "command",
     "title": "neither -y nor -n is set",
     "why": "In a script this blocks forever on \"File exists. Overwrite?\"
             rather than failing.",
     "fix": "Add -y if the clip is disposable, -n if it is not."}
  ],
  "reconciliation": [
    {"flag_id": "FF-001", "status": "confirmed",
     "note": "Copy plus -vf on out0 is the reason this does not start."},
    {"flag_id": "FF-002", "status": "confirmed",
     "note": "Output-side -ss with a copy is exactly the slow path you were
              trying to avoid."}
  ],
  "next_lane": {"lane": "deliver",
                "reason": "Once you decide between copying and scaling, the
                           deliver lane writes the command for where the clip
                           is going."},
  "body": {
    "what_it_does": "It opens lecture.mkv, takes a 90 second window starting at
                     12:30, asks for the video to be scaled to 1280x720, asks
                     for every stream to be copied rather than re-encoded, moves
                     the MP4 index to the front, and writes clip.mp4. The scale
                     and the copy contradict each other, so it stops there.",
    "walkthrough": [
      {"group": "in0", "option": "-i lecture.mkv", "means": "the only input",
       "state": "correct"},
      {"group": "out0", "option": "-ss 00:12:30",
       "means": "an output-side seek: decode from the start and discard until
                 12:30, rather than seeking the input",
       "state": "suspect"},
      {"group": "out0", "option": "-t 90", "means": "stop after 90 seconds of
       output", "state": "correct"},
      {"group": "out0", "option": "-c copy",
       "means": "copy every stream's packets without re-encoding",
       "state": "suspect"},
      {"group": "out0", "option": "-vf scale=1280:720",
       "means": "scale the video - which requires re-encoding, so it conflicts
                 with -c copy",
       "state": "suspect"},
      {"group": "out0", "option": "-movflags +faststart",
       "means": "move the MP4 index to the front so it plays before it finishes
                 downloading",
       "state": "correct"}
    ],
    "streams": [
      {"stream": "video", "from": "in0", "through": "-", "to": "out0",
       "action": "copied - but the -vf makes this impossible as written"},
      {"stream": "audio", "from": "in0", "through": "-", "to": "out0",
       "action": "copied"}
    ],
    "cost_shape": "As written, nothing: it exits before encoding. Resolve the
                   conflict by dropping -vf and the cost is dominated by the
                   output-side seek, which reads 12.5 minutes of file before it
                   writes anything. Move -ss in front of -i and the whole job is
                   a keyframe seek plus 90 seconds of packet copying.",
    "command": "ffmpeg -y -ss 00:12:30 -i lecture.mkv \\\n  -t 90 \\\n  -c copy \\\n  -movflags +faststart \\\n  clip.mp4",
    "command_note": "Your own command with two changes and nothing else: -ss
                     moved in front of -i, and -vf removed so the copy is legal.
                     If you actually need 1280x720, this is a transcode, not a
                     copy - that is the deliver lane.",
    "command_scan": {"checked": true, "reason": "", "flags_remaining": 0,
                     "remaining_rules": [], "cleared": 3, "introduced": []}
  }
}

task: "graph" — Walk the graph (pads, labels and order)

The lane about wiring. Filter order is a first-class concern here: scaling before overlaying is cheaper than after, fps before an expensive filter halves its work, format in the wrong place forces a conversion per frame.

body keyTypeMeaning
graph_reads_asstringOne paragraph: what the graph does, chain by chain, in the order data flows. If the command has no -filter_complex at all, this says so plainly and describes the -vf chain instead — and command then shows how the same work would be written as a -filter_complex, which is the useful answer rather than an empty body.
nodes[]array{id, does, pads_in, pads_out, concern}, plus a client-added id_ungrounded. One entry per filter node in facts.filter_graph.filters, using the same id. Do not invent a node and do not skip one. concern is an empty string when there is nothing wrong with that node — empty is the normal case, not a gap.
uncovered_nodes[]array of string (client-added)The node ids in facts.filter_graph.filters that nodes[] did not mention. Displayed as a hole in the reading. If this is non-empty, the reply did not do the one thing this lane exists for.
wiring_notes[]array{label, note}, plus a client-added label_ungrounded. What each label carries and anything wrong with how it is used — a label consumed twice needs a split, a label defined twice is ambiguous, a label consumed but never defined stops ffmpeg before the first frame.
simplifications[]array{change, why}. The concrete edit, and what it buys: fewer decodes, one less scale, clearer order. Empty when the graph is already the shape it should be.
command, command_notestringThe rewired graph as a runnable command, with the filtergraph quoted.

task: "subs" — Sort the subtitles (will the text actually appear)

The two paths are genuinely different and the contract does not let them be blurred. Burn-in is the subtitles= or ass= filter: it re-encodes the video, the text cannot be turned off, and every player shows it. Soft mux is -c:s: the text stays selectable, costs nothing to encode, and depends entirely on the container — MP4 takes mov_text and nothing else, Matroska takes srt and ass, WebM takes webvtt.

body keyTypeMeaning
path_callstringOne of burn-in, soft-mux, both, none-present; anything else normalizes to none-present. none-present is a real answer, not a failure: when there is no subtitle work in the command the lane says so, then uses command to show the smallest correct way to add it for this output's container, with the reasoning in steps.
steps[]array{step, detail}. What happens, and the option or filter that makes it happen. Escaping belongs here: the filtergraph is parsed twice, so a colon, a space or an apostrophe in a subtitles= path has to survive both passes.
compat[]array{player, result, why}. result is one of shows, no subtitles, fails to play; anything else normalizes to shows. why names the specific reason — the codec, the container, the disposition flag — not a general remark about support.
styling[]array{aspect, setting, note} where aspect is font, size, position, colour, timing or encoding: what it is now, and what to change.
command, command_notestringThe subtitle path made to work, quoted so it survives the shell and both filtergraph passes.

task: "deliver" — Rewrite it for delivery (the command you ship)

The lane that produces the command the user will actually run. It is driven by target: see the eight values for what each one asks for.

body keyTypeMeaning
target_callstringWhich target was encoded for and what that decides. When target is general this says what was inferred from the command and the note instead.
changes[]array{from, to, why, impact}. A diff, not a rewrite log: one entry per meaningful difference between the user's command and the returned one, with from quoting what they had, or the literal "(absent)" when something is being added. impact is one of quality, size, speed, compatibility; anything else normalizes to quality.
ladder[]array{name, settings, note}. The adaptive rungs, for target: "hls" and adaptive delivery generally. For every other target this is an empty array rather than invented rungs.
tradeoffs[]array{choice, cost, benefit}. What was chosen, what it costs, what it buys. This is where "CRF 21 at preset slow" gets an honest price in encode time.
verify[]array{check, expected}. Runnable or observable: an ffprobe line, a thing to look for in the output, a player to try. Never "check it looks right".
command, command_notestringThe command to ship. This is the one lane where the returned command is expected to differ substantially from the input, which is exactly why command_scan matters most here.

Step 1 — a tiny client

Everything below uses this one helper. It does the single thing that matters: it reads the envelope and raises on ok: false, because an error response is still HTTP-shaped JSON and ignoring it turns a 402 into a confusing null three lines later. Tokens come from the token page — it shows the token this browser already holds, reveals it, copies it, and can mint a fresh guest one — or from POST /guest in step 2.

Step 2 — get a token

The friendly route is the token page: it shows the token this browser already holds, reveals it, copies it, and can mint a fresh guest one without you writing any code. Programmatically, POST /guest is the whole story, and it is the one call whose body is not a lane input: it takes {"slug": "ffmpeg-desk"}, and that slug is what binds the returned token to this app. Omit it and you get a 400 invalid_request; misspell it and you get a 404 not_found. Everything after this reads the app identity off the token, which is why no later call carries a slug and there is no X-App-Slug header anywhere.

What a guest token can do: GET /me, POST /estimate, and GET /jobs/{id} for jobs the guest itself created. That is enough to validate an input shape, learn which model the app is bound to and price a lane, all without an account. What it cannot do: run a metered lane. POST /run and POST /run-stream need a personal token — a guest wallet has no credits, so the run fails the balance check rather than the auth check, which is why a guest attempt usually surfaces as 402 payment_required and not 401. The exception is a sponsored app: /estimate returns sponsor_enabled, and when that is true the app's owner is paying and a guest can run. The web app checks exactly that before it decides whether to ask you to sign in. A guest token expires roughly a month out, at data.expires_at.

Step 3 — who am I, and can I afford this

GET /me returns the subject and the balance. Comparing it against /estimate before you submit is what turns a 402 from an error your user sees into a button you disabled. It is also how you tell a guest token from a personal one: subject_type reads guest on the first and user on the second, and only the second can run a metered lane on an unsponsored app. credits is the spendable balance in the same unit hold_credits and min_credits are quoted in, so the comparison is a plain integer comparison and nothing needs converting.

Step 4 — estimate, free

/estimate creates no job and charges nothing. It is also the authoritative check that your input shape is valid and that the app is bound to the model you think it is: the reply carries model, model_alias and markup_bps alongside hold_credits, min_credits, sponsor_enabled and byok. Estimate per lane and per input: the four lanes have different prompts and different output caps, so one lane's hold is not another lane's price, and a deliver reply carrying a ladder and a rewritten filtergraph is not priced like a read reply.

FieldWhat it means
hold_creditsA reservation, not the price. It is the worst case that gets held when the run starts, computed against the full output cap, and almost no run reaches it — the real cost comes back as charged_credits on the finished job and is usually far below. Show it to a user as RESERVED. Showing it as the price is the most common way to make a cheap app look expensive.
min_creditsThe floor below which the run will not start at all. Below this you get 402 payment_required. Between min_credits and hold_credits the run executes with a reduced cap and comes back truncated: true.
modelThe exact model id the run will be executed on. This is what "bound to" means: it is not a preference, it is what you will be charged for.
model_aliasThe friendlier name for the same thing, suitable for showing a user.
markup_bpsThe app's markup over raw model cost, in basis points. 0 means none.
sponsor_enabledTrue when the app owner is paying, which is the one case where a guest subject can run a metered lane. The web app calls /estimate specifically to read this before deciding whether to prompt for sign-in.
byokTrue when the run will use your own provider key rather than metered credits.

Step 5 — run and poll

Submit, then poll the job until it is terminal. data.output.output is a string holding the JSON object — parse it a second time. charged_credits is the real cost and is usually far below the hold, which priced the full output cap. truncated comes back on the same object; see the error section for what it obliges you to tell your user.

The Idempotency-Key is not optional in practice. The app derives it from the lane, the command text, the target, the note and the attempt number, as ffmpeg-desk:<lane>:<hash>:a<attempt>, so four lanes over one command are four runs that cannot collide on one key, changing the target is a genuinely new run, and a retried POST of the same lane is free rather than a second charge. Any deterministic derivation works; a counter does not, because reusing one key with a different body is a 409.

Step 6 — run-stream, for progress

The same run, reported as it happens, as server-sent events. This is what the web app uses, and the reason its progress card can name a real stage rather than spin: the appearance of "findings", then "body", then the lane's own keys in the accumulated delta text is a real signal about where the model is. Accumulate the deltas and parse once at the end, because a partial JSON document is not parseable.

LaneThe five markers the app watches, in order
read"findings", "body", "walkthrough", "streams", "command"
graph"findings", "body", "nodes", "wiring_notes", "command"
subs"findings", "body", "steps", "compat", "command"
deliver"findings", "body", "changes", "tradeoffs", "verify"

The events worth handling are job (accepted, the run is now billed against the hold), delta (a chunk of the JSON document, in order, as {"text": "..."}), done (terminal, with charged_credits and truncated) and error (terminal failure). A stream that dies mid-document leaves you holding unparseable text; that is exactly the case retry_note exists for, and the app retries once with the reformat instruction before it gives up and shows the raw text. Throttle whatever you paint from the deltas — the app repaints at most every 250 ms — because the chunks are small and frequent and a repaint per chunk is the easiest way to make a fast run feel slow.

Idempotency keys and rate limits

An idempotency key is what makes a retry free instead of a second charge, and it is the reason a 5xx or a dropped connection is recoverable rather than expensive. The app builds one for every run and you should too.

How the app derives it. It hashes the input, not a counter: JSON.stringify([task, command, target, context_note]) through a short non-cryptographic hash, then formats the key as ffmpeg-desk:<lane>:<hash>:a<attempt> — the slug, the lane, the hash of the input, and the attempt number. Four consequences fall straight out of that shape, and they are the properties you want:

Any deterministic derivation works. A counter does not: reusing one key with a different body is a 409, and generating a fresh key per attempt turns your retry into a duplicate charge. If you cannot hash the body cheaply, hash the command text and the lane at minimum — those are the two fields that change the answer most.

Rate limits. A 429 rate_limited means back off, wait, and retry with the same idempotency key. Never tight-loop. The polling loop in step 5 is the usual offender: GET /jobs/{id} is free, but free is not unlimited, and a loop with no sleep will earn a 429 well before the job finishes. Two seconds between polls is what the app uses and is plenty; exponential backoff from one second is better still if you are polling many jobs. If you are running lanes in bulk, prefer /run-stream over a poll loop — one connection per run beats one request every two seconds per run — and serialise rather than fanning out, because a burst of concurrent submissions is the other reliable way to find the limit.

The honesty rules a caller should know about

These are not style guidance. They are enforced by the prompt, checked by the client, and they are the reason the output is worth consuming programmatically at all. If you build your own renderer, reproduce them.

What this API will not do