Atmos VHS
VHS is an external tool with its own .tape scripting
DSL for recording terminal sessions. Many Atmos users already have .tape files -- often written
for the real vhs binary, sometimes just copied from a demo repo. This skill covers Atmos's own
subset-compatible interpreter of that same tape syntax, built into the type: cast workflow
step: it parses and executes VHS-dialect directives directly, in memory, every run. It is not a
wrapper around the real vhs binary -- no VHS/ttyd/ffmpeg install required, and no .tape is
ever converted into generated YAML on disk.
Related Skills
| Need | Load |
|---|---|
type: cast step mechanics: mode: steps/mode: session, output formats, type: simulate | atmos-cast |
| Embedding a cast step inside a larger multi-step workflow | atmos-workflows |
Quick recap (full detail lives in atmos-cast): mode: steps runs real nested steps and records
them, with full exit-code propagation. mode: session drives one PTY-backed shell through
scripted write/key/pause/wait actions -- realistic keypress fidelity, but no per-command
exit codes. Everything below is about which of those two modes a given VHS tape needs, and how far
you can get without hand-writing either.
Two Paths: Interpret or Hand-Migrate
- Interpret as-is -- point
tape:/source:(oratmos cast record) straight at an existing.tapefile. Zero rewrite for tapes that only use directives in the "mapped" or "cosmetic" categories below. This is the fastest path and the right default. - Hand-migrate to native
steps:/session actions -- rewrite the tape's on-camera commands astype: simulate+type: shell/type: atmospairs (mode: steps) orwrite/waitsession actions (mode: session). Do this when you want per-step names for observability, want to interleave real Atmos-native step types the tape can't express (structured error handling,type: require,type: toast), or when a tape mixesmode: steps-legal and session-only directives and you want to keep the steps-legal parts undermode: stepsdeliberately.
Both paths can coexist: interpret a tape unmodified today, hand-migrate later only if you outgrow it.
tape: / source: and atmos cast record
steps:- type: castname: quick-demomode: sessionsource: demo/landing/hero.tape # path to an external .tape file
steps:- type: castname: inline-demomode: sessiontape: |Require atmosSet Shell bashSet WaitTimeout 30sType "atmos version" EnterWait /❯$/
An explicit step-level YAML field (shell:, width:, height:, output:, and so on) always
wins over the value a tape directive would otherwise imply -- the tape fills in gaps, it never
overrides what you set explicitly in YAML.
For a single tape with no workflow YAML at all, use the CLI directly -- it reuses the exact same
interpreter as source:, just without a type: cast step wrapping it:
atmos cast record demo/landing/hero.tape --output=hero.mp4 # mode: session (default)atmos cast record demo.tape --output=demo.cast --mode=steps # real exit codes
hero.tape itself only works under mode: session -- its Hide block (see the worked example
below) is a hard parse-time error under mode: steps regardless of what's inside it. --mode=steps
only applies to a tape with no Hide/Show, bare keypresses, or Type without a trailing Enter
(see "Choosing mode: steps vs mode: session" below).
atmos cast record is a distinct subcommand from atmos cast play/atmos cast render -- play
and render are unchanged and still only operate on .cast recordings; neither accepts a tape,
since a tape isn't a cast.
Choosing mode: steps vs mode: session for a Tape
This is the decision that matters most. Scan the tape for any of these three things:
- A bare/standalone keypress anywhere:
Enter,Space,Tab,Up,Down,Left,Right,Backspace,Escape,PageUp,PageDown,Delete,Insert,Home,End,Ctrl+<letter>,Ctrl+Alt+<letter>,Alt+<char>,Shift+Tab,Shift+<letter>-- when it is not immediately the terminatingEnterof aType "..." Enterline. - A
Hide...Showblock anywhere, regardless of what it contains. - A
Type "text"with no trailingEnter-- a bare keystroke sent to an already-running interactive program.
None found -> mode: steps is legal. Use it: you get real, individually tracked exit codes
for every command, which is the entire point of choosing steps for a demo that should actually
prove something ran correctly.
Any found -> mode: session is required. Trying to interpret that tape under mode: steps
is a hard parse-time error naming the offending line and telling you to switch to
mode: session.
demo/landing/dx.tape is a real example that forces mode: session:
Type "atmos terraform deploy" Sleep 600ms EnterSleep 2.5sDownSleep 700msEnterSleep 1.5sEnterWait /❯$/
The Down / Enter / Enter sequence drives an interactive plan-confirmation picker with raw
keypresses -- there is no discrete "step" that means "press the down arrow once." That tape can
only be interpreted (or hand-migrated) under mode: session.
Under mode: session, a leading Hide block can lift simple export/cd/clear
commands into native env:/working_directory: settings. unset always runs in the live
shell, because removing a key from env: cannot remove a variable inherited from the process
or workflow. Once a command needs replay, the rest of that block also replays in order.
Later Hide blocks always replay literally. Consequently, hero.tape's leading unset
keeps its entire setup block as muted shell input, including its substituted cd command.
Under mode: steps, any Hide/Show token remains a parse error. Hand-migrate the setup
as shown below, or remove the Hide/Show directives and let the interpreter track simple
setup commands between the real steps.
Worked Example: demo/landing/hero.tape
demo/landing/hero.tape is a real VHS tape in this repo, normally driven by the actual vhs
binary via the separate atmos demo record pipeline (unrelated to type: cast). It sources
defaults.tape, hides a setup block that only exports env vars and cds into a fixture, types
two on-camera commands, and takes a trailing Screenshot:
Source demo/landing/defaults.tapeOutput website/static/img/demos/hero.webmOutput website/static/img/demos/hero.mp4HideType "unset NO_COLOR ATMOS_NO_COLOR" EnterType `export ATMOS_EXPERIMENTAL=silence ATMOS_TERMINAL_SPEED=8 ...` EnterType `cd "$(git rev-parse --show-toplevel)/demo/landing/fixtures/hero"` EnterType "clear" EnterWait /❯$/ShowSleep 800msType "atmos list stacks" Sleep 600ms EnterWait /❯$/Sleep 1.5sType "atmos list components -s dev" Sleep 600ms EnterWait /❯$/Sleep 3sScreenshot website/static/img/demos/hero.pngSleep 5s
(a) Interpret as-is via source:
steps:- type: castname: hero-demomode: sessionsource: demo/landing/hero.tape
This does not work unmodified. The first Output ... hero.webm line is a hard error --
.webm is not one of the extensions the interpreter (or atmos cast render) accepts. The fix is
a one-line edit: delete that line, or change it to .mp4 (the very next Output line already
targets .mp4, so deleting the .webm line is enough). Every other directive in this tape --
Source, the Hide/Show block, Sleep/Wait, Screenshot -- interprets unmodified.
(b) Hand-migrated to mode: steps
For a hand-written steps recording, put exported values and the directory in
env:/working_directory:. Each shell command runs separately, so prepend
unset NO_COLOR ATMOS_NO_COLOR to each command that must remove inherited color settings.
The paired simulate step displays only the command the viewer should see. Tape interpretation
handles tracked unset commands automatically when using mode: steps without Hide/Show.
steps:- type: castname: hero-demo-stepsmode: stepsworking_directory: demo/landing/fixtures/hero # lifted from the Hide block's `cd` lineenv:ATMOS_EXPERIMENTAL: "silence"ATMOS_TERMINAL_SPEED: "8"# Add the remaining exported values here.output:cast: website/static/img/demos/hero.caststeps:- type: simulatemode: typedtext: atmos list stacks- type: shellcommand: unset NO_COLOR ATMOS_NO_COLOR; atmos list stacks- type: simulatemode: typedtext: atmos list components -s dev- type: shellcommand: unset NO_COLOR ATMOS_NO_COLOR; atmos list components -s dev
Sleep/Wait lines are simply dropped -- step execution is already synchronous, there's nothing
to wait for. A mid-recording Screenshot like the tape's trailing line maps to a native
type: screenshot child step (path: <file>), so hand-migrating it is just one more entry in
steps:.
(c) Hand-migrated to mode: session (session-faithful)
If you want to keep PTY fidelity instead (e.g. this tape also had raw keypresses), the same on-camera section becomes scripted session actions:
steps:- type: castname: hero-demo-sessionmode: sessionshell: bashworking_directory: demo/landing/fixtures/herooutput:mp4: website/static/img/demos/hero.mp4steps:- type: writetext: "atmos list stacks\n"- type: waitregex: "❯\\s*$"timeout: 30s- type: writetext: "atmos list components -s dev\n"- type: waitregex: "❯\\s*$"timeout: 30s
Directive Support at a Glance
| Category | Directives | Behavior |
|---|---|---|
| Mapped to a real cast field | Set Shell, Set Width/Height (VHS pixels, converted to columns/rows), Set TypingSpeed, Set WaitTimeout, Output, Require, Source | Fills in the corresponding step field/behavior; explicit YAML always wins |
| Feeds the size conversion only | Set FontSize/Padding/LineHeight/LetterSpacing | Used to convert Set Width/Height pixels to cells; they do not change rendering fonts |
| Cosmetic -- warns, then ignored | Set FontFamily/Theme/Margin/BorderRadius/Framerate/CursorBlink/MarginFill/WindowBar/PlaybackSpeed/LoopOffset, Set WaitPattern (v1) | No cast equivalent; logs a warning naming the key and continues without applying it |
| Session-only | bare keypresses, Type with no Enter, Hide/Show (always, regardless of contents) | Legal under mode: session only; hard parse-time error under mode: steps |
Pacing, dropped under mode: steps | Sleep, Wait | Under mode: session, translated to real pause/wait actions; under mode: steps, step execution is already synchronous, so these are dropped with a warning (not an error) |
| Works in both modes | Screenshot <path> | Becomes a native type: screenshot child step that captures a still image at that point in the recording |
| Always a hard error | Copy, Paste, Env | Not silently dropped -- rewrite as Type/export (see reference) |
For the exhaustive, directive-by-directive table -- every Set <Key>, the full Output
extension list, every keypress name, and rewrite examples for Copy/Paste/Env -- read
references/vhs-directive-support.md.
Wait vs Wait+Screen: the Scrolling Trap
Waits only match output produced after the most recent input action (Type/keypress), so the echo
of the command just typed and earlier output never satisfy a Wait. A timeout fails the cast with
timed out waiting for cast output: /re/ not seen within 10s. Override the timeout for one wait
with Wait@10s /re/ (also Wait+Screen@10s and Wait+Line@10s); it takes precedence over
Set WaitTimeout.
Prefer a bare Wait /pattern/ over Wait+Screen /pattern/ for any command whose output can
scroll (terraform plan/apply/deploy, long list/describe output). This mirrors a real,
cited VHS bug (charmbracelet/vhs#657 /
#659, documented in
demo/landing/README.md): once terminal output scrolls, Wait+Screen (and .ascii/.txt
capture) search the top of scrollback instead of the visible viewport, so the match can time out
even though the recording itself is fine. The interpreter is a from-scratch implementation, not a
call into the real vhs binary, but it reproduces the same terminal-cell-grid model -- so the
same caveat applies. Anchor Wait on the prompt returning (e.g. Wait /❯$/) rather than trying to
match specific output text for anything long-running.
Sizes, Literal Text, Timing, and Setup Lines
Set Width/Set Heightare VHS pixels, converted to columns/rows from the tape's font and padding (VHS's default 1200x600 becomes 81x21). The step'swidth:/height:always win.- Tape text is literal:
{{ ... }}is never templated, and double-quoted strings take no backslash escapes (as in VHS). @<duration>sets typing speed onType, repeat delay on keypresses, and timeout onWait.- Simple setup commands update tracked state in steps mode. Session mode lifts leading hidden
cd/export/clearcommands until literal replay is needed;unsetalways replays. - A tape
Outputoverwrites an existing file, like VHS.
Formulas, clamping, and every edge case are in references/vhs-directive-support.md.
Session Shell Startup (shell_init)
mode: session starts the shell clean by default so recordings do not leak personal prompts,
aliases, or plugins. shell_init: clean (default) skips startup files (bash --noprofile --norc,
zsh -f, fish --no-config, other shells ENV=), user loads them, and any other value is an rc
file path sourced instead. Precedence: the step's shell_init > atmos cast record --shell-init >
cast.session.shell_init in atmos.yaml (ATMOS_CAST_SESSION_SHELL_INIT) > clean. Session mode
waits for the shell's first prompt before the first action.
The shell field (and atmos cast record --shell, which overrides a tape's Set Shell) is a
command line: shell: bash -o vi passes -o vi after the shell_init arguments, keeping the clean
startup. A command line that manages startup files itself (bash --rcfile demo.rc) pairs with
shell_init: user to run as written. A shell that exits at startup (for example on an unknown
option) fails the cast with the shell's own message.
Source
Source <file> inlines another tape file's directives at that point, resolved relative to the
step's working_directory (or the process CWD if unset) -- the same fixed base source itself
resolves against, and the same base every Source uses no matter how many levels deep. This
matches real vhs: vhs script.tape resolves every Source in the script relative to its own
invocation directory, not relative to whichever file a Source happens to be read from. Concretely,
hero.tape's own Source demo/landing/defaults.tape line is written as a path relative to the repo
root (where vhs is invoked from), even though hero.tape itself already lives in
demo/landing/ -- resolving it relative to hero.tape's own directory would incorrectly look for
demo/landing/demo/landing/defaults.tape. That Source line pulls in defaults.tape's
Require atmos, Set Shell bash, Set WaitTimeout 300s, and every cosmetic Set before
hero.tape's own Output/Hide/Show content runs. Source chains -- a sourced file can itself
Source another file -- and is commonly used the way demo/landing/*.tape uses it: one shared
defaults.tape for look-and-feel and wait behavior, sourced by every topical tape.
Require
Require <program> maps onto the same tool-presence check as Atmos's own type: require step
(tools: [program]) -- it fails fast, before anything runs, if the named program isn't on PATH.
defaults.tape's Require atmos line becomes an implicit tools: [atmos] precondition on the
cast step.
Common Patterns
- Try interpreting a tape unmodified first (
source:oratmos cast record) before hand- migrating anything -- most tapes that don't drive an interactive picker or useHidefor something exotic work with zero rewrite. - Use
atmos cast record <tape> --output=<path>as a fast smoke test for "does this tape even work under the interpreter" before wiring it into atype: caststep inside a workflow. - A leading session
Hideblock can lift simpleexport/cd/clearlines.unsetand subsequent commands replay in the shell. Steps mode rejectsHide/Show; move setup outside the block or use the hand-migration example above. - Delete or rewrite any
Copy/Paste/Envline (an error in both modes) before pointing the interpreter at a tape authored for the realvhsbinary -- they always error, on purpose, rather than silently producing a subtly broken demo. - Fix
Output ... .webmlines (common in tapes written foratmos demo record, which targets.webm/.mp4for the website) to a supported extension (.mp4is usually already present alongside it) before interpreting a tape that was written for the realvhsbinary.
Reference Files
- references/vhs-directive-support.md -- exhaustive
directive-by-directive support matrix: every
Set <Key>, the fullOutputextension acceptance list, every keypress name, the exactmode: stepserror-trigger list, andCopy/Paste/Envrewrite examples.