No API keys, no heavy deps — the agent writes the copy; the scripts only scaffold and validate.
-
Watch the source FIRST (remix mode).
In remix mode you are rewriting somebody's ad. Don't trust
extracted_script alone — 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 /watch skill on the
source video before writing any lines:
jq -r .media_url ./remix/source-sample.json
Read the frames /watch returns. 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/ and clients/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 in
clients/<slug>/brand-research/. If clients/<slug>/concept-brief.md
exists, 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 the
brand-research skill against the same --brand-dir. It is
step-idempotent: every phase that's already complete is skipped, and
only the missing pieces run. When verify_pack.py finally returns
PASS, 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.md voice + "what to never say",
audience.md verbatim phrasing, claim limits, visual-identity.md
palette for end-card decisions). In remix mode, also read
source-sample.json — note its recipe beats, extracted_script (the
skeleton), kept voices, how_to gotchas, and formatProfile (the
field that drives which path you take below).
-
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 /watch returned 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-fabricated | Two-host skit, themed set, broadcast mics, HER/HIM banter | podcast-script (shipped) | create-podcast-skit-ad |
imessage-thread | iPhone screen, blue/grey bubbles, typing dots, optional VO | none — hand-author | create-imessage-video-ad |
single-host-ugc | One person to camera, selfie register | none — hand-author | create-podcast-skit-ad's single-host sibling, or per-run |
music-video-sung | Sung lyrics, on-beat cuts, lyric meter locks scene count | none — hand-author | per-run |
animated-explainer-villain | Animated VO, villain/hero arc | none — hand-author | per-run |
Fresh mode (no source): ask the user which format they want.
Don't pick podcast-script for them just because it's the only
shipped template. Surface the table above as options.
Remix mode: the source's formatProfile wins. If the source is
imessage-thread and 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 formatProfile is null or unrecognized, ask the user (one
AskUserQuestion): "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 AskUserQuestion call. 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.md lists 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 no concept-brief.md exists, 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,
/watch it 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.
-
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 shipped
templates/<name>.json). Today that's only podcast-skit-fabricated
→ podcast-script. Run the scaffolder:
python scripts/new_script.py --template <name> --scenes <N> \
--title "<brand> — <hook>" --duration <sec> \
[--set "<themed set>"]
--out ./working/script.json
python scripts/new_script.py --template <name> \
--source ./remix/source-sample.json --title "<brand> remix" \
[--scenes <N>] \
--out ./working/script.json
Scene-count rule: start from source count, flex within the
template's _meta.rules.scene_count range (e.g. 20-24 for
podcast-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 run
new_script.py --template podcast-script as 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_script and (if present) the source
ad's production/script-drafts.json or working/script.json.
- Hand-write
./working/script.json in that same shape. For
imessage-thread, scenes are message bubbles
({scene, sender, side: "left"|"right", text, typing_delay, ...}),
not HER/HIM lines. For music-video-sung, scenes are lyric lines
({scene, bar, lyric, on_screen_text, ...}). Mirror what the
source did.
- Honor
source-sample.json.formatProfileProperties. If
sceneCount: "locked-to-source" (music-video-sung), mirror 1:1.
- You will skip
validate_script.py (step 8) for this path; you'll
add a validationNote block 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.
-
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.md enumerates them
— that's how Hume run-03 ended up choosing between 3 invented angles
while 7 pre-derived ones sat unread in
clients/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-thread without 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-script template):
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). Alternate side
(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.
-
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>",
"scriptRef": "./script.json",
"scenes": [
{
"scene": 1,
"still": "<one-line visual description for this scene>",
"frame": null,
"note": null
}
]
}
What goes in still is 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.json scene; scene numbers must align 1:1.
still is required and free-text. Keep it grounded in the source
/watch observations and the brand's visual-identity.md.
frame is null until a preview is rendered; then a path
relative to working/ (e.g. ./storyboard-frames/T01.png) or an
absolute URL the app can fetch.
note is optional; omit / null when there's nothing to say.
- Do NOT put stills into
script.json's still field (if it has
one — only podcast-script does) — 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-nanobanana atom is
available), render the requested stills into
./working/storyboard-frames/T01.png … TN.png and write the
paths back into storyboard.json's matching frame fields.
Unrendered scenes keep frame: null. If image-gen isn't wired,
leave every frame null 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, rewrite storyboard.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 validationNote instead
(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 validationNote
block into working/script.json documenting 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-fabricated | create-podcast-skit-ad |
imessage-thread | create-imessage-video-ad |
single-host-ugc | per-run (no fixed skill) — confirm with user |
music-video-sung | per-run (no fixed skill) — confirm with user |
animated-explainer-villain | per-run (no fixed skill) — confirm with user |
Pass working/script.json (+ working/storyboard.json + any
rendered storyboard-frames/) to that production template. The app
reads storyboard.json directly; the production template ignores
it (it re-derives stills from the script).