Developers

Call Lull from a terminal, or play its files on your page.

The same pipeline as the studio, with the same checks: a REST API that streams each run as it happens, the command line it runs on, and an accessible player that takes the files it writes.

Live API https://lull-api-494617981995.us-central1.run.app

Quick start

  1. Point your shell at the API

    Every call below uses this address.

    export LULL_API="https://lull-api-494617981995.us-central1.run.app"
    Set the API address
  2. Check it can take a run

    ok and speech true, live_busy false, and the day’s spend under its cap.

    curl -s "$LULL_API/v1/health"
    Health
    What comes back
    {
      "ok": true,
      "speech": true,
      "budget": {
        "spent_today": 0.0332, "cap_day": 3.0, "spent_total": 3.27, "cap_total": 35.0, "cap_run": 0.1,
        "searches_today": 1, "searches_cap_day": 60, "searches_cap_run": 3
      },
      "live_busy": false,
      "retention_days": 7.0,
      "fork_retention_hours": 24.0
    }
    Health answer
  3. See what it can describe

    Sample clips of up to 45 s, each with its title, length, credit and licence. Or send your own file instead, up to 60 s and 30 MB; longer clips are refused (too_long).

    curl -s "$LULL_API/v1/samples"
    Samples
    What comes back
    [
      {
        "id": "ed45", "file": "ed45.mp4", "title": "Elephants Dream (excerpt)",
        "credit": "Orange Open Movie Team / Blender Foundation, elephantsdream.org",
        "licence": "CC BY 2.5", "licence_url": "https://creativecommons.org/licenses/by/2.5/",
        "source_url": "https://commons.wikimedia.org/wiki/File:Elephants_Dream_(2006).webm",
        "excerpt": "1:00-1:44.9",
        "changes": "cut to 44.9 s and re-encoded at 854x480 (H.264 CRF 26, AAC 96 kb/s)",
        "duration": 44.917
      },
      { "id": "tos45", "file": "tos45.mp4", "title": "Tears of Steel (excerpt)", "licence": "CC BY 3.0", … }
    ]
    Samples answer
  4. Describe a sample, and watch it happen

    -N keeps curl from buffering: each stage, model call and line arrives as it happens. The first event has the run id and its edit token; keep both. X-Lull-Client: web is required on this and on a fork: a custom header makes browsers ask first, so other sites can't start runs from a visitor's browser.

    curl -N "$LULL_API/v1/runs" -H "X-Lull-Client: web" -F sample=ed45 -F mode=standard -F voice=Sofia
    
    # or your own clip
    curl -N "$LULL_API/v1/runs" -H "X-Lull-Client: web" -F file=@lecture.mp4 -F mode=standard
    Start a run
    What comes back
    id: 0
    event: run
    data: {"type":"run","stage":null,"ts":1761642902.1,"seq":0,"run_id":"live-…","duration":42.0,"options":{"mode":"standard","voice":"Sofia"},"edit_token":"…"}
    
    id: 3
    event: stage_end
    data: {"type":"stage_end","stage":"listen","seq":3,"seconds":6.4,"cached":false,"words":187,"gaps":5,"windows":4,"capacity_words":140}
    
    id: 9
    event: call
    data: {"type":"call","stage":"watch","seq":9,"role":"watch","model":"nvidia/Nemotron-3.5-Super-VL","tokens_in":18420,"tokens_out":812,"cost_usd":0.0063,"latency_s":5.42}
    
    id: 14
    event: call
    data: {"type":"call","stage":"research","seq":14,"role":"research","model":"tavily-search","provider":"tavily","latency_s":2.6}
    
    id: 21
    event: cue
    data: {"type":"cue","stage":"finalize","seq":21,"id":"c001","status":"verified","text":"…","start":8.32,"audio_dur":3.1,"note":null}
    
    id: 30
    event: done
    data: {"type":"done","seq":30,"run_id":"live-…","seconds":182.4,"summary":{"cues":{…},"coverage":{…},"wcag":[…],"cost_usd":0.03323,"spend":{"model_usd":0.03323,"model_usd_all_runs":0.03323,"searches":1},…}}
    Start a run answer
  5. Fetch what it published

    The run's manifest and cues, then any of its files.

    RUN=live-…   # the run_id from the first event
    curl -s "$LULL_API/v1/runs/$RUN"
    curl -sO "$LULL_API/v1/runs/$RUN/files/descriptions.vtt"
    curl -sO "$LULL_API/v1/runs/$RUN/files/described-audio.m4a"
    Fetch the outputs

