Skip to main content
A build that does not spend needs no account and no API key, and once Chromium and ffmpeg are on the machine it needs no network at all unless a page loads something from another host. Everything else a job needs is here: one command that fetches the toolchain, one flag that turns every prompt into a flag or a refusal, two ways to read a run as data, and one exit rule every command shares.

Fetch the toolchain up front

Nothing has to run this, because a command that needs Chromium or ffmpeg fetches it. It exists for the three cases where the download belongs somewhere other than inside the work: a container layer, a job that caches it, and a machine that will be offline later. A second run names what was already there and fetches nothing, so it is safe to leave in a job forever.
  • Chromium, ffmpeg and ffprobe all go into DeckTalk’s cache, so a job caches one directory. Requirements lists where it is on all three platforms, and tools.cache_dir moves it.
  • On Linux, install is also what installs Chromium’s system libraries, which needs root. A build never installs them. On a terminal it asks before it does, and without a terminal it goes ahead, because a job that ran install asked for exactly this.
  • decktalk doctor reports what this machine holds and what a run would use. Without a terminal, or under --json or --no-input, it fetches nothing unless --fix is passed, and on a terminal otherwise it offers once to fetch what is missing. Its cache field is the directory to cache, read from the machine rather than guessed.
  • Each download prints a line as it starts, progress lines as it arrives, with its size once the host states it, and a Fetched line with what arrived, so a job log shows a fetch is still moving. Roughly how large has the sizes.
After install, DeckTalk itself reaches the network only to buy speech or sound, and a build with --no-spend never does. A free provider such as dtsp is still called under --no-spend once a voice is named, on the address the machine names, a loopback address by default, because spend gates money and nothing else. A page that loads a font or a library from another host still reaches for it, as What leaves your machine explains.

Never prompt

--no-input is on every command. Under it a run never asks a person anything. Each prompt either takes the safe default or refuses and names the flag that would have answered, because a prompt an agent cannot answer and a flag that does not exist are the same failure. The refusal is the whole of what a caller needs.
--json also turns every prompt off, so a caller that reads the output never blocks on a question it cannot see.

Read the run as data

Two flags make a run machine-readable, and they answer different questions. --json is the answer. The command prints exactly one flat object on stdout, prints nothing else there, turns colour off and sends every log line to stderr. Four keys are on every result, which are schema, ok, findings and error, and each command adds its own fields beside them. There is no wrapper object and no nesting. The JSON contract is the whole of it.
--events is the account of the run while it happens. One JSON object per line goes to stderr as each event occurs, so --json and --events together are the answer on stdout and the progress on stderr with neither in the other’s way.
event is the discriminator. A stage.done and a section.done each carry an outcome of ran, kept, skipped, stopped or failed, so a job can tell work that was done from work that was kept, and a failed run’s run.done carries its error. The event stream lists every event and its fields. Every run appends the same lines to build/events/<run>.jsonl whether --events was passed or not, and the first line names that file. So a job that backgrounds a build can follow the file, and decktalk status --json lists any run whose file is still open under runs. [events] keep_runs prunes the oldest, and keeps twenty runs by default.

The exit codes a job gates on

Every command shares one exit rule, so a pipeline asserts on the code rather than on the output. Exit codes is the table, and the rule a job follows is one line: a non-zero exit with error null is a finding the job may fix, and a non-null error means DeckTalk could not run or did not finish. When the object beside it is the command’s own result, its cost is what the stopped run spent. A build that stops on a finding follows the same rule. It exits 1 with error null, and its result carries stopped_at, the stage it stopped after, beside its findings and the spend so far. A job reads stopped_at to learn that the build stopped short. film is null when it stopped before assemble, and names the film when assemble itself stopped it, because that film was made and then never measured.

Choose what fails the pipeline

