Recording the README GIFs
July 28, 2026 · View on GitHub
Everything in this directory that ends in .tape is a script. Nothing was performed by hand,
nothing was screen-captured, and any of it re-renders from a clean checkout:
assets/record.sh # all four
assets/record.sh workbench # or one
That is the point. A hand-recorded screencast is a one-off you cannot fix a typo in; a tape is source you can edit and re-run.
The tools
vhs turns a declarative .tape file into a GIF. It
spawns a real terminal, sends real keystrokes and records what actually happened, so the
numbers in these GIFs are the numbers that server really had.
brew install vhs # pulls in ffmpeg and ttyd
ffmpeg comes along and does double duty: pulling frames out for inspection, and re-timing a finished GIF if one ever needs it. None of the three currently do — what is committed is exactly what vhs produced, so a re-render is byte-comparable.
The fixture
A CLI that reads a server is only as interesting as the server. An empty Chronicle has nothing
to show, and the only populated one on the machine these were recorded on belonged to a real
customer system — so demo-store/ builds a third option: a throwaway Chronicle
in Docker with a small bookshop seeded into it.
assets/demo-store/reset.sh # fresh server, seed, exit
assets/demo-store/reset.sh serve 90 # stay connected and healthy
assets/demo-store/reset.sh serve-failing 90 # stay connected with one partition failing
It registers eight books, three members, two projections, two reducers and one reactor, then appends a story: borrowings, two returns, two reservations, two books going overdue. The reactor that sends overdue notices throws for exactly one book, which is what leaves a failed partition behind for the CLI to find.
Three properties matter:
- Domain identifiers, not GUIDs. A book is keyed by its ISBN and a member by a handle, so
978-0131177055is the same book on every re-render, the tapes can name it directly, and every column the CLI prints stays readable. The first version of this fixture usedGuid.NewGuid()and the read model table came out as two columns of wrapped hex. - A connected client. Three of the four clips run against
serve, so observers report Active. A client that has exited leaves everythingDisconnected, which looks like a broken system when it is only a stopped process — the first cut of this set had three redDisconnectedrows in the hero for no reason other than that the seeder had finished. - The failure survives. Chronicle retries a failed partition on its own with a widening backoff, and it succeeds the moment the client reconnects without the fault. So the seeder exits after seeding and the partition stays failed. An earlier version of the hero tape ran a background writer during the workbench segment to make the dashboard move on camera — it reconnected the client, Chronicle retried, and the failure the GIF was about quietly disappeared. Frames caught it.
The project sits outside the CLI solution on purpose and carries its own Directory.Build.props
so MSBuild does not walk up into the repository's analyzers and central package versions.
The loop
The order matters more than any individual setting.
1. Read the key handling before writing a line of tape. For the workbench that meant
WorkbenchKeyDispatcher.Dispatch. It is also how two stale claims turned up before they could
be filmed: the workbench's own [LlmDescription] and its Overview panel both told you to press
number keys to switch views, and no number key was bound anywhere in the dispatcher. Both are
fixed — but only because writing the tape meant reading the dispatcher first.
2. Find the environment's opt-out switches. This CLI has an unusually strong one: it
detects agent environments through CLAUDECODE, CURSOR_TRACE_DIR, WINDSURF_SESSION_ID and
friends, and silently switches to compact JSON with no banner. Recording from inside an agent
session without unsetting those produces a GIF of machine output.
CRATIS_NO_UPDATE_CHECK=1 silences the update hint, which would otherwise close every command
in every clip with ↑ update available. That switch did not exist when these were first
recorded — the tapes pre-dated the cache the check consults instead, which worked but was a
hack aimed at the wrong layer. Needing it here was the argument for adding it.
prepare-env.sh handles the rest, and builds an isolated $HOME because
~/.cratis/config.json on a real machine holds real server addresses and client secrets.
3. Rehearse, and look at the frames. Actually look at them.
ffmpeg -i out.gif -vf fps=1 frames/%03d.png
Every problem in this set was found by looking, never by reasoning about what the tape should have done:
- A
\\line continuation that vhs typed literally, so the command ran as three broken fragments. - The workbench opening with an empty content pane on a cold start, because the call that paints the content was guarded behind a restored view index greater than zero. Fixed rather than worked around.
- The generated bash completion script failing on
_init_completion, which comes from the bash-completion package that the macOS system bash 3.2 does not have. - Two clips rendered against a server that had died between renders, showing
0 itemswhere the content should be.
To find where something changed without scrubbing by eye, hash the frames and print only the ones that differ from their predecessor:
ffmpeg -v error -i out.gif -vf fps=1 seq/%03d.png
python3 -c "
import hashlib, glob
prev = None
for f in sorted(glob.glob('seq/*.png')):
h = hashlib.md5(open(f,'rb').read()).hexdigest()
if h != prev: print(f'{int(f[-7:-4])-1:3d}s {f}')
prev = h
"
4. Verify the server afterwards. Capture the tail sequence number, the failed-partition count and every observer's position before and after a render, and diff them. For this set both sides were identical, which is how "nothing was changed" became a checked fact rather than a hope.
Arrow keys do not work here
On the machine these were recorded on, vhs cannot deliver arrow keys to the application at all. This is not a workbench problem. The decisive test was bash itself:
Type "echo FIRST" Enter
Type "echo SECOND" Enter
Up
Up
Two Up presses recalled zero history. Readline is the most reliable arrow consumer there is,
so the keys are being lost below the application.
Everything else arrives: printable characters, Enter, Escape, Tab and Ctrl+<key>. So
the workbench clips are choreographed entirely out of those — Ctrl+P to open the palette,
typing to drive it, F to filter, Escape and q to leave. Tab is avoided inside the
workbench specifically, because it moves focus somewhere that blanks the content pane.
Two consequences worth knowing before editing a tape:
- The keys table in the README documents arrows because they work for real users. They are not demonstrable here.
- The workbench opens on the view it was last left on, so
prepare-env.shwrites aworkbench-state.jsonand the clips start where they want to be rather than navigating on camera.RECORDING_NAV_INDEXoverrides which view that is.
The settings, and what each is for
All of them live in _style.tape, which every other tape pulls in with
Source assets/_style.tape. Four recordings that drift apart in font size or theme read as
four screencasts; one shared file makes them read as one set.
| Setting | Why |
|---|---|
Set FontSize 14 + 1400x800 | 143 columns by 42 rows. Enough for the workbench's sidebar, table and detail pane side by side without the table truncating everything; GitHub scales a README image to about 880px, and this is still legible there. |
Set Theme {…} | Hand-matched to WorkbenchColors.cs and OutputFormatter.cs so the terminal chrome and the application agree. |
Set WindowBar Colorful, BorderRadius 10, Margin 24, MarginFill | The polish. Costs nothing, and a bare rectangle of terminal looks unfinished next to it. |
Set Framerate 24 | 50 is the default and roughly doubles the file for no visible gain. |
Set TypingSpeed | 55ms globally, dropped to 18ms for a 36-character partition id nobody reads, raised to 160ms where the typing is the content. |
_style.tape deliberately does not set the shell and does not close its Hide block.
Each tape picks its own shell and writes its own prompt, because a bash prompt and a zsh prompt
are not written the same way — and completions.tape has to be zsh.
vhs gotchas
Output /abs/path.giffails to parse. Quote it, or use a relative path.Escape, notEsc.Ctrl+P, notC-p.Setworks mid-tape, soSet TypingSpeed 18mscan speed up one stretch.- A
\\insideType "…"is typed literally — it is not a shell line continuation. Put long commands on one line. vhs validate file.tapecatches the syntax in a second. Run it before every render.
What earns a GIF
The most useful discipline here was editorial, not technical.
Show the tool working, not the system failing. The first version of this set was built
entirely around a broken store: the hero opened on red ✗ marks and a four-deep stack trace,
and three observers showed Disconnected because the seeder had exited. It read as "this is
for when everything is on fire" rather than "this is what the tool does". Three of the four
clips now run against a healthy, connected system, and exactly one is about a failure.
One hero, then short single-purpose clips. The top of the README gets the three questions you actually open the tool to answer — is it healthy, what happened, what state did that produce — at about 29 seconds. Everything after is 15-25 seconds and shows one thing, sitting next to the prose that explains it.
A GIF has to show something text cannot. That is the whole test.
- ✅
demo.gif— the verdict, then the raw log, then the state those events produced. Three commands, one question each, in the order they get asked. - ✅
workbench.gif— a full-screen dashboard narrowing live as a filter is typed, then one word matching five kinds of artifact at the same time. A screenshot shows five rows; it cannot show them arriving as the query resolves. - ✅
completions.gif— a tab press that makes a network call. There is no way to convey that the list came from the server rather than from the script, except by watching it happen. - ✅
triage.gif— one failed partition in a healthy store, and the trail from the verdict to the exception. Kept short and kept to one clip: four attempts of the same stack trace is a wall of output, and the README quotes the interesting part as text where it is searchable. - ❌ The output formats. The same command four ways is a table with byte counts, not a recording.
Watch the total weight. Four GIFs, under 1 MB.
Nothing destructive is typed on camera
An earlier cut of completions.tape used cratis chronicle observers replay to demonstrate
completion, because an observer id is a good thing to complete. That command reprocesses an
observer from sequence zero. It never ran — the tape stopped short of Enter and ended on
Ctrl+C — but it meant a single stray keystroke in a tape stood between a demo and a replay.
It now completes cratis chronicle read-models instances instead, which is read-only, and the
clip is better for it: the completion list has no $system entries in it, so there is no
backslash-escaped noise on screen either.
Audit before rendering, and again whenever a tape changes:
grep -n "replay\|retry\|remove\|delete" assets/*.tape
That should return nothing. If a clip ever does need a destructive command, keep it off Enter
and verify the server before and after — capture the tail sequence number, the failed-partition
count and every observer's position, and diff them.
No README asset justifies a tape typo replaying somebody's observer.