Endpoints

JSON in and out, except the run streams (text/event-stream) and the files. A refusal is JSON with the API’s own code and plain words, plus the code’s extras (cue_ids, findings, scope); some 409, 429 and 503 refusals send Retry-After in seconds.

{
  "error": "rate_limited",
  "message": "You have started 3 live descriptions in the last hour. Try again in 52 minutes, or open a recorded run."
}
Shape of a refusal

Live runs

GET/v1/health

Whether the live service can take a run: the speech server, a run in progress, the model spend so far against its caps, the web searches made today, and how long runs are kept.

Auth
None.

200 application/json. Cached for 10 s, except live_busy. Spend is model calls, in USD; a cap of null is none, a cap of 0 refuses every call. Web searches are counted, never priced. Uploads are kept retention_days, copies made in the studio fork_retention_hours.

{
  "ok": true,
  "speech": true,
  "budget": {
    "spent_today": 0.0332, "cap_day": 3.0, "spent_total": 3.27, "cap_total": 35.0, "cap_run": 0.1,
    "searches_today": 1, "searches_cap_day": 60, "searches_cap_run": 3
  },
  "live_busy": false,
  "retention_days": 7.0,
  "fork_retention_hours": 24.0
}
Shape of the GET /v1/health answer
curl -s "$LULL_API/v1/health"
curl: GET /v1/health

GET/v1/samples

The sample clips the live service can describe: each up to 45 s, with its title, length, credit and licence.

Auth
None.

200 application/json. Each sample (its samples.json entry): the id to pass as sample to POST /v1/runs, its title, credit, licence and the licence's address, where it was cut from, what was changed, and its length in seconds. An API without samples.json lists ids alone.

[
  {
    "id": "ed45", "file": "ed45.mp4", "title": "Elephants Dream (excerpt)",
    "credit": "Orange Open Movie Team / Blender Foundation, elephantsdream.org",
    "licence": "CC BY 2.5", "licence_url": "https://creativecommons.org/licenses/by/2.5/",
    "source_url": "https://commons.wikimedia.org/wiki/File:Elephants_Dream_(2006).webm",
    "excerpt": "1:00-1:44.9",
    "changes": "cut to 44.9 s and re-encoded at 854x480 (H.264 CRF 26, AAC 96 kb/s)",
    "duration": 44.917
  },
  { "id": "tos45", "file": "tos45.mp4", "title": "Tears of Steel (excerpt)", "licence": "CC BY 3.0", … }
]
Shape of the GET /v1/samples answer
curl -s "$LULL_API/v1/samples"
curl: GET /v1/samples

POST/v1/runs

Describe a clip, live: send a file or a sample id and read the run as it happens, as Server-Sent Events.

Auth
None.
Limits
One run at a time. 3 runs an hour per visitor and 20 a day across visitors; a run is stopped after 10 minutes. The clip goes to Nebius Token Factory (NVIDIA Nemotron models) and to NVIDIA speech models on the service's server; anyone with a run's id can open it. Uploads are deleted after 7 days, forks after 24 hours.
Body, multipart/form-data
Name and typeWhat
file optionalfileThe video: mp4, mov, webm or mkv, up to 60 s and 30 MB; longer clips are refused (too_long).
sample optionalstringA sample id instead of a file (samples run up to 45 s).
mode optionalstandard | extendedextended also writes long versions for the extended track. Default standard.
voice optionalSofiaThe voice: Sofia, the one built-in voice (the default).

200 text/event-stream. First a run event with the run id and its edit token (sent only here, keep it: the studio needs it), then each stage, model call, cue and warning as it happens, then done or error. A “: ping” comment comes every 15 s of quiet.

id: 0
event: run
data: {"type":"run","stage":null,"ts":1761642902.1,"seq":0,"run_id":"live-…","duration":42.0,"options":{"mode":"standard","voice":"Sofia"},"edit_token":"…"}

id: 3
event: stage_end
data: {"type":"stage_end","stage":"listen","seq":3,"seconds":6.4,"cached":false,"words":187,"gaps":5,"windows":4,"capacity_words":140}

