Skip to main content
SKILL.md18.3 KB
View on GitHub

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

NeedLoad
type: cast step mechanics: mode: steps/mode: session, output formats, type: simulateatmos-cast
Embedding a cast step inside a larger multi-step workflowatmos-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

  1. Interpret as-is -- point tape:/source: (or atmos cast record) straight at an existing .tape file. Zero rewrite for tapes that only use directives in the "mapped" or "cosmetic" categories below. This is the fastest path and the right default.
  2. Hand-migrate to native steps:/session actions -- rewrite the tape's on-camera commands as type: simulate + type: shell/type: atmos pairs (mode: steps) or write/wait session 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 mixes mode: steps-legal and session-only directives and you want to keep the steps-legal parts under mode: steps deliberately.

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: cast
name: quick-demo
mode: session
source: demo/landing/hero.tape # path to an external .tape file
steps:
- type: cast
name: inline-demo
mode: session
tape: |
Require atmos
Set Shell bash
Set WaitTimeout 30s
Type "atmos version" Enter
Wait /❯$/

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 terminating Enter of a Type "..." Enter line.
  • A Hide ... Show block anywhere, regardless of what it contains.
  • A Type "text" with no trailing Enter -- 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 Enter
Sleep 2.5s
Down
Sleep 700ms
Enter
Sleep 1.5s
Enter
Wait /❯$/

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.tape

Output website/static/img/demos/hero.webm
Output website/static/img/demos/hero.mp4

Hide
Type "unset NO_COLOR ATMOS_NO_COLOR" Enter
Type `export ATMOS_EXPERIMENTAL=silence ATMOS_TERMINAL_SPEED=8 ...` Enter
Type `cd "$(git rev-parse --show-toplevel)/demo/landing/fixtures/hero"` Enter
Type "clear" Enter
Wait /❯$/
Show

Sleep 800ms
Type "atmos list stacks" Sleep 600ms Enter
Wait /❯$/
Sleep 1.5s
Type "atmos list components -s dev" Sleep 600ms Enter
Wait /❯$/
Sleep 3s

Screenshot website/static/img/demos/hero.png
Sleep 5s

(a) Interpret as-is via source:

steps:
- type: cast
name: hero-demo
mode: session
source: 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: cast
name: hero-demo-steps
mode: steps
working_directory: demo/landing/fixtures/hero # lifted from the Hide block's `cd` line
env:
ATMOS_EXPERIMENTAL: "silence"
ATMOS_TERMINAL_SPEED: "8"
# Add the remaining exported values here.
output:
cast: website/static/img/demos/hero.cast
steps:
- type: simulate
mode: typed
text: atmos list stacks
- type: shell
command: unset NO_COLOR ATMOS_NO_COLOR; atmos list stacks
- type: simulate
mode: typed
text: atmos list components -s dev
- type: shell
command: 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: cast
name: hero-demo-session
mode: session
shell: bash
working_directory: demo/landing/fixtures/hero
output:
mp4: website/static/img/demos/hero.mp4
steps:
- type: write
text: "atmos list stacks\n"
- type: wait
regex: "❯\\s*$"
timeout: 30s
- type: write
text: "atmos list components -s dev\n"
- type: wait
regex: "❯\\s*$"
timeout: 30s

Directive Support at a Glance

CategoryDirectivesBehavior
Mapped to a real cast fieldSet Shell, Set Width/Height (VHS pixels, converted to columns/rows), Set TypingSpeed, Set WaitTimeout, Output, Require, SourceFills in the corresponding step field/behavior; explicit YAML always wins
Feeds the size conversion onlySet FontSize/Padding/LineHeight/LetterSpacingUsed to convert Set Width/Height pixels to cells; they do not change rendering fonts
Cosmetic -- warns, then ignoredSet 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-onlybare 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: stepsSleep, WaitUnder 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 modesScreenshot <path>Becomes a native type: screenshot child step that captures a still image at that point in the recording
Always a hard errorCopy, Paste, EnvNot 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 Height are VHS pixels, converted to columns/rows from the tape's font and padding (VHS's default 1200x600 becomes 81x21). The step's width:/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 on Type, repeat delay on keypresses, and timeout on Wait.
  • Simple setup commands update tracked state in steps mode. Session mode lifts leading hidden cd/export/clear commands until literal replay is needed; unset always replays.
  • A tape Output overwrites 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: or atmos cast record) before hand- migrating anything -- most tapes that don't drive an interactive picker or use Hide for 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 a type: cast step inside a workflow.
  • A leading session Hide block can lift simple export/cd/clear lines. unset and subsequent commands replay in the shell. Steps mode rejects Hide/Show; move setup outside the block or use the hand-migration example above.
  • Delete or rewrite any Copy/Paste/Env line (an error in both modes) before pointing the interpreter at a tape authored for the real vhs binary -- they always error, on purpose, rather than silently producing a subtly broken demo.
  • Fix Output ... .webm lines (common in tapes written for atmos demo record, which targets .webm/.mp4 for the website) to a supported extension (.mp4 is usually already present alongside it) before interpreting a tape that was written for the real vhs binary.

Reference Files

  • references/vhs-directive-support.md -- exhaustive directive-by-directive support matrix: every Set <Key>, the full Output extension acceptance list, every keypress name, the exact mode: steps error-trigger list, and Copy/Paste/Env rewrite examples.