--fail-on names the least severe finding that fails a run, and --allow CODE carries on past one code, which is still reported and still in the JSON. Which findings fail a run is the table of both.
decktalk schema finding prints every code, and each one has a page under the finding codes saying what moves it. A run that uses --fail-on never still exits 3 on an error, because an error is not a finding and no threshold covers it.

Judge the files with no browser

check judges script.md, cues.json and the pages, writes nothing a stage reads, and prices the narration a voiced build would buy. --no-pages drops the browser entirely and says which judgements it could not reach, so a docs-only job, a pre-commit hook and a container with no Chromium can all still run it.
The run is never quiet about what it skipped, so a hook that runs --no-pages cannot be mistaken for the full check. --no-frames is the middle setting: it keeps the browser and the page’s own catalog and drops the freeze comparison alone. check --json carries cost, so one call is also how an agent prices the narration before approving it. The score is priced apart, by decktalk score --no-spend --json.

Say where the project is

Every command takes -p DIR, and DECKTALK_PROJECT sets the default, so a job can run from the repository root without repeating the path.
Without either, the project is the directory the process started in. Every settings key publishes an environment variable of its own, named DECKTALK_<TABLE>_<KEY>, such as DECKTALK_VIDEO_PRESET. It sits above decktalk.toml and below --set. decktalk config explain KEY prints the value in force with the layer that set it, beside the key’s default, its safe range and the hazard of leaving that range, which is what to run when a job and a laptop disagree.

Size the machine

The keys below say how much work this machine takes on at once and how long it waits, which are the keys to change on a runner with fewer CPUs or less memory than a laptop. They describe the machine rather than the film, so they are machine keys. A recording waits for every frame it steps, so a frame drawn slowly is late on the clock of the run and never on the clock of the film. record.concurrency set above this machine’s share therefore costs speed and never correctness: a starved machine records slower, and every reveal still lands on its word. A page that stops answering is held to its deadlines instead. A call into it that outlives record.page_deadline_seconds, such as a step of a page that spins its main thread, closes the page and stops the run with a TOOL error naming the section and the frame, and a chunk or a run that takes record.deadline_slowdown times its expected time is stopped the same way. check, storyboard, cue and assemble hold every call into the pages they open to the same deadline, so no page can hold a build open.
A decktalk.toml that sets any of them is refused by name, because a project travels and a limit that suits one machine is wrong on the next:
Each also has its environment variable, such as DECKTALK_RECORD_CONCURRENCY, which is how a job sets one for a container it does not keep.

A GitHub Actions job

.github/workflows/video.yml
The job runs on every push to main and on every pull request, and its token can read the repository and nothing more. Each action is pinned to a commit, with its version in the comment beside it. DECKTALK_VERSION pins DeckTalk, and the tool cache is keyed on it as well as on the runner. Each DeckTalk version pins its own ffmpeg build and installs with the Playwright release that chooses its Chromium, so a new version starts from an empty cache and fetches the builds it wants, rather than restoring the ones the last version used. decktalk install fetches anything the restored cache lacks. DECKTALK_PROJECT names the project’s directory, so every command finds it from the repository root and the upload reads the film from the project’s own build/final/. The reports are written to reports/ at the root, outside the project. The second upload is the film alone with archive set to false, so it is a single file that plays in the browser, and it takes its name from that file. decktalk build runs all six stages, and verify is the last of them, so the film is measured inside the same run that made it and its findings already decide the build’s exit code. A job does not need a second command to gate on the film. --no-spend plays every take the repository holds and a placeholder for each turn whose take a provider that bills would sell, so the measurement covers the recording, the encode and the mix and not only the cue times, on a job that spends nothing and holds no key. A job whose voice is the free dtsp and that runs decktalk-voice beside it gets every missing take voiced under the same flag. A project with a render section names a program, and the runner refuses it as any machine does by default: record.programs is refuse, and ask refuses too without a terminal, so the build is refused before its first stage with INPUT. A job whose project’s programs you have read allows them with DECKTALK_RECORD_PROGRAMS: run in the build step’s env. Never set it on a job that builds a fork’s pull request, because the program runs with the job’s rights. Programs a project names says why.