id: 9
event: call
data: {"type":"call","stage":"watch","seq":9,"role":"watch","model":"nvidia/Nemotron-3.5-Super-VL","tokens_in":18420,"tokens_out":812,"cost_usd":0.0063,"latency_s":5.42}

id: 14
event: call
data: {"type":"call","stage":"research","seq":14,"role":"research","model":"tavily-search","provider":"tavily","latency_s":2.6}

id: 21
event: cue
data: {"type":"cue","stage":"finalize","seq":21,"id":"c001","status":"verified","text":"…","start":8.32,"audio_dur":3.1,"note":null}

id: 30
event: done
data: {"type":"done","seq":30,"run_id":"live-…","seconds":182.4,"summary":{"cues":{…},"coverage":{…},"wcag":[…],"cost_usd":0.03323,"spend":{"model_usd":0.03323,"model_usd_all_runs":0.03323,"searches":1},…}}
Shape of the POST /v1/runs answer
Refusals
StatuserrorWhen
400client_headerNo X-Lull-Client: web header (it makes browsers ask first, so other sites can't start work from a visitor's browser).
400invalidThe body couldn't be read as a form.
400disconnectedThe upload was cut off before it finished.
402budgetA spend cap is reached (scope: run, day, total, or the provider's balance).
408timeoutThe whole upload didn't arrive within 60 s.
409busyAnother description is running: one at a time. Sends Retry-After.
413too_largeThe file is over 30 MB.
422formatNot an mp4, mov, webm or mkv.
422unsupportedMore work than a live run takes, said in the message: over 1920x1080 or 60 fps, more than one picture or two sound tracks, a subtitle or data track, over 100 Mb/s; or a copy that can't be made.
422too_longOver 60 s (a sample: 45 s).
422unreadableThe file can't be read as a video.
422invalidNeither or both of file and sample; an unknown mode or voice.
422sampleNo sample with that id.
429rate_limited3 runs an hour from you, 20 a day in all, or 10 of your uploads refused in an hour. Sends Retry-After.
429one_upload_at_a_timeYour previous upload is still arriving. Sends Retry-After.
503uploads_busyTwo uploads are already being received. Sends Retry-After.
503storageThe upload couldn't be stored just now (the service's disk, not your file). Sends Retry-After.
curl -N "$LULL_API/v1/runs" -H "X-Lull-Client: web" -F sample=ed45 -F mode=standard -F voice=Sofia
curl: POST /v1/runs

Reading runs

GET/v1/runs

The recorded runs, with where each stands. Visitors' runs (samples, uploads and copies) are never listed: each opens only by its id.

Auth
None.

200 application/json. status: running (being described now), done (published), failed, or stopped (unfinished; its owner may resume it). Spend is model calls in USD and web searches as a count. Cached for 5 s.

[
  {
    "id": "live-…", "kind": "live", "title": "ed45.mp4", "duration": 45.0,
    "created": "2026-10-28T09:16:42+00:00", "status": "done",
    "cost_usd": 0.03323, "spend": { "model_usd": 0.03323, "model_usd_all_runs": 0.03323, "searches": 1 },
    "cues": { "verified": 5, "edited": 0, "approved": 0, "held": 0, "extended": 1 }
  }
]
Shape of the GET /v1/runs answer
curl -s "$LULL_API/v1/runs"
curl: GET /v1/runs

GET/v1/runs/{id}

Any run, by its id: where it stands and where it came from, its timeline, soundtrack levels and original video, and once it is done its manifest (files, summary, WCAG statuses, versions) and every cue with its checks.

Auth
None for a done run; for the rest of a run that isn't done, Authorization: Bearer <its edit token>.
Parameters
Name and typeWhat
idpathThe run id (letters, digits, . _ -; at most 80).

200 application/json. status as in the list. A run that isn't done shows anyone but its owner only { id, status, source: { kind }, created }; send its edit token as a Bearer for the rest. source: { kind: sample, sample_id }, { kind: upload }, or { kind: fork, forked_from, origin, sample_id? }. speech and gaps (usable windows, capacity in words) come from its cues; loudness is dBFS per loudness_hop_s, null when none was kept; original is null until it is done. dirty: changed in the studio since it was last published. Local paths never appear: a cue's take is an audio_url.

