Turn brand details + a creative prompt into a render-ready ad script.
Template-driven: you pick a script format from templates/ (the first is
podcast-script), the scaffolder lays out the correct scene structure, and you
(the agent) write the lines. The output is a script.json the matching
production template renders.
Two modes:
- Fresh — brand details + prompt → a new script from scratch.
- Remix — a source ad's remix payload (from the
remix-ad/ library flow) → inherit its format + beat arc (NOT its literal scene count), then write the new brand's script with as many scenes as that brand needs.
When to use
- "Write the script for [brand] / this idea."
- "Remix this ad's script for [other brand]." (works for any format — podcast-skit, iMessage thread, sung music video, single-host UGC, animated explainer.)
- Any step that needs a structured script before generation (this is the script stage of the remix pipeline, extracted as its own skill).
Do NOT use to: research the brand (use brand-research), or render
the ad (hand the finished script.json to the format-matching
production template — create-podcast-skit-ad,
create-imessage-video-ad, etc. — see step 9's route table).
Inputs
- Brand details (one or both):
- a brand-context pack (
brand-research/*.md— voice, audience, what-to-never-say, claims) from thebrand-researchskill, and/or - a source sample payload (
source-sample.jsonfromremix-ad'sfetch_sample.py) for remix mode.
- a brand-context pack (
- User prompt — the creative direction / angle.
formatProfile— comes fromsource-sample.jsonin remix mode; asked from the user in fresh mode. There is no default format. The skill never pickspodcast-scriptto be safe — if the source is iMessage, the output is iMessage (hand-authored). See step 3's route table.
Templates
A "template" here means a scaffolder + validator pair for one
formatProfile. Templates live in templates/<name>.json (a _meta
block with scene count, word caps, intonation legend, guardrails).
Only one template is shipped today — and that does not mean every remix routes to it. Most formats are hand-authored (see step 5 Path B). The full set of formats this skill handles is in step 3's route table.
podcast-script(only shipped template) — two-host fabricated podcast skit (HER + HIM), 20-24 alternating scenes, intonation legend, ≤10 words/line, ≤3 words/caption, themed-set mismatch as the joke. Renders viacreate-podcast-skit-ad. Scene shape:{scene, who, time, still, caption, text}. Used only when the sourceformatProfileispodcast-skit-fabricated(or fresh mode with the user explicitly picking this format).
To add a template for another format, drop a new <name>.json here
following the same _meta.rules shape, plus a matching scaffolder
clause in scripts/new_script.py and validator clause in
scripts/validate_script.py.
Setup
No API keys, no heavy deps — the agent writes the copy; the scripts only scaffold and validate.
pip install -r requirements.txt # stdlib only
Workflow
-
Watch the source FIRST (remix mode).
In remix mode you are rewriting somebody's ad. Don't trust
extracted_scriptalone — it's a transcript, not the ad. The pacing, sight gags, on-screen text, where the brand surfaces, and the tone often only land once you've seen it. Run the/watchskill on the source video before writing any lines:# source-sample.json has a media_url field (CDN URL or file:// path). # Pipe it into /watch — frames + transcript come back in the same turn. jq -r .media_url ./remix/source-sample.json # then: /watch <that-url> "what's the format, beat arc, and visual joke?"Read the frames
/watchreturns. Note: archetype (skit / single-host / sung / explainer), set + blocking, where the brand logo / pack lands, what the on-screen text says vs. what's spoken, the actual hook beat. These observations belong in the storyboard preview at step 7 — don't throw them away. The format you see here is also what drives step 3.Skip this step in fresh mode (no source video). Also skip if the user has already provided their own brief storyboard for the source.
-
Gather brand details.
Completeness-check before asking. Slug the target brand (lowercase, dashes), then
ls clients/case-insensitively —clients/hume-health/andclients/Hume-health/are the same brand; normalize.Then run
brand-research's verify gate against the brand root:python ../brand-research/scripts/verify_pack.py --brand-dir clients/<slug>- Exit 0 (
PASS) — the pack is complete. Read every file inclients/<slug>/brand-research/. Ifclients/<slug>/concept-brief.mdexists, treat its concepts as the starting menu the user picks from in step 4 (clarifying questions). Proceed to step 3. - Non-zero exit — the pack is missing or incomplete (no folder, stub
TBDs, missing headers, manifest gaps, etc.). Do not write the script with partial info, and do not ask the user. Invoke thebrand-researchskill against the same--brand-dir. It is step-idempotent: every phase that's already complete is skipped, and only the missing pieces run. Whenverify_pack.pyfinally returnsPASS, resume here at step 3.
Do NOT ask the user "do you have a brand pack?" — answer that with
verify_pack.py. Likewise, do not ask "should I run research?" — if the gate failed, run it.Then read the pack (
brand-summary.mdvoice + "what to never say",audience.mdverbatim phrasing, claim limits,visual-identity.mdpalette for end-card decisions). In remix mode, also readsource-sample.json— note itsrecipebeats,extracted_script(the skeleton), kept voices,how_togotchas, andformatProfile(the field that drives which path you take below). - Exit 0 (
-
Identify the format profile. This is the routing key for everything below — do not default to
podcast-script.Read
source-sample.json.formatProfile. Cross-check it against the frames/watchreturned in step 1. If the field is missing or doesn't match what you saw, trust the watched frames and surface the discrepancy to the user. Common slugs you'll see:formatProfile What it looks like Script template Production template podcast-skit-fabricatedTwo-host skit, themed set, broadcast mics, HER/HIM banter podcast-script(shipped)create-podcast-skit-adimessage-threadiPhone screen, blue/grey bubbles, typing dots, optional VO none — hand-author create-imessage-video-adsingle-host-ugcOne person to camera, selfie register none — hand-author create-podcast-skit-ad's single-host sibling, or per-runmusic-video-sungSung lyrics, on-beat cuts, lyric meter locks scene count none — hand-author per-run animated-explainer-villainAnimated VO, villain/hero arc none — hand-author per-run Fresh mode (no source): ask the user which format they want. Don't pick
podcast-scriptfor them just because it's the only shipped template. Surface the table above as options.Remix mode: the source's
formatProfilewins. If the source isimessage-threadand you scaffold a podcast-script, you are producing the wrong ad. The user is rewriting this source for their brand — they don't want a different format.If
formatProfileis null or unrecognized, ask the user (oneAskUserQuestion): "source format read as<value>; treat it as<best guess from watched frames>, or pick a different one?" Don't guess silently.From here,
<format>refers to the chosen formatProfile. Steps 4–8 branch on it. -
Ask the user clarifying questions before writing.
Batch one
AskUserQuestioncall. The question set is conditioned on<format>(step 3) — do not ask about a themed podcast set when the source is iMessage.Always ask:
- Angle / hook — when
concept-brief.mdlists multiple concepts, surface the top 3 (by claim strength + audience fit) as options with one-line previews; let them pick or paste a different angle. If noconcept-brief.mdexists, ask for the angle in one sentence. - Reference material — "anything I should mirror? (a reference
video URL, a competitor ad, a mood/tone keyword, an existing ad
you liked)". If they paste a URL,
/watchit the same way as the source. - Must-include and must-avoid — anything beyond the brand pack's
what-to-never-say(a specific phrase, competitor jab, stat).
Add at most one format-specific question (skip if the source already answers it):
podcast-skit-fabricated: themed set ("laundromat at 2am, kitchen mid-bake, ...") — keep source's, or swap?imessage-thread: who's texting whom (friend ↔ friend, partner ↔ partner, customer ↔ brand?), iOS bubble palette, optional voiceover y/n, end-card style.single-host-ugc: who's the talent register (founder / unboxing creator / skeptical reviewer), location + wardrobe.music-video-sung: genre / BPM / vocal gender; confirm scene count is locked to source lyric meter.animated-explainer-villain: villain identity (the problem the brand defeats), art register (flat vector, hand-drawn, 3D).
Don't drip questions across turns. Skip any item the brand pack or user's opening message already answered.
- Angle / hook — when
-
Scaffold the script — using the path the format dictates. Two paths only; pick by
<format>, not by what's easy:Path A — template-backed (
<format>has a shippedtemplates/<name>.json). Today that's onlypodcast-skit-fabricated→podcast-script. Run the scaffolder:# fresh python scripts/new_script.py --template <name> --scenes <N> \ --title "<brand> — <hook>" --duration <sec> \ [--set "<themed set>"] # podcast-script only --out ./working/script.json # remix python scripts/new_script.py --template <name> \ --source ./remix/source-sample.json --title "<brand> remix" \ [--scenes <N>] \ --out ./working/script.jsonScene-count rule: start from source count, flex within the template's
_meta.rules.scene_countrange (e.g. 20-24 forpodcast-script) based on the new brand's copy density.Path B — hand-author (every other
<format>—imessage-thread,single-host-ugc,music-video-sung,animated-explainer-villain, anything else). Do NOT runnew_script.py --template podcast-scriptas a fallback. That would change the format and produce the wrong ad.Instead:
- Read the source's actual script shape. It lives in
source-sample.json.extracted_scriptand (if present) the source ad'sproduction/script-drafts.jsonorworking/script.json. - Hand-write
./working/script.jsonin that same shape. Forimessage-thread, scenes are message bubbles ({scene, sender, side: "left"|"right", text, typing_delay, ...}), not HER/HIM lines. Formusic-video-sung, scenes are lyric lines ({scene, bar, lyric, on_screen_text, ...}). Mirror what the source did. - Honor
source-sample.json.formatProfileProperties. IfsceneCount: "locked-to-source"(music-video-sung), mirror 1:1. - You will skip
validate_script.py(step 8) for this path; you'll add avalidationNoteblock instead.
Add a new
templates/<name>.json(with_meta.rules, a scaffolder, and a validator) when the same non-podcast format shows up across 3+ remixes — that's the threshold for "worth templating." Until then, hand-author. - Read the source's actual script shape. It lives in
-
Write every line / bubble / lyric. Fill the scene payload — what that means depends on
<format>.The angle is already chosen (step 4); don't re-litigate it here. Do NOT invent angles unprompted when
concept-brief.mdenumerates them — that's how Hume run-03 ended up choosing between 3 invented angles while 7 pre-derived ones sat unread inclients/Hume-health/concept-brief.md.Cross-format rules (always apply):
- No acronyms anywhere the script gets spoken — spell them out;
TTS / sung vocals read them letter-by-letter. (For purely visual
formats like
imessage-threadwithout VO, acronyms are fine inside bubbles — they're rendered as text, not spoken.) - Apply the brand's guardrails: no competitor names, no
transformation/outcome claims unless allowed (pull these from
brand-summary.md"what to never say"). - Brand surfaces early-mid, payoff lands on an end card / final beat — but how it surfaces is format-dependent (logo bug, mention in line, pinned reply, lyric, etc.).
Format-specific rules (apply only the row that matches
<format>):podcast-skit-fabricated(podcast-scripttemplate):text≤10 words, conversational, intonation marks from the legend (…,—,?,CAPS).caption≤3 words. Alternate HER/HIM strictly. 20-24 scenes. Remix: preserve format + beat arc (hook → mechanism → payoff), not literal scene count.imessage-thread: one bubble per scene. Keep bubbles short (real texts are short — ~3-12 words). Alternateside(left/right) the way the source does. Use typing-dots scenes between sender swaps when the source uses them. On-screen brand surface is a pinned message, reaction, link preview, or in-bubble mention — never a podcast-style end card.single-host-ugc: scenes are{scene, time, line, b_roll}. Lines are first-person ≤14 words, register matches the talent type chosen in step 4. No HER/HIM alternation.music-video-sung: lyrics scan to the source's meter (count syllables, match stress pattern). Technical / acronym / Latinate brand terms (GABA, L-theanine, FDA, GLP-1) go in on-screen hyperframes, never sung lyrics — ElevenLabs Music vocal pronunciation is unreliable on multi-syllable Latinate words. Scene count locked to source.animated-explainer-villain: scenes are VO + visual beats. The villain (the problem the brand defeats) is named within the first 2 scenes; the brand surfaces as the resolver mid-arc.
- No acronyms anywhere the script gets spoken — spell them out;
TTS / sung vocals read them letter-by-letter. (For purely visual
formats like
-
Storyboard preview — show the user what each scene will look like BEFORE handoff. Production renders per-scene imagery differently per format (podcast-skit composites 2 base stills × expression variants; iMessage paints bubbles on an iPhone frame; sung paints on-screen text over B-roll). The user reading a script can't tell what each scene will look like — surprises at render time are expensive. The storyboard is the bridge.
Write
./working/storyboard.json— a structured artifact the consuming app reads to render the storyboard UI. The skill emits data, not presentation; the app owns layout/copy. Shape:{ "formatProfile": "<format>", // so the app picks the right cell renderer "scriptRef": "./script.json", // sibling script the scenes pair with "scenes": [ { "scene": 1, // matches script.json scene number "still": "<one-line visual description for this scene>", "frame": null, // null until step (a)/(b) renders one; then a relative path "note": null // optional: sight gag, brand surface, end-card cue } // ...one entry per script scene, in order ] }What goes in
stillis format-dependent — describe what the eye sees in that scene:podcast-skit-fabricated: "HER curious-questioning look, broadcast mic foreground, leaning toward HIM"imessage-thread: "grey bubble left: '...', timestamp 11:42pm, read-receipt below"single-host-ugc: "talent mid-pour into glass, kitchen daylight, hand-held"music-video-sung: "wide shot, talent on rooftop golden-hour, on-screen lyric burnin centred"animated-explainer-villain: "villain (Brain Fog) shoulders sagging, scribble lines, flat-vector palette"
Rules:
- One entry per
script.jsonscene;scenenumbers must align 1:1. stillis required and free-text. Keep it grounded in the source/watchobservations and the brand'svisual-identity.md.frameisnulluntil a preview is rendered; then a path relative toworking/(e.g../storyboard-frames/T01.png) or an absolute URL the app can fetch.noteis optional; omit /nullwhen there's nothing to say.- Do NOT put stills into
script.json'sstillfield (if it has one — onlypodcast-scriptdoes) — leave that blank for the production template to reassign. The storyboard is a script-stage preview, not a render contract.
Then ask the user with
AskUserQuestion:- "Preview keyframes — (a) all N scenes, (b) hooks only (1, mid, end-card), (c) skip preview"
- For (a) or (b): if image-gen is wired in this run (e.g.
image-generation/create-product-images-nanobananaatom is available), render the requested stills into./working/storyboard-frames/T01.png … TN.pngand write the paths back intostoryboard.json's matchingframefields. Unrendered scenes keepframe: null. If image-gen isn't wired, leave everyframenull and tell the user the app will display text-only stills.
Loop on edits: if the user says "redo scene 7's blocking", update that scene's
still(and the script line if needed), regenerate that one keyframe, rewritestoryboard.json, re-surface. Do not move on until they sign off or explicitly say "skip the preview, ship it". -
Validate — only on Path A (template-backed scaffold from step 5). Skip for Path B (hand-authored); add a
validationNoteinstead (see below).python scripts/validate_script.py --script ./working/script.json [--source ./remix/source-sample.json]Must print
PASS(checks scene count 20-24, strict speaker alternation, ≤10-word lines, ≤3-word captions, no acronyms). In remix mode the source is not enforced as a scene-count constraint — only the template's own range applies.For any source format without a shipped template: the validator's invariants don't apply. Instead, write a top-level
validationNoteblock intoworking/script.jsondocumenting which invariants don't apply and why. The shape is generic — list the entries that fit the format you're working with:"validationNote": { "formatProfile": "<source formatProfile slug>", "templateInvariantsThatDontApply": [ "<which template rule doesn't fit and why — one per line>" ], "reasonValidatorSkipped": "<one-sentence rationale>" }Example items you might include (depending on format): "scene count 20-24 doesn't apply (lyric meter locks to N)", "HER/HIM alternation doesn't apply (single narrator)", "≤10-word lines doesn't apply (sung lyrics use musical meter, not word count)". The next agent (
remix-ad) reads this block to confirm the script is intentionally non-standard, not malformed. -
Hand off — to the production template that matches
<format>, not a default. Use the route table from step 3:<format>Production template podcast-skit-fabricatedcreate-podcast-skit-adimessage-threadcreate-imessage-video-adsingle-host-ugcper-run (no fixed skill) — confirm with user music-video-sungper-run (no fixed skill) — confirm with user animated-explainer-villainper-run (no fixed skill) — confirm with user Pass
working/script.json(+working/storyboard.json+ any renderedstoryboard-frames/) to that production template. The app readsstoryboard.jsondirectly; the production template ignores it (it re-derives stills from the script).
Decision rules
- The template's
_meta.rulesare binding. Don't exceed the word/caption caps or the scene range. - Remix preserves the format + arc, not the scene count — for the
flexible-count formats (
podcast-skit-fabricated,single-host-ugc,animated-explainer-villain). The archetype (two-host skit, single narrator, villain-explainer) and the beat arc (hook → mechanism → payoff) carry from the source; the new ad's scene count flexes with the new brand's copy density. - For
formatProfile: music-video-sung: scene count and per-scene timing are LOCKED to the source — lyric meter sets timing, not copy density. Mirror the source 1:1. The arc isverse → pre-chorus → chorus → outro(or whatever the source declares), nothook → mechanism → payoff. Flexing scene count would desync lyrics from the music. - For
formatProfile: music-video-sung: technical / acronym / Latinate brand terms (GABA, L-theanine, FDA, GLP-1) go in on-screen hyperframes, NEVER sung lyrics. ElevenLabs Music vocal pronunciation is unreliable on multi-syllable Latinate words; lead with the brand-promise / emotional layer in lyrics, deliver technical claims via burned-in type. - Voice is the brand's, not the source's. Match the brand-context pack's voice + never-say list, even when remixing.
- Brand-pack-current-SKU wins over the user's one-liner. If the
user's brief mentions a product variant that the brand pack flags as
EOL / replaced (e.g. user says "can on nightstand" but
01-company-overview.mdsays cans are discontinued), use the current SKU and flag the swap to the user. No question needed unless they explicitly override. - No acronyms in
text— spell out or rephrase. - Lock duration to rendered VO later —
duration_target_secis advisory; the production template re-times after TTS. - One template = one format. Add a new
templates/<name>.jsonrather than overloadingpodcast-script.
Output
./working/script.json— validated, render-ready, in the chosen template's shape. Forpodcast-script:{title, duration_target_sec, aspect_ratio, intonation_legend, voices{HER,HIM}, set_description, scenes[]}../working/storyboard.json— structured per-scene still descriptions- optional
framepaths. The consuming app reads this directly to render the storyboard UI. Shape:{ scriptRef, scenes: [{scene, still, frame, note}] }.
- optional
./working/storyboard-frames/T01.png … TN.png— optional, rendered only when the user opts in at step 7 and an image-gen atom is wired.
Failure modes
- Source has no
recipe.shots→ can't inherit beats; pass--scenes Nand write fresh. - validate_script.py flags an acronym → spell it out or rephrase the line.
- Caption >3 words → it's a caption, not the line; compress to the punch.
- Same speaker twice → intentional double-beats are rare; default to strict alternation.