| name | civilwar-letterifier |
| description | Rewrite any text as a solemn, slightly absurd 19th-century Civil War field dispatch (Ken Burns documentary voice), then optionally turn it into a multimodal video — period narration via ElevenLabs, the letter in fancy script on aged parchment, a mournful music bed, and a slow Ken Burns pan/zoom of the letter being read. Use when the user types /civilwar, asks to "civil-war-ify" or "Ken Burns" some text, or asks for a documentary-style letter video. |
| license | Complete terms in LICENSE |
Civil War Letterifier
Two layers. Use only what the user asks for.
- Letterify (text → letter). Rewrite the user's text as a mournful field dispatch. This is the default and is just prose — no tools needed.
- Letterify the multimodal extravaganza (letter → documentary clip). Narrated, scored, written in script on parchment, with a Ken Burns drift over the page. Triggered when the user wants audio/video ("make the video", "with the voice and music", "the full Ken Burns thing").
Layer 1 — The /civilwar command
When the user types /civilwar <text> (or asks to civil-war-ify / Ken-Burns something), rewrite that text as a solemn, slightly absurd Civil War-era letter or documentary narration.
Inviolable rules:
- Preserve the original meaning, names, facts, dates, numbers, requests, and logistics exactly. A blocked Jira ticket stays a blocked Jira ticket.
- Do not invent facts. Period-flavored metaphor is welcome; new commitments, dates, or details are not.
- Mournful field-dispatch register: read over banjo, fiddle, and candlelight. Address a recipient ("My dear colleagues"), sign off as an obedient, beleaguered servant.
- Keep professional messages still usable — the manager must learn the actual status.
Modes
| Mode | Trigger | Length & tone |
|---|
| standard | /civilwar <text> | Default. One sepia paragraph or two. Tasteful tragedy. |
| field-note | /civilwar field-note <text> | One or two sentences — short enough for Slack/SMS. |
| full | /civilwar full <text> | Maximum banjo, maximum plague. Open with a stage cue (*faint fiddle over distant thunder*). HR is concerned. |
| executive | /civilwar executive <text> | Grave dispatch from Antietam that still conveys the Jira ticket, the blocker, and the ask. Safe to send to a manager. |
See references/letter-style-guide.md for the full register, devices, and worked examples. Read it before writing if you want the voice exactly right.
Quick example
/civilwar full I can't make the meeting today because my stomach is wrecked and I need to lie down.
faint fiddle over the low murmur of distant thunder
My dear colleagues,
I regret to report that I shall be unable to attend our appointed council this day. A most grievous rebellion has commenced within my own constitution, and I have been compelled to retire from the field and take refuge upon the nearest horizontal surface.
Pray proceed without me, and know that I remain, though diminished, devoted to the cause.
Your obedient and intestinally besieged servant,
J.
Layer 2 — The multimodal extravaganza
Goal: a documentary clip where the letter, in elegant script on candlelit parchment, drifts slowly under the camera (Ken Burns) while a weathered period voice reads it over a mournful fiddle-and-banjo bed.
Pipeline (all local; renders on the user's machine):
user text
│ Layer 1 — YOU write the period prose (the only creative/agentic step)
▼
the note ──► scripts/build.mjs ──► out/letter.mp4
│ 1. ElevenLabs narration (Cartesia capacity fallback)
│ → <output-dir>/.slowburns-narration/v1/<job+claim hash>/public/narration.mp3
│ 2. music bed (drop-in or auto-generated, optional)
│ 2b. ambient bed (assets/sfx, always-on atmosphere)
│ 3. props.json (date auto-generated; signature/title fixed)
└─ 4. Remotion render (parchment + script + Ken Burns + audio)
Three audio layers stack in the render: the narration (voice), the optional
music bed, and an always-on ambient bed (field atmosphere) underneath
both.
The only thing you produce is the note text. Everything else — narrator
voice, date line, signature, title, music, ambient, render — is deterministic
and handled by build.mjs. Don't write a date or signature; the script supplies
them (today's date as "From the Encampment, this Nth day of <Month>", plus a
fixed signature and title).
Step 1 — Write the note
Letterify the text (Layer 1). That period prose is the note — a salutation, a
body, and a closing line (no signature; the script appends one). Pass it straight
to the build as text, or save it to a .txt file. \n\n separates paragraphs;
keep it to a slow read (roughly ≤ 200 words for a tight clip).
Step 2 — Render
node scripts/build.mjs --text "My dear colleagues, ...the period prose... Pray proceed without me." --out out/letter.mp4
node scripts/build.mjs --file note.txt --out out/letter.mp4
node scripts/build.mjs --file note.txt --auto-music
node scripts/build.mjs --file note.txt --music assets/music/your-track.mp3
node scripts/build.mjs --file note.txt --font dispatch
First run installs the Remotion deps under remotion/ automatically. The clip auto-lengths to the narration plus a title card and a fade-out. (--spec letter.json is still accepted for back-compat, but only its letterText is read.)
Voice
The narrator is hardcoded — the custom "Civil War Veteran" voice
(HvjKMFO0rjuPaM2f997g), set as VOICE_ID in scripts/narrate.mjs. There is no
--voice flag and no env override: one note, one narrator. To change narrators,
design a new voice (see references/voice-and-music.md) and replace that single
constant.
Capacity fallback
ElevenLabs remains the one-call primary narrator. Cartesia is considered exactly
once only after a definitive primary quota/capacity/availability result (allowlisted
provider code paired with an eligible provider-specific status and type). For
ElevenLabs, the current error envelope
must have detail.type: rate_limit_error plus detail.code of
rate_limit_exceeded or concurrent_limit_exceeded at HTTP 429, or
detail.type: service_unavailable plus detail.code of service_unavailable
or maintenance at HTTP 503. Current credit exhaustion is eligible only as HTTP
402 with the exact current detail.type: payment_required and
detail.code: insufficient_credits pair. The documented legacy
400/401 quota shape
is eligible only at HTTP 400 or 401 with exact detail.status: quota_exceeded
and no current detail.code; its optional type may only be payment_required.
The legacy API may instead expose that same quota cause as the exact wrapper
detail.type: invalid_request plus detail.code: quota_exceeded at HTTP 400 or
401. That tuple is also eligible, but invalid_request is never a general
fallback type and no other status/code pairing inherits its eligibility.
The documented legacy detail.status values too_many_concurrent_requests and
system_busy remain eligible only at HTTP 429. Legacy HTTP 503 availability is
eligible only as detail.status: service_unavailable or maintenance, with an
absent or exact service_unavailable type. A present current detail.code
must agree with any retained legacy status; an auth, input, malformed, unknown,
wrong-status/type/code, or contradictory hybrid never falls back. Cartesia
retains its distinct, top-level structured error schema and its own positive
allowlist.
For ElevenLabs, a recognized provider error type repeated at the response top
level must not conflict with the nested detail cause. A mixed-scope auth,
authorization, validation, or other recognized error versus an eligible quota
tuple fails closed; genuinely unknown top-level metadata remains inert.
Bare, malformed, generic, auth/configuration/voice/model/input, or otherwise
unclassified responses — including generic 5xx responses — fail closed. It is
never a retry path and never follows another 4xx, malformed output, oversized or
invalid audio, or an ambiguous transport outcome. The adapter writes a temporary
MP3, validates it with ffprobe, then atomically publishes it with a sanitized
receipt sidecar.
Each failed entry in receipt.attempts is a strict allowlist: the existing
provider, model, voice, fallback_class, and sanitized request_id, plus
optional http_status, error_type, and error_code. The status is retained
only when it is an integer from 100 through 599; type/code values are retained
only as lowercase tokens matching [a-z][a-z0-9_]{0,127}. For legacy
ElevenLabs envelopes, error_code is the effective detail.status value.
Provider messages, raw/nested bodies, headers, URLs, transcripts, credentials,
and arbitrary or inherited fields are never copied. These are optional fields
within receipt schema version 1, so older receipts remain readable; a missing
tuple never authorizes a retry or a broader fallback inference. For manual
diagnosis, inspect only .state, .attempts, and .selection with jq, then
reconcile a sanitized request_id against the provider before changing policy.
Fallback is dark until both variables are present in the invocation environment:
CARTESIA_API_KEY (standard sk_car_..., never sk_car_admin_...) and an explicit
CARTESIA_VOICE_ID. The reviewed SlowBurns stock fallback configuration is Clyde
(98a34ef2-2140-4c28-9c71-663dc4dd7022) with
CARTESIA_API_KEY=op://DeLoSecrets/Cartesia/CARTESIA_API_KEY, resolved only by
op run. See references/voice-and-music.md for the metadata-only selection
record, objective preview validation, and source references. There is no
persisted dotenv configuration.
Bounded narration I/O and manual recovery
The narration adapter reads all values from the invocation environment and rejects
invalid values before any provider request. These bounded controls have safe
defaults and accept only whole numbers in the stated ranges:
SLOWBURNS_NARRATION_REQUEST_TIMEOUT_MS — 30000 default, 1..120000.
SLOWBURNS_NARRATION_BODY_TIMEOUT_MS — 30000 default, 1..120000.
SLOWBURNS_NARRATION_MAX_AUDIO_BYTES — 67108864 default, 1024..134217728.
SLOWBURNS_NARRATION_MAX_ERROR_BODY_BYTES — 65536 default, 128..1048576.
SLOWBURNS_NARRATION_FFPROBE_TIMEOUT_MS — 5000 default, 1..30000.
SLOWBURNS_NARRATION_FFPROBE_MAX_BUFFER_BYTES — 65536 default,
1024..1048576.
Every resolved output has one exclusive canonical claim at
<output>.narration.lock, independent of a caller-selected receipt path. The
lock contains only a hashed output identity, operation hash, owner PID/host,
timestamps, and a bounded phase history; it never contains a transcript, raw
path, provider message/body, request header, or credential. A response timeout,
body-read/size failure after a provider boundary, invalid successful audio, or
receipt-integrity failure retains the claim and blocks automatic regeneration.
On any non-normal streamed-body exit, the adapter aborts its request controller
synchronously, then best-effort starts cancellation of the unread reader/body
without awaiting it before classifying the error. Late cleanup failures never
replace that error or authorize a retry. Fully consumed streams are not
cancelled.
SLOWBURNS_NARRATION_MAX_AUDIO_BYTES applies to provider bodies, temporary
validation, and completed-artifact recovery. Recovery stats the final artifact
before ffprobe or a full SHA-256 read; an over-limit file is retained as
receipt_integrity_artifact_too_large with only its safe byte count, configured
limit, and hash identities in the lock. Its completed receipt is not repaired or
rewritten.
For an orchestrated render, --job-id and --claim-operation-id travel
together from the SlowBurns daemon through the CLI and build. The two exact,
length-framed values are SHA-256 hashed into a path-safe namespace below the
job MP4's runtime directory; the narration operation then binds that identity
plus the exact letter text. Identical text in two jobs therefore cannot share
audio, receipt, lock, temporary, props, or Remotion public state. A retry of the
same admitted claim resolves the same paths. Direct human CLI builds without
those internal arguments remain supported through a deterministic identity
bound to the absolute output path. The old shared
remotion/public/narration.* paths are legacy state and are never read,
overwritten, or removed by this flow.
There is no automatic stale-lock cleanup. For a manual recovery, first inspect
the sanitized phase and receipt. The only possible manual-clear candidate is a
claimed_pre_provider lock unchanged for at least 10 minutes, whose recorded
host is the current host, whose recorded owner PID is dead, and whose exact
namespace has no audio artifact or receipt indicating provider dispatch. A
young lock, live owner, foreign host, any artifact, or any
*_request_started, *_headers_received, *_body_read_*,
*_audio_validation_*, or receipt-integrity phase stays locked. Provider-boundary
state requires provider/request and billing reconciliation (using only a safe
request ID when present), artifact inspection, and explicit operator removal
after that investigation. Never delete an ambiguous claim merely to make the
CLI retry.
Music
Pick one; defaults to voice-only if you skip it:
--music <file> — a track you supply (recommended for the real thing). Drop files in assets/music/.
--auto-music — synthesize an original mournful fiddle/banjo bed via ElevenLabs (no licensing worries, looped under the voice).
Licensing matters: the actual Ken Burns theme ("Ashokan Farewell") is copyrighted — do not bundle it. references/voice-and-music.md lists public-domain period tunes and royalty-free sources.
Ambient bed
A separate, always-on atmosphere layer (crickets, wind, a distant camp) that
plays beneath the voice and music for the entire film — it is not an alternative
to the music bed; both stack. Drop an audio file in assets/sfx/ (a file named
ambient.* is preferred; otherwise the first/random track is used) and it is
picked up automatically. Overrides:
--ambient <file> — use a specific ambient track.
--ambient-volume <0..1> — peak level of the bed (default 0.16; it sits
below the music's 0.22 so it never competes with the narration).
If assets/sfx/ is empty and no --ambient is given, the layer is simply
skipped.
Tuning the look
The composition lives in remotion/src/CivilWarLetter.tsx. Preview and tweak live with:
cd remotion && npm install && npm run studio
references/composition-guide.md documents every knob (parchment, sepia grade, candle flicker, pan distance, fonts, pads).
Defaults this skill was built with
- Music: drop-in track if present, else auto-generated bed (so the pipeline always finishes).
- Ambient: always-on bed from
assets/sfx/ (skipped only if that folder is empty), layered under voice + music at 0.16.
- Voice: hardcoded custom "Civil War Veteran" (
HvjKMFO0rjuPaM2f997g in narrate.mjs); not parameterized.
- Date line / signature / title: deterministic (date from today; signature + title fixed). The note text is the only creative input.
- Output: 1280×720, 60fps, browser-safe BT.709 h264 MP4. The composition
retains a 1920×1080 design canvas and downsamples during rendering.