{
  "id": "live-20261028-de7ca0a30ae1f82a26beeccb",
  "kind": "live",
  "status": "done",
  "source": { "kind": "sample", "sample_id": "ed45" },
  "title": "ed45.mp4",
  "duration": 44.917,
  "created": "2026-10-28T09:16:42+00:00",
  "error": null,
  "dirty": false,
  "wps": 3.05,
  "speech": [[0.24, 1.68], [2.08, 4.8], [5.36, 9.09], …],
  "gaps": [{ "id": "g001", "start": 9.29, "end": 15.64, "capacity": 19 }, …],
  "loudness": [-29.4, -29.9, -30.4, -35.0, …],
  "loudness_hop_s": 0.1,
  "original": "/v1/runs/live-20261028-de7ca0a30ae1f82a26beeccb/original",
  "manifest": {
    "run_id": "live-20261028-de7ca0a30ae1f82a26beeccb",
    "created": "2026-10-28T09:16:42+00:00",
    "video": { "name": "ed45.mp4", "duration": 44.917, "sha256": "…" },
    "options": { "mode": "extended", "profile": "education", "voice": "Sofia", "language": "en" },
    "outputs": { "descriptions.vtt": { "sha256": "…", "bytes": 612 }, "described-audio.m4a": { … }, … },
    "summary": {
      "cues": { "verified": 5, "edited": 0, "approved": 0, "held": 0, "extended": 1 },
      "coverage": { "important_events": 6, "described": 5, "ratio": 0.833, … },
      "overlap_seconds": 0.0,
      "held": [],
      "wcag": [{ "criterion": "1.2.5", "level": "AA", "status": "met (automated checks)", "via": "…", "reasons": [] }],
      "cost_usd": 0.03323,
      "spend": { "model_usd": 0.03323, "model_usd_all_runs": 0.03323, "searches": 1 },
      "repetitive": [],
      …
    },
    "versions": { "engine": "…", "models": ["nvidia/Nemotron-3-Ultra-550b-a55b", …], "tts": "Magpie TTS Multilingual" }
  },
  "cues": { "run_id": "…", "language": "en", "cues": [ <cue>, … ], "extended": [ <cue>, … ] }
}
Shape of the GET /v1/runs/{id} answer
Refusals
StatuserrorWhen
404not_foundNo run with that id (never made, or removed: uploads after 7 days, copies after 24 hours).
curl -s "$LULL_API/v1/runs/$RUN"
curl: GET /v1/runs/{id}

GET/v1/runs/{id}/original

The video a done run described, with byte ranges. HEAD works too.

Auth
None.
Parameters
Name and typeWhat
idpathThe run id (letters, digits, . _ -; at most 80).

200 video/mp4. 206 for a range. The detail's original gives this address once the run is done.

(video)
Shape of the GET /v1/runs/{id}/original answer
Refusals
StatuserrorWhen
404not_foundNo run with that id, or it isn't done yet.
curl -s -r 0-1023 -o head.mp4 "$LULL_API/v1/runs/$RUN/original"
curl: GET /v1/runs/{id}/original

GET/v1/runs/{id}/events

A run's events: the replay of a published run (open to anyone), or, for its owner only, the live stream of a run that is going and the resume of one that stopped.

Auth
None to read; to resume a run that stopped before it finished, Authorization: Bearer <its edit token>.
Parameters
Name and typeWhat
idpathThe run id (letters, digits, . _ -; at most 80).
after optionalintegerOnly events after this seq (also read from Last-Event-ID).

200 text/event-stream. The same events POST /v1/runs streamed. A stream whose page leaves stops the run: its last event is stopped, and its owner resumes it with the same GET and the edit token (a 409 busy may come for a few seconds while the stopped run winds down). 204 when nothing is left after `after`.

id: 31
event: done
data: {"type":"done","seq":31,…}

id: 12
event: stopped
data: {"type":"stopped","stage":"watch","seq":12,"message":"The description stopped when the page following it closed. Open it again to carry on: the steps already done are kept."}
Shape of the GET /v1/runs/{id}/events answer
Refusals
StatuserrorWhen
403forbiddenFollowing or resuming a run that isn't published, or replaying a failed one, needs its edit token as a Bearer.
429busyThree streams already follow this run, or eight are open in all. Sends Retry-After.
404not_foundNo run with that id, or no progress was recorded for it.
409busyResuming, while another run is going. Sends Retry-After.
402budgetResuming, past a spend cap.
curl -N "$LULL_API/v1/runs/$RUN/events?after=10"
curl: GET /v1/runs/{id}/events

