@spoolkit/cli
v0.7.0
Published
Agents record their own spools: real browser video, AI voiceover, word-synced captions, ffmpeg-rendered MP4. No human hits record.
Maintainers
Readme
spool
Agents record their own spools. After an agent builds a feature, it drives the app in a real browser, records a real continuous video (not screenshot stitching), narrates it with AI voice, and renders a designed, captioned MP4 — no human ever hits record. Think of the narrated walkthroughs you'd make with screen-recording products like Loom, except the agent is the producer.
Inspired by BuilderIO/agent-native's Clips, inverted: there a human records and the agent watches; here the agent is the producer.
Recaps are the exception, and nothing records them. When a PR merges on a repo with the
spoolkit GitHub App installed, the webhook queues a render_recap job and the worker renders
a vertical diagram video straight from the diff, then comments the watch link on the PR.
Install
npm i -g @spoolkit/cli
npx playwright install chromiumYou also need ffmpeg on PATH (macOS: brew install ffmpeg) and node >= 20. Run spool doctor
anytime to check your environment and get fix hints for anything missing.
Creating a spool requires a Spool platform API key. Create and manage named keys at
Dashboard → API keys, or run spool login to save one
locally, or set SPOOL_PUBLISH_TOKEN in your environment (including CI). This
applies to live, record, vo, render, finish, build, plan build, share,
and bg, including runs with --no-publish or local voiceover. An OpenAI, Fish,
or OpenRouter key does not replace the Spool key. The local prerequisite checks
for a configured key without a network request; hosted operations authenticate it.
Help, setup, diagnostics, scaffolding, plan drafting, and dry runs remain available
without a key.
0.6.0 adds spool voice: clone your own voice once and every spool narrates in it, with no
provider key. Word timings now come from the CLI's own aligner, so the fish engine needs no local
whisper. 0.5.1 adds spool --version and silent spools (--no-voice).
0.5.0 adds the change record's before/after screenshots (POST /shot in a live session,
spool change shot outside one, a slider on the watch page) and writes the pull request
description from the record when you publish with --pr. The repo is
aaarnv/spool.
To work on spool itself, install from a clone instead:
git clone [email protected]:aaarnv/spool.git && cd spool
npm install && npm link
npx playwright install chromiumPreferences
Your config belongs to your API key, not to a machine. It lives on the platform, under
spoolkit.dev > API keys, so every machine and every agent that uses that key records
the same way. spool setup writes it there:
spool setup # interactive on a TTY
spool setup --browser chrome --engine hosted --yes # non-interactive
spool setup --show # print effective config (token masked)
spool setup --browser chrome --local # this machine only| Key | Values | Default | Effect |
| --- | --- | --- | --- |
| browser | chromium | chrome | edge | chromium | recording browser (Playwright channel) |
| target | browser | os | browser | default spool live capture target |
| engine | auto | openrouter | openai | hosted | local | auto | default VO engine |
| bg | preset, wallpaper, or path | none | default render background |
| format | wide | vertical | wide | default render format |
Precedence is explicit flag > env (SPOOL_BROWSER/SPOOL_TARGET/SPOOL_ENGINE) > the key
config on the platform > ~/.spool.json > default. A flag or an env var still wins for one
run, so a machine can always step outside the key.
spool setup writes locally when this machine has no account, and prints how to connect
it. --local forces that for a machine that has one. The CLI reads the key config once a
run and keeps a copy in ~/.spool.json; when the platform is unreachable it uses that copy
and says so. spool doctor reports the active profile and its sources. Everything else the
renderer decides for itself. See Defaults.
First run
Bare spool init is the whole setup, on this machine and on this repo. Run it once from
inside the project you want to record:
cd <your-project>
spool initIt walks six steps, prints one line each, and skips any step that is already satisfied:
- Environment runs the
spool doctorchecks. A missing node, ffmpeg, or chromium stops the run and prints that check's fix. A missing OpenAI key is only a warning: hosted voice covers it. - Login starts the browser device flow when
~/.spool.jsonhas no token. Add--pasteto type anspk_token instead. Off a terminal it prints the command to run and stops.--no-loginskips this step for CI. - Preferences writes the defaults in Preferences when you have never set one, then prints the effective config.
- Repository reads the GitHub owner and name with
gh. Outside a repo, steps 5 and 6 are skipped. - GitHub App prints the install link. Installing it is what turns a merged pull request into a recap.
- Knowledge scaffolds
spool/project/, where you author what this repo is so later recordings start warm. Nothing is sent until you runspool init --apply.
spool init <slug> is unrelated and unchanged: it scaffolds spool/<slug>/steps.mjs for the
scripted path.
How it works
steps.mjs (agent-authored demo script)
│
├── spool vo → vo/seg_NN.wav + word timestamps OpenAI gpt-4o-mini-tts + aligner
│ (in parallel) (bounded concurrency pool)
└── spool record → video.webm + timeline.json Playwright recordVideo, fake cursor,
│ human-speed motion, natural timing
spool render → final.mp4 ffmpeg: retime each step to fit its
narration, play its capture at 1x then
freeze-hold, click zooms, word-synced
captions, VO at each step's offsetSync is record-first, narrate-parallel, retimed-in-render: the capture runs at natural
interaction speed while narration is generated concurrently, then the renderer sizes each
step to max(narration+pad, recorded) — playing the recording at 1x and freeze-holding its
last frame for the remaining dead air under the voice. Nothing is padded during capture, so a
5-step build drops from ~90s to roughly max(record, vo) + fast render.
Usage (any agent, any project)
Two authoring paths land on the same render.
Live — you just drove the flow while verifying a feature, so drive it once more and let spool record as you go. No steps.mjs to author or debug:
cd <your-project>
# boots a headless recording browser + an HTTP control server on 127.0.0.1:<port>.
# stdout prints one line: {"port":N,"session":"<dir>"}
spool live spool/my-feature --url http://localhost:3000
# then, per step (narration is required — the renderer fits the window to it):
curl -sX POST 127.0.0.1:$PORT/step -d '{"name":"open","narration":"The dashboard now loads the new flow."}'
curl -sX POST 127.0.0.1:$PORT/js -d '{"code":"await h.click(\"#open\"); await page.waitForSelector(\".result\")"}'
# … more /step + /js … a bad selector returns {ok:false} without killing the session …
curl -sX POST 127.0.0.1:$PORT/end # finalizes video.webm + timeline.json + a generated steps.mjs
spool finish spool/my-feature # vo → render → share → final.mp4 + share/
spool publish spool/my-feature # → https://<host>/l/<id>spool live also writes a generated steps.mjs capturing the config, per-step
names/narration/zoom, and the js snippets that succeeded — so the take is reproducible and
editable as a scripted spool later.
Live (OS target) — capture the whole macOS desktop instead of a browser tab, for demos
that leave the browser (native apps, the terminal, multiple windows). Same control protocol,
no page driver — you drive the desktop yourself (osascript/cliclick/your own tools) between
steps and use /sh to run terminal-visible commands:
spool live spool/my-demo --target os --title "…" # ffmpeg avfoundation full-display capture
curl -sX POST 127.0.0.1:$PORT/step -d '{"name":"open","narration":"…","zoom":"none"}'
# … drive the desktop out-of-band, then optionally log terminal commands …
curl -sX POST 127.0.0.1:$PORT/sh -d '{"cmd":"ls src/record"}' # returns + logs stdout/exit
curl -sX POST 127.0.0.1:$PORT/end # capture.mp4 + timeline.json + steps.os.md
spool finish spool/my-demoArrange your desktop first (hide unrelated windows, bring the app you're demoing to the
front — the capture is the whole display). zoom defaults to "none" on the OS target;
pass {"x":…,"y":…} (capture-pixel coords) to zoom toward a point. Screen Recording
permission is required: if capture comes back black, spool fails fast telling you to grant
it to your terminal in System Settings → Privacy & Security → Screen Recording, then restart
the terminal. The first "Capture screen" device is the one recorded.
Scripted — reproducible; author the driver up front:
spool init my-feature # scaffolds spool/my-feature/steps.mjs
# author the steps: N steps × { name, narration, zoom, run(page, h) }
spool dry spool/my-feature # debug the driver cheaply in a visible browser
spool build spool/my-feature # (vo ‖ record) → render → share → final.mp4 + share/
spool publish spool/my-feature # → https://<host>/l/<id> — one link, click to watch
spool publish spool/my-feature --pr # …and comment the link on the branch's GitHub PR(spool build on a live/recorded session skips recording and finishes it, so build works
for both paths.)
Plan Spools — a proposal a human watches and an agent reads. The workdir carries a
plan.json packet beside the media, so the summary on the watch page and the packet an
agent acts on cannot drift:
spool plan init my-feature --goal "..." --task SPL-14 # scaffolds plan.json + evidence.json
spool plan validate spool/my-feature # terse report; --json for diagnostics
spool plan build spool/my-feature # validate, then the build abovespool plan validate exits 0 when the packet is valid, 1 when it is invalid, and 2
when the workdir carries no plan. An invalid plan never costs a recording or a voiceover
run. See CONTRACTS.md → "Plan Spools".
The implementation gate — how much approval an agent needs before it builds. Set the
policy in spool.config.json (off, advisory, high_risk_required, required); with
no config a project is advisory:
spool gate policy # what is in force here, and why
spool gate check --command "npm run migrate" # exit 0 allowed, 1 blocked, 2 could not run
spool gate run -- npm run migrate # check, then run it (blocked: it never runs)
spool gate status --pr # publish the verdict as a GitHub commit statusA blocked run names the command, the policy and the plan, and links the plan to get a
decision on. Work that starts without an approved plan is recorded either way — under
advisory too — in .spool/audit.jsonl and on the plan. --bypass --reason "..." is the
one way past a policy, and it says so in the log. See
CONTRACTS.md → "Implementation gate".
On a pull request — one comment carries the plan, and one check says whether the plan is still about this code:
spool plan pr spool/my-feature # post/refresh the compact plan comment on the PR
spool plan stale spool/my-feature # exit 0 current, 1 stale, 2 unknownThe comment names the status, the decision, the source revision and the watch link, and
every later run rewrites that same comment instead of posting another. It is opt-in per
repository — add {"github": {"comment": true}} to spool.config.json — and no GitHub
failure can fail a build. A plan reads stale when its branch moved past the commit it was
written against, when a file it cites changed, or when it simply got old; the tolerance is
github.stale in the same file. See CONTRACTS.md → "GitHub integration".
spool publish uploads the video + share bundle to the hosted watch app (spoolkit.dev,
developed in a separate repo, deployable to Vercel + Blob) and returns a single unlisted, unguessable link —
video player, chapters, transcript for humans; raw spool.json on the same page for agents.
Agent-to-agent sharing (Clips, inverted then completed)
Every build also emits share/ — a machine-readable bundle so another agent can
consume the spool without watching video: spool.json (steps, narration, timings, click
coords, keyframe paths), transcript.txt, one keyframe PNG per step, and
console.jsonl (browser console/pageerror/requestfailed telemetry captured during
recording). A receiving agent runs spool read <dir> for an instant digest, then Reads
only the frames it cares about — e.g. to review a demoed feature, file bugs from
console errors, or verify a claimed fix actually renders.
Requirements: node ≥ 20, ffmpeg on PATH, and a voiceover engine. The engine auto-resolves:
- OpenRouter (default) —
OPENROUTER_API_KEY(env, the project's.env, oropenrouterKeyin~/.spool.json). Voice isdeepgram/flux-tts:free; override withSPOOL_TTS_MODELandSPOOL_TTS_VOICE(Flux voices areflux-<name>-en, defaultflux-drew-en). Audio comes back as lossless pcm, with mp3 as the retry for a model that refuses it. When the free tier is still throttling after the retries, that one segment falls back to the OpenAI voice, else the hosted voice, so a render never dies on a rate limit; or - OpenAI —
OPENAI_API_KEY(env, the project's.env, oropenaiKeyin~/.spool.json); or - hosted (zero-key) — just the
host+tokenyou already put in~/.spool.jsonforspool publish. Voice runs on the hosted app with no key of your own — the same dashboard token covers both publishing and voice (subject to a fair-use daily cap), and, once you runspool voice clone, your own voice; or - Fish Audio —
FISH_API_KEY(env orfishKeyin~/.spool.json), with the reference voice inSPOOL_FISH_VOICEorfishVoice. Never auto-selected: pin it to use it. Word timings come from the aligner, so this engine needs no local whisper; or - local (free) — a
SPOOL_VO_SHscript for local TTS/whisper.
They are tried in that order. Pin one with spool setup --engine openrouter|openai|hosted|fish|local,
or SPOOL_ENGINE for a single run. The pin goes on your API key, so the machines and agents
sharing that key all speak with the same engine. See Preferences.
Word timings never touch a transcription model on any engine: the narration is known, so the CLI's own aligner reads the words off the wav (pauses from the audio energy, punctuation snapped to them, syllables sharing each phrase). A provider key buys speech and nothing else.
Your own voice
Read a one-minute script once and every spool narrates in your voice.
spool voice clone # print the script, record you reading it, clone it
spool voice # what voice is set, and when it was cloned
spool voice remove # back to the house voicespool voice clone <file> clones a recording you already have (wav, webm, m4a, mp3 or opus).
--seconds 30 reads for longer (10 to 60), --device "<name>" picks a microphone.
The clone lives on the platform under your account, not on your machine, so cloning also pins
the VO engine to hosted. You need no provider key of your own. Record only your own voice.
Setup: npm install && npm link in this repo (chromium comes from Playwright's cache,
npx playwright install chromium if missing).
Use it from any agent
skills/spool/SKILL.md is an agent skill teaching any
skill-aware agent (Claude Code, Codex, etc.) the full workflow — live driving, OS capture,
narration voice rules, verification, PR comments. Copy it out of this repo into your agent's
skills directory (~/.claude/skills/spool/ or ~/.codex/skills/spool/). It is not part of
the npm package, so it needs repo access.
GitHub Action
The root action.yml is the composite setup action this repo's own workflows use through
uses: ./: it installs the CLI, ffmpeg and headless chromium on a runner and writes
~/.spool.json from the token input (store your spk_ token as a repo secret).
This repo is private, so it serves that action to nobody else. Workflows in other repos use
the public shim in public-action/, which installs @spoolkit/cli from
npm rather than cloning anything, and which can instead notify the platform and install
nothing at all. docs/PUBLISH-PUBLIC-ACTION.md is how it
gets pushed out as spoolkit/action; until that happens, the uses: line below resolves
for nobody.
- uses: spoolkit/action@v1
with:
token: ${{ secrets.SPOOL_TOKEN }}docs/examples/pr-guide.yml is a copy-paste workflow that
pairs it with anthropics/claude-code-action to author and publish a narrated PR guide on
every opened pull request (spool pr, tour + explainer authoring, headless record,
spool publish --pr).
Relay
The relay is a background service that ships your finished coding worklogs to Spool. A worklog is one conversation with Claude Code or Codex. Spool reads them to show what a team keeps struggling with, which commands fail, and how many tool calls a repo costs before a check passes.
spool relay install # register it as a login service, and start it
spool relay status # is it running, what is queued, where is the log
spool relay uninstall # stop it and remove the service fileInstall writes a launchd agent on macOS (~/Library/LaunchAgents/dev.spoolkit.relay.plist)
or a systemd user unit on Linux (~/.config/systemd/user/spool-relay.service). It needs a
Spool API key, so run spool login first.
By default it ships transcripts modified in the last 30 days. A laptop with years of history would otherwise backfill all of it on the first run. Change the window, or ask for everything:
spool relay install --since 90 # a wider window
spool relay install --since 0 # every transcript on the machineWhat it does, and only this:
- It watches
~/.claude/projectsand~/.codex/sessions. - A transcript with no writes for ten minutes is a finished worklog.
- It masks secrets on this machine before the record exists. API keys, bearer tokens,
JWTs,
NAME=valuepairs whose name says secret, and passwords in URLs are all replaced. Characters a database cannot store are removed: NUL bytes, and the half of an emoji left behind when text is cut to a length. The rule set is versioned, so a miss traces to a version. - It uploads the record and deletes it from the queue. Tool output bodies stay here; only the first 300 characters of each go up.
- Offline, it buffers to
~/.spool-relay/queueand retries. A repeat upload is a no-op because the server dedupes on the content hash. A record the server refuses outright, or one that fails five passes in a row on its own, moves to~/.spool-relay/rejectedwith the reason, so one bad record never blocks the rest. - It never edits anything, never runs a model, and never prints to your terminal. Under
the service its log is
~/.spool-relay/logs/relay.err.log, one timestamped line per event.
Environment: SPOOL_RELAY_HOME moves the queue, the state and the logs.
SPOOL_RELAY_QUIET_MS changes the ten-minute window. SPOOL_RELAY_SINCE_DAYS is the
--since window in days, 0 for all of them, which install writes into the service file
for you.
SPOOL_RELAY_CLAUDE_DIR and SPOOL_RELAY_CODEX_DIR point it at other transcript
directories. spool relay run --once does one scan, one upload pass and one heartbeat,
then exits.
Defaults
Spool has one way to make a video, and it is the good one. There is no --hq and no --rate,
and no render flag picks a background: every knob below is decided for you, and every one of
them was a flag somebody had to get right before. (Changing the canvas after the fact is its
own command, spool bg, because it costs seconds instead of a render.)
| What | House default | Why it is not a flag |
| --- | --- | --- |
| Frame rate | 60fps | The renderer retimes to the narration either way; 30 only ever looked worse. |
| Encode | hardware H264 for a --preview draft, libx264 -preset slow -crf 17 for every final.mp4 | The tier follows the purpose: a draft nobody publishes, or a master that gets published. |
| Format | wide for walkthroughs, vertical for packet videos | Stamped into the session at capture by spool live --format, then read from the workdir by everything downstream. |
| Background | the machine's real Sonoma wallpaper on a Mac, else the sky preset; packet videos get an ambient clip hashed from the packet | The video sits on the desktop it was recorded on, and nobody picks a wallpaper per video. |
| Narration | house voice, tempo 1.0, engine auto-detected (your key → hosted → local) | One voice is the product's voice. The engine is whichever one this machine can actually reach. |
| Plan narration | rewritten by gpt-5, falling back to the deterministic script | The fallback is silent and always correct, so there was never a reason to opt in. |
| Plan theme | warm-briefing | The founder picked it (SPL-DECISIONS #17). |
| Evidence | every collector runs (diff, commit, console, keyframes) | Each one skips itself when its source is absent, so choosing between them was work with no answer. |
| Publish | spool build/finish publish when they finish | A spool nobody can watch is not finished. --no-publish is the one exception, for verification runs. |
| Gate audit | every spool gate run is recorded to .spool/audit.jsonl | An unaudited run is indistinguishable from one that never happened. |
Escape hatches
Environment variables, for recovery and debugging. None of them belong in a script you commit.
| Variable | Effect |
| --- | --- |
| SPOOL_HOST, SPOOL_TOKEN / SPOOL_PUBLISH_TOKEN | Where to publish and as whom. Auth, not a knob — also settable via spool login and spool setup --host. |
| SPOOL_RENDER_FPS | Render at another frame rate. 30 roughly halves render time when you are iterating. |
| SPOOL_RENDER_CONCURRENCY | Cap parallel ffmpeg work. The GitHub Action sets it. |
| SPOOL_BG | Override the canvas for one local render (preset name, macOS wallpaper name, or an image path). |
| SPOOL_FORMAT | Force wide or vertical when a workdir has no stamp of its own. |
| SPOOL_ENGINE, SPOOL_VO_SH | Pin the VO engine; point at a local TTS/whisper script. |
| SPOOL_TTS_MODEL, SPOOL_TTS_VOICE | OpenRouter engine: the speech model and its voice. |
| SPOOL_PLAN_VOICE, SPOOL_PLAN_VISUALS, SPOOL_PLAN_MODEL | The plan narration profile and the model that rewrites it. |
| SPOOL_BROWSER, SPOOL_TARGET | Playwright channel and capture target for one run (spool setup is the durable form). |
| SPOOL_V0_ENGINE | Fall back to the Chrome rasteriser when Skia misbehaves. |
| SPOOL_SKIA_WORKERS | Cap packet-render stripe workers when the machine's memory is smaller than its core count suggests. |
| SPOOL_MCP_NOTIFY, SPOOL_MCP_WAKE_CMD, SPOOL_MCP_HOME | spool mcp watch: notify on wake, what to run per event, where the cursor lives. |
| SPOOL_PILOT_ROOTS | Colon-separated workdir roots, to collect one pilot dataset across several worktrees. |
| SPOOL_RELIABILITY=off | Stop journalling attempts (tests that fail on purpose). |
| SPOOL_PR_COMMENT_VIA_APP=0 | Platform-side setting: hand the PR comment back to the publisher's gh. The GitHub App writes it by default (docs/GITHUB-APP.md §5). |
The steps contract
See CONTRACTS.md for the full data contracts (steps.mjs shape,
timeline.json, vo/manifest.json). The only file an agent authors per spool is steps.mjs;
everything else is generated.
Editing a published spool
Disabled for now. Plain-English edits of a published spool are off unless
SPOOL_EDITS_ENABLED is set on the web app. Publishing still uploads the render sources
(normalized video.mp4, timeline.json, render.json, and the vo/ segments), and
spool bg <workdir> <bg> still swaps the canvas locally with no re-render. The kept
shapes: docs/EDIT-CONTRACT.md.
Run spool backgrounds (or --json) to list the backgrounds installed on this
machine. On macOS this includes iMac colors, Sonoma, available light/dark Sonoma
variants, Tahoe, and solid colors such as solid-space-gray. Animated system
wallpapers supply a still frame. Availability depends on which wallpapers macOS
has installed; Spool does not download or bundle Apple's images.
spool backgrounds
spool bg spool/my-feature tahoe
spool bg spool/my-feature sonoma-dark
SPOOL_BG=imac-purple spool render spool/my-featureOnly listed names are available locally. Bundled presets also work on Linux.
Design notes
- Capture is an adapter.
--target browser(default) = PlaywrightrecordVideo(CDP screencast → WebM, ~25fps, headless, zero OS permissions).--target os= macOS full-displayffmpeg avfoundationcapture (real cursor, 30fps CFR H264, long edge capped at 2560), which needs Screen Recording permission. Both emit the same timeline/render contract; the OS target addstarget:"os"+capture.mp4and drops thepagedriver. - Render is one ffmpeg filtergraph, with the design rasterised by a browser. The recording is composited onto a rounded card with gentle zooms toward logged click coordinates (Screen-Studio style). Every static layer — card chrome, caption pills, plan cards, hook/CTA — is screenshotted once by Playwright from the product's own CSS, so captions stay designed type rather than burned SRT, while ffmpeg does the per-frame compositing. The WebM → CFR H264 pass exists because VFR VP8 seeks badly.
- The canvas is house-picked. The background behind the card is this machine's real Sonoma
wallpaper on a Mac, and the
skypreset everywhere else (src/render/bg-resolve.mjs); packet videos get an ambient clip chosen deterministically from the packet, so the same plan always looks the same. A rendered spool is re-skinned in seconds withspool bg <workdir> <bg>, which composites a new canvas under the savedlayers/fg.webm; published spools can also be re-skinned from the web editor via theset_bgop (repo presets only), andSPOOL_BGoverrides one local render. - Dry-run first (scripted path).
spool drydrives the steps in a visible browser with no VO or video, so the agent can fix selectors/timing before spending TTS calls and render minutes. The live path skips this — you drive once and fix fumbles inline (a failed/jsdoesn't kill the take). - Live is record-derived.
spool liveinverts authoring: instead of writing a driver and debugging it, the agent drives the real app once over an HTTP control port and the steps are derived from the session, then emitted as a reproduciblesteps.mjssnapshot.
License
Spool is open core. The CLI, the agent skill, the MCP server, the render pipeline and the
GitHub Action are in this repository under the Functional Source
License, version 1.1 with an Apache 2.0 future license (FSL-1.1-Apache-2.0,
which upstream now names FSL-1.1-ALv2).
Use it for anything except a Competing Use: shipping it as a commercial product or service that substitutes for Spool or for spoolkit.dev. Internal use, non-commercial education, non-commercial research and professional services all count as Permitted Purposes. Every version converts to Apache 2.0 two years after it ships.
The hosted app on spoolkit.dev — the feed, the GitHub App, the render worker and billing — is closed source and stays in a private repository.
Earlier releases are unaffected: @spoolkit/cli up to 0.3.1 on npm remains MIT.
The two bundled music beds in assets/ are CC0 from FreePD.com ("Arpent" and "Wisdom in
the Sun" by Kevin MacLeod), trimmed and gain-matched. CC0 waives attribution; this note is
provenance only.