Build with the takes you committed

A take is found by its content, so a repository that commits its takes builds its real film on a runner with no API key. Run decktalk narrate --spend once on your own machine and commit takes/. It holds the takes and their words files, and in takes/aligned/ the words an aligner measured in a take whose voice sent none, so a job with no aligner plays them as they were timed. A placeholder, the take index and the joined narration stay under build/, so a job that plays them changes no tracked file. A project with a score does the same with decktalk score --spend and score/, which holds the bought sounds and their ledger, while the music joined from its parts stays under build/score/.
A minute of ElevenLabs speech at mp3_44100_128 is about 1 MB, and its words file about 11 KB, so a 10 minute film’s takes come to about 10 MB, and each change of voice adds that again because the old takes stay until you remove them. A repository reaches GitHub’s advised 1 GB only after about a hundred such revoicings. If you would rather keep the audio out of the repository’s history, Git LFS takes it with one line in .gitattributes, and the checkout step then needs lfs: true:
.gitattributes
The voice id is a published name rather than a secret, so it is committed beside the takes and the job is handed nothing at all. A project that keeps the id out of the file hands the job DECKTALK_VOICES_NARRATOR_ID instead, and still leaves the key out. A run with --no-spend plays every take on disk and never builds a voice that bills, so it reads no key. Each turn whose take is missing plays a placeholder and is reported as TAKE_MISSING, once per turn, which is once per section when one voice reads it, with the command that buys its take. A free provider is the exception: once a voice is named it makes the missing takes, and when its server is not running each turn plays a placeholder whose finding says to start it rather than to buy anything. That finding is a warning, because the film is still a whole film with estimated timing in that section, so the default --fail-on error builds it and exits 0 and the finding sits in build.json for the job summary. A job that must fail on a missing take passes --fail-on warning, and --allow carries on past any other warning it accepts. A pull request from a fork runs the fork’s page on your runner. Under the default record.page_policy that page reaches the network as it would in a browser. A page someone else wrote explains the policy that refuses it.

Cue timing on a hosted runner

A hosted runner has no GPU that Chromium can use, so every frame is drawn in software and a recording takes longer. It does not land a reveal any later. The recorder steps the section clock one frame at a time and draws each frame at its own second, so a reveal shows on the same frame on a hosted runner as on your own machine, and CUE_OFF fails the job above exactly as it fails a build on your desk. DeckTalk’s own end-to-end suite holds every reveal to its frame on every runner it runs on. A slower runner does have limits. One call into a page that takes longer than record.page_deadline_seconds, 15 seconds by default, stops the run with a TOOL error naming the section and the frame. A recording more than record.deadline_slowdown times slower, 30 by default, than this machine’s record.chunk_startup_seconds and record.step_seconds stops the same way. A chunk whose renderer crashed is recorded again in a fresh page up to record.renderer_retries times, once by default. On a runner much slower than the machine those step times were set for, raise the limits with DECKTALK_RECORD_PAGE_DEADLINE_SECONDS and DECKTALK_RECORD_DEADLINE_SLOWDOWN.

Build offline

A build that does not spend runs with no network at all after these three steps.
1

Fetch the tools while there is a network

This is the download a build would otherwise do the first time it needs it, which on an offline machine is the moment there is no network left to do it with.
2

Write the project after the tools are there

DeckTalk serves the packaged KaTeX on the page’s own origin, so the equations render with nothing fetched.
3

Build without buying

The starter’s page points its KaTeX tags at the engine’s path. A page of your own that points them anywhere else leaves its equations as plain text offline, and the run reports PAGE_KATEX_MISSING.
deck/index.html
Anything else a page loads from another origin is reported as PAGE_CDN_ASSET, which is the one finding that names a file an offline build would not have. Put the file beside the page and the deck is self-contained.