GET/v1/runs/{id}/files/{path}

One of the run's files: any output its manifest lists, or keyframes/<cue>.jpg. HEAD works too.

Auth
None.
Parameters
Name and typeWhat
idpathThe run id (letters, digits, . _ -; at most 80).
pathpathdescriptions.vtt, extended.vtt, described-audio.m4a, described.mp4, extended.mp4, script.md, report.html, cues.json, manifest.json, keyframes/c001.jpg

200 the file's own type. report.html is served with a Content-Security-Policy that allows no scripts.

WEBVTT
Kind: descriptions
Language: en

c001
00:00:08.320 --> 00:00:11.420
In a dim corridor of cables, …
Shape of the GET /v1/runs/{id}/files/{path} answer
Refusals
StatuserrorWhen
404not_foundNot one of the run's outputs or keyframes.
curl -sO "$LULL_API/v1/runs/$RUN/files/descriptions.vtt"
curl: GET /v1/runs/{id}/files/{path}

GET/v1/runs/{id}/cues/{cue_id}/audio

One cue's own recording (WAV): the take the mix used, or an edit's new take. HEAD works too.

Auth
None.
Parameters
Name and typeWhat
idpathThe run id (letters, digits, . _ -; at most 80).
cue_idpathThe cue id from the run's cues (c002, x001…).

200 audio/wav. Each cue's audio_url carries ?v=<tag>, which changes with each new take.

(audio)
Shape of the GET /v1/runs/{id}/cues/{cue_id}/audio answer
Refusals
StatuserrorWhen
404not_foundNo such cue, or it has no take (a held line).
curl -sO "$LULL_API/v1/runs/$RUN/cues/c001/audio"
curl: GET /v1/runs/{id}/cues/{cue_id}/audio

Studio

PATCH/v1/runs/{id}/cues/{cue_id}

Change a line's text. It is linted first; then the line is checked against its frames, fitted and voiced again.

Auth
Authorization: Bearer <the run’s edit token> (from its run event, or from a fork).
Limits
30 changes a minute per visitor, 2,000 a day in all; one change to a run at a time.
Parameters
Name and typeWhat
idpathThe run id (letters, digits, . _ -; at most 80).
cue_idpathThe cue id from the run's cues (c002, x001…).
Body, application/json
Name and typeWhat
textstringThe new line, 1 to 1,000 characters.

200 application/json. The cue as stored: edited, or held with the reason in its note.

{
  "id": "c002",
  "gap_id": "g002",
  "start": 18.349,
  "end": null,
  "text": "The two people are surrounded by wires.",
  "event_ids": ["e004"],
  "status": "edited",
  "ahead": false,
  "claims": [{ "text": "two people", "verdict": "supported", "at": 18.95 }],
  "audio_url": "/v1/runs/{id}/cues/c002/audio?v=3f9a1c07d2",
  "audio_dur": 2.322,
  "tempo": 1.0,
  "findings": [{ "rule": "R14-gender", "message": "gendered word; confirm the programme establishes it", "span": [4, 7], "severity": "warning" }],
  "note": null,
  "extended": false
}
Shape of the PATCH /v1/runs/{id}/cues/{cue_id} answer
Refusals
StatuserrorWhen
403read_onlyThe run is a recorded run: make a copy (fork) to edit.
403forbiddenNo Authorization: Bearer with this run's edit token.
404not_foundNo run, or no cue, with that id.
409busyThe run is still being described, or another change to it has held it for more than 2 s. Sends Retry-After.
409not_readyThe run isn't done (published) yet.
409not_editableThe run's folder lacks what re-checking needs (its source video or stage records).
413too_largeThe body is over 64 KB.
422invalidNo text, or over 1,000 characters.
422lintThe text breaks a writing rule: the answer has the findings and the text as checked; nothing else ran.
402budgetA spend cap is reached (an edit reserves about $0.01).
429rate_limitedMore than 30 changes a minute from you, 200 a day from you (50 publishes), or 2,000 a day in all. A day's limit says “today” in its message. Sends Retry-After.
502engineThe check itself failed; the line is left as it was.
503overloadedTwo edits are already being checked. Sends Retry-After.
curl -s -X PATCH "$LULL_API/v1/runs/$RUN/cues/c002" \
  -H "Authorization: Bearer $EDIT_TOKEN" -H "Content-Type: application/json" \
  -d '{"text":"Two people stand among hanging wires."}'
curl: PATCH /v1/runs/{id}/cues/{cue_id}

POST/v1/runs/{id}/cues/{cue_id}/approve

Sign a line off: a verified, edited or voiced extended line becomes approved.

Auth
Authorization: Bearer <the run’s edit token> (from its run event, or from a fork).
Parameters
Name and typeWhat
idpathThe run id (letters, digits, . _ -; at most 80).
cue_idpathThe cue id from the run's cues (c002, x001…).

200 application/json. The cue, approved (approving it again changes nothing).

{ "id": "c002", "status": "approved", … }
Shape of the POST /v1/runs/{id}/cues/{cue_id}/approve answer
Refusals
StatuserrorWhen
403read_onlyThe run is a recorded run: make a copy (fork) to edit.
403forbiddenNo Authorization: Bearer with this run's edit token.
404not_foundNo run, or no cue, with that id.
409busyThe run is still being described, or another change to it has held it for more than 2 s. Sends Retry-After.
409not_readyThe run isn't done (published) yet.
409invalid_transitionA held line, or one without a take: edit it first. The answer says its cue_status.
429rate_limitedMore than 30 changes a minute from you, 200 a day from you (50 publishes), or 2,000 a day in all. A day's limit says “today” in its message. Sends Retry-After.
curl -s -X POST "$LULL_API/v1/runs/$RUN/cues/c002/approve" -H "Authorization: Bearer $EDIT_TOKEN"
curl: POST /v1/runs/{id}/cues/{cue_id}/approve

POST/v1/runs/{id}/cues/{cue_id}/hold

Hold a line back for review, with a reason. A held line isn't voiced or mixed.

Auth
Authorization: Bearer <the run’s edit token> (from its run event, or from a fork).
Parameters
Name and typeWhat
idpathThe run id (letters, digits, . _ -; at most 80).
cue_idpathThe cue id from the run's cues (c002, x001…).
Body, application/json
Name and typeWhat
reason optionalstringWhy, up to 300 characters; the note becomes “held by a reviewer: <reason>”.

200 application/json. The cue, held.

{ "id": "c002", "status": "held", "note": "held by a reviewer: the name is wrong", … }
Shape of the POST /v1/runs/{id}/cues/{cue_id}/hold answer
Refusals
StatuserrorWhen
403read_onlyThe run is a recorded run: make a copy (fork) to edit.
403forbiddenNo Authorization: Bearer with this run's edit token.
404not_foundNo run, or no cue, with that id.
409busyThe run is still being described, or another change to it has held it for more than 2 s. Sends Retry-After.
409not_readyThe run isn't done (published) yet.
422invalidThe reason isn't text, or is over 300 characters.
429rate_limitedMore than 30 changes a minute from you, 200 a day from you (50 publishes), or 2,000 a day in all. A day's limit says “today” in its message. Sends Retry-After.
curl -s -X POST "$LULL_API/v1/runs/$RUN/cues/c002/hold" \
  -H "Authorization: Bearer $EDIT_TOKEN" -H "Content-Type: application/json" -d '{"reason":"the name is wrong"}'
curl: POST /v1/runs/{id}/cues/{cue_id}/hold

POST/v1/runs/{id}/publish

Publish the lines as they are now: the overlap gate, then every file written again. Nothing is written when the gate refuses.

Auth
Authorization: Bearer <the run’s edit token> (from its run event, or from a fork).
Parameters
Name and typeWhat
idpathThe run id (letters, digits, . _ -; at most 80).

200 application/json. The run as GET /v1/runs/{id} gives it, dirty false. Publishing an unchanged run does nothing.

{
  "id": "live-20261028-de7ca0a30ae1f82a26beeccb",
  "kind": "live",
  "status": "done",
  "source": { "kind": "sample", "sample_id": "ed45" },
  "title": "ed45.mp4",
  "duration": 44.917,
  "created": "2026-10-28T09:16:42+00:00",
  "error": null,
  "dirty": false,
  "wps": 3.05,
  "speech": [[0.24, 1.68], [2.08, 4.8], [5.36, 9.09], …],
  "gaps": [{ "id": "g001", "start": 9.29, "end": 15.64, "capacity": 19 }, …],
  "loudness": [-29.4, -29.9, -30.4, -35.0, …],
  "loudness_hop_s": 0.1,
  "original": "/v1/runs/live-20261028-de7ca0a30ae1f82a26beeccb/original",
  "manifest": {
    "run_id": "live-20261028-de7ca0a30ae1f82a26beeccb",
    "created": "2026-10-28T09:16:42+00:00",
    "video": { "name": "ed45.mp4", "duration": 44.917, "sha256": "…" },
    "options": { "mode": "extended", "profile": "education", "voice": "Sofia", "language": "en" },
    "outputs": { "descriptions.vtt": { "sha256": "…", "bytes": 612 }, "described-audio.m4a": { … }, … },
    "summary": {
      "cues": { "verified": 5, "edited": 0, "approved": 0, "held": 0, "extended": 1 },
      "coverage": { "important_events": 6, "described": 5, "ratio": 0.833, … },
      "overlap_seconds": 0.0,
      "held": [],
      "wcag": [{ "criterion": "1.2.5", "level": "AA", "status": "met (automated checks)", "via": "…", "reasons": [] }],
      "cost_usd": 0.03323,
      "spend": { "model_usd": 0.03323, "model_usd_all_runs": 0.03323, "searches": 1 },
      "repetitive": [],
      …
    },
    "versions": { "engine": "…", "models": ["nvidia/Nemotron-3-Ultra-550b-a55b", …], "tts": "Magpie TTS Multilingual" }
  },
  "cues": { "run_id": "…", "language": "en", "cues": [ <cue>, … ], "extended": [ <cue>, … ] }
}
Shape of the POST /v1/runs/{id}/publish answer
Refusals
StatuserrorWhen
403read_onlyThe run is a recorded run: make a copy (fork) to edit.
403forbiddenNo Authorization: Bearer with this run's edit token.
404not_foundNo run, or no cue, with that id.
409busyThe run is still being described, or another change to it has held it for more than 2 s. Sends Retry-After.
409not_readyThe run isn't done (published) yet.
409not_editableThe run's folder lacks what re-checking needs (its source video or stage records).
409overlapA line would overlap speech, the edge of its pause or another line: cue_ids names them.
409not_voicedA line that would be voiced has lost its take: cue_ids names them.
429rate_limited4 publishes a minute from you, 500 a day in all. Sends Retry-After.
503overloadedAnother run is being published. Sends Retry-After.
500publish_failedWriting the files failed; the last published version stays.
curl -s -X POST "$LULL_API/v1/runs/$RUN/publish" -H "Authorization: Bearer $EDIT_TOKEN"
curl: POST /v1/runs/{id}/publish

POST/v1/runs/{id}/fork

Copy a published run (a recorded one too) into a new run you can edit. No model is called. Copies are removed a day after they are made.

Auth
None.
Parameters
Name and typeWhat
idpathThe run id (letters, digits, . _ -; at most 80).

201 application/json. The copy's id and its edit token (keep it: it is the only way to change the copy).

{ "run_id": "live-…", "edit_token": "…" }
Shape of the POST /v1/runs/{id}/fork answer
Refusals
StatuserrorWhen
400client_headerNo X-Lull-Client: web header (it makes browsers ask first, so other sites can't start work from a visitor's browser).
404not_foundNo run, or no cue, with that id.
409busyThe run is still being described, or another change to it has held it for more than 2 s. Sends Retry-After.
409not_readyThe run isn't done (published) yet.
429rate_limited5 copies an hour or 10 a day from you, 100 a day in all. A day's limit says “today” in its message. Sends Retry-After.
500fork_failedThe copy couldn't be made.
curl -s -X POST "$LULL_API/v1/runs/ed90/fork" -H "X-Lull-Client: web"
curl: POST /v1/runs/{id}/fork

Agents (MCP)

POST/mcp

The hosted MCP server: streamable HTTP, stateless. Tools list_samples, describe_video(sample_id, mode), get_tracks(run_id) and get_report(run_id). It takes sample ids only, never a URL, a path or an upload, so it never fetches what it is handed.

Auth
None.
Body, application/json
Name and typeWhat
(JSON-RPC)objectAn MCP request: initialize, tools/list, tools/call. Accept application/json and text/event-stream.

200 application/json or text/event-stream. describe_video blocks while the run goes (about six minutes for a 45 s sample on the hosted service) and sends a progress notification at every stage: reset your timeout on progress, or allow at least 10 minutes. A refusal comes back as a tool error that starts with the API's code (busy:, rate_limited:, budget:, sample:, unsupported:).

{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [
  { "name": "list_samples", … }, { "name": "describe_video", "inputSchema": { "properties": { "sample_id": …, "mode": { "enum": ["standard", "extended"] } } }, … },
  { "name": "get_tracks", … }, { "name": "get_report", … } ] } }
Shape of the POST /mcp answer
Refusals
StatuserrorWhen
403originA browser page whose Origin isn't one of the site's: another web page can't drive it from a visitor's browser.
421hostA Host name the server doesn't answer to (DNS rebinding).
413too_largeA request over 64 KB.
503unavailableThe MCP server isn't running yet.
curl -s -X POST "$LULL_API/mcp" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
curl: POST /mcp

Models

Which NVIDIA model does what in a run. The language models run on Nebius Token Factory and are called by id; speech runs on a NeMo-Speech.cpp server.

ModelWhat it does
Parakeet TDT 0.6B v3Listenon the speech serverTranscribes the speech with a time for every word: where nobody speaks is where a line can go.Licence: CC-BY-4.0
Nemotron 3 DiarizationListenon the speech serverTells the speakers apart, so a pause is a silence of everyone.Licence: OpenMDW
Nemotron 3.5 Super VLWatchnvidia/Nemotron-3.5-Super-VLWatches the video and lists what a listener would miss, with times (role watch).Licence: OpenMDW-1.1
Nemotron 3 UltraPlannvidia/Nemotron-3-Ultra-550b-a55bReads the whole video's events and transcript at once and drafts lines that fit each pause (role plan, reasoning on).Licence: see its model card on Token Factory
Nemotron 3.5 Super VLFinalizenvidia/Nemotron-3.5-Super-VLChecks each line claim by claim against the frames it describes (role verify).Licence: OpenMDW-1.1
Nemotron 3.5 LightningFinalizenvidia/Nemotron-3_5-LightningSmall rewrites: cuts an unsupported claim, shortens a line that runs long, repairs a writing-rule break (roles remove, compress, repair); also lists names to look up (role research).Licence: see its model card on Token Factory
Magpie TTS Multilingual 357MVoiceon the speech serverSpeaks each line; its measured length decides whether it fits.Licence: NVIDIA Open Model License

MCP

Agents can describe video too. The hosted server describes the samples; the local one, which you run with your own keys, also takes your own files and lists the lines waiting for review.

ToolWhat it does
list_samples()hosted and localThe sample clips it can describe, with title, length, credit and licence.
describe_video(sample_id, mode)hosted and localDescribes a sample (locally, a file of yours by path) and returns the run id, its edit token, counts, coverage, WCAG statuses and spend. Blocks while it runs and sends progress.
get_tracks(run_id)hosted and localEvery line with its timecode and status, and the files: tracks, the described audio and videos.
get_report(run_id)hosted and localThe conformance report: WCAG rows with what blocks each, held lines with reasons, spend.
review_queue()local onlyYour runs with lines held for a person, newest first.

Hosted

https://lull-api-494617981995.us-central1.run.app/mcp, streamable HTTP. In Claude Desktop, add it under Settings, Connectors, as a custom connector; clients configured in JSON take:

{
  "mcpServers": {
    "lull": { "type": "http", "url": "https://lull-api-494617981995.us-central1.run.app/mcp" }
  }
}
Hosted MCP config
  • It takes sample ids only. A URL, a file path or an upload is refused, so the server never fetches what it is handed (no server-side request forgery).
  • A browser page from another site is refused (403 origin), and so is a host name it doesn’t serve (421 host).
  • describe_video blocks while the run goes, about six minutes for a 45 s sample on the hosted service, and sends progress at every stage. Reset your client’s timeout on progress, or allow at least 10 minutes.
  • Runs pass the same gates and spend caps as the REST API: one at a time, per-visitor limits, the day’s budget.

Local

lull mcp serves the same tools over stdio from the engine, with your Token Factory key and your own caps. describe_video also takes a path to an mp4, mov, webm or mkv file, and review_queue lists your runs with lines held for a person. Claude Desktop’s claude_desktop_config.json:

{
  "mcpServers": {
    "lull": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/lull/engine", "lull", "mcp"],
      "env": { "NEBIUS_API_KEY": "your Token Factory key", "TAVILY_API_KEY": "your Tavily key" }
    }
  }
}
Local MCP config