gooseworks 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,440 +2,307 @@
2
2
  name: goose-video
3
3
  slug: goose-video
4
4
  description: >
5
- GooseWorks video ads — remix a video ad template (iMessage chat-reveal, more coming) into a
6
- branded video ad for the user's product. Renders LOCALLY on the user's machine (Playwright +
7
- ffmpeg + GooseWorks media proxies) and saves the finished MP4 back to the project over MCP.
8
- Use when the user says "make the video for project <id>", "for video batch <id>", references a
9
- video ad project/batch or template, or asks to remix a video ad. Unlike goose-ads (static images,
10
- generated server-side), video renders locally and reports progress + the result back through the
11
- gooseworks MCP tools.
5
+ Order a finished video ad without leaving the chat. Use it when the user says "make me a video
6
+ ad for <brand>", "I want a video ad", or asks for a UGC / iMessage / explainer video. One
7
+ sentence is enough: it picks the brand, asks what the ad is for, suggests formats in a table
8
+ with demo links, asks that format's questions, and shows the script for approval before any
9
+ real spend. It renders on the GooseWorks server, returns the video in the chat, and works in
10
+ hosted connectors too. For an existing app video project or batch, it hands off to
11
+ goose-video-local.
12
12
  category: ads
13
- version: 0.3.0
13
+ version: 1.0.0
14
14
  author: GooseWorks
15
- tags: [gooseworks, ads, video, remix, imessage, local-render, byoa]
15
+ tags: [gooseworks, ads, video, order, server-render]
16
16
  ---
17
17
 
18
- # GooseWorks Video Ads — local remix runtime
19
-
20
- You produce **video** ad creative on the user's OWN machine and sync the result back to the
21
- GooseWorks app over MCP. This document is the **runtime contract** (auth, credits, the media
22
- proxies, data I/O, the review gate). A separate **recipe skill** — fetched per format — tells you
23
- *what to make* (the pieces, prompts, models, order of assembly).
24
-
25
- **Division of authority — read both, but when they disagree THIS doc wins on the environment AND the
26
- review/approval flow.** The recipe governs WHAT to make; this doc governs WHEN you pause, generate,
27
- and spend. In particular: a recipe may spell out a **multi-phase, multi-gate** flow — "generate the
28
- still [GATE] → approve → author the prompt [GATE] → approve → render [GATE] → approve", several
29
- separate pauses. **Do NOT run it that way.** Collapse every one of those gates into the single
30
- **review-once** flow below: one review set, one approval (Step 3). Take the recipe's pieces, prompts
31
- and models; ignore its intermediate pauses. This is the exact contradiction that confused past runs
32
- (GOOSE-2542) — there is no ambiguity: review-once wins.
33
-
34
- You run inside the user's own Claude Code session (they pasted an instruction with a project
35
- id). The app NEVER runs you — it is the viewer + review surface; you are the renderer.
36
-
37
- ## CLI-free environments (cowork / headless)
38
-
39
- You may be running WITHOUT the `gooseworks` CLI binary (e.g. Anthropic cowork). The
40
- `mcp__gooseworks__*` tools work over the MCP connection regardless, so wherever this skill
41
- says to shell out, use the MCP equivalent:
42
-
43
- - `gooseworks fetch <slug>` → the **`fetch_skill`** MCP tool (returns the same content/scripts/
44
- files/dependencySkills). `gooseworks search <q>` → **`search_skills`**.
45
- - `gooseworks credits` → the **`get_ad_credits`** MCP tool.
46
- - `gooseworks doctor` → do the manual toolchain check in the preflight below.
47
-
48
- ## Report problems so we can fix them (telemetry — do this, don't skip it)
49
-
50
- If anything blocks or degrades this run — a media/proxy call fails or errors, a required input or
51
- asset is missing, a recipe instruction is ambiguous or contradictory, the render toolchain won't set
52
- up, or you hit a bug you can't work around — **report it** so the team gets visibility and can fix
53
- the skill. It's fire-and-forget, never counts against you, and never blocks your work.
54
-
55
- - **First, set a stable run id** so every event (yours + the auto-logged media calls) groups together:
56
- `export GW_RUN_ID="vid-<project_or_batch_id>"` (and `export GW_SKILL="<recipe-slug>"`) in the
57
- shell you render from. The media proxies read `GW_RUN_ID` automatically.
58
- - **CLI present →** `gooseworks log "<what happened>" --event-type <type> --level error --details '{"error":"...","step":"...","model":"..."}'`
59
- - **No CLI (cowork / headless) →** the **`log_cli_event`** MCP tool with the same fields (pass `run_id`).
60
- - `--event-type`: `api_failure` (a proxy/model call failed) · `missing_input` · `blocker` ·
61
- `confusion` (unclear/contradictory instruction) · `error` (a bug) · `step`/`info` (progress notes).
62
- - Put the **real error text + the step you were on** in `--details`. Paid FAL/ElevenLabs calls
63
- ALREADY auto-log their own failures, so focus your manual logs on what the proxy can't see:
64
- missing inputs, confusing/contradictory recipe instructions, toolchain/setup failures, and bugs.
65
- - Logging is FOR US — it does not replace telling the user. When a problem blocks the run, still
66
- explain it to the user (and ask if you need a decision); just also `log` it so we can fix the skill.
67
-
68
- ## Prerequisite — MCP + a render toolchain (Phase 0 preflight)
69
-
70
- - The `mcp__gooseworks__*` tools are REQUIRED. If they're unavailable, stop and tell the user
71
- to connect the GooseWorks MCP server (or run `gooseworks install --claude --mcp` on the CLI)
72
- and restart. There is no REST fallback.
73
- - **The render runs wherever THIS agent runs, and it needs a real toolchain: `ffmpeg` +
74
- `ffprobe` + a Playwright **Chromium**.** Establish it in this priority order, and do NOT start
75
- rendering until one is confirmed:
76
- 1. **CLI present →** run `gooseworks doctor` (checks login, MCP, ffmpeg/ffprobe, Playwright
77
- Chromium in one shot). Fix any ✗ with the command it prints, then continue.
78
- 2. **No CLI →** check the toolchain yourself: `ffmpeg -version`, `ffprobe -version`, and a
79
- Playwright Chromium probe (`npx playwright --version` and, if needed, `npx playwright install
80
- chromium`). If all resolve, continue.
81
- 3. **Docker available →** this is the most reliable way to get the toolchain in a sandbox that
82
- lacks it: run the render steps inside the prebuilt image
83
- **`ghcr.io/gooseworks-ai/goose-video-render`** (ffmpeg + ffprobe + Playwright Chromium baked
84
- in), mounting the project working directory. Use Docker whenever the host is missing ffmpeg or
85
- Chromium and `docker` is on PATH. (Note: nested Docker is usually disabled inside managed
86
- sandboxes like cowork — treat this as an option, not a guarantee.)
87
- 4. **None of the above works →** STOP and tell the user plainly, e.g.: *"Video rendering needs
88
- ffmpeg + a Playwright Chromium (or Docker) on the machine running this agent. This environment
89
- doesn't have them and I can't install them here. Options: (a) enable/allow Docker so I can use
90
- the goose-video-render image, (b) install ffmpeg + `npx playwright install chromium`, or
91
- (c) run this skill locally in your own Claude Code where the toolchain is available."* Do not
92
- half-render or fake a result. Static image ads (the `goose-ads` skill) do NOT need any of this
93
- and work anywhere — offer that as the fallback if they just want an ad now.
94
-
95
- ## Identity, token, credits
96
-
97
- - Read `~/.gooseworks/credentials.json` → `api_key` (your agent token), `api_base`, `agent_id`.
98
- Never print the token.
99
- - **CRITICAL — target the org-default Ads agent on EVERY file op.** The app serves project files
100
- (the render-file route) from the org's DEFAULT agent, but MCP file writes default to your
101
- token's pinned agent — which can be a DIFFERENT agent, so a render written with the default
102
- scope is **invisible in the app**. First resolve the Ads agent: `list_accessible_scopes` → the
103
- scope with `is_org_default: true` (the ORG default — NOT the `is_default` / `default_agent_id`
104
- fields, which are the *user's* default agent and are often a DIFFERENT agent). Its `agent_id` is
105
- `ADS_AGENT` (name "Ads agent", slug `org-default`; usually also the `agent_id` in
106
- credentials.json). Then pass `target: { type: "agent", agent_id: ADS_AGENT }` on EVERY
107
- `get_upload_url` / `get_download_url` / `write_file` / `list_directory` / `read_file` — NEVER
108
- omit `target`.
109
- - **CRITICAL — publish under the PROJECT FOLDER, not the workspace root (the #1 "video renders but
110
- is invisible" bug).** `get_upload_url` stores at `<ADS_AGENT>/files/<path>` verbatim, but the
111
- render-file route reads from
112
- `<ADS_AGENT>/files/agent-config/brands/<brand_slug>/projects/<project_id>/<path>`
113
- (see backend `resolveProjectFileKey`). So EVERY publish/preview `path` MUST be prefixed with
114
- `agent-config/brands/<brand_slug>/projects/<project_id>/` — e.g. upload to
115
- `agent-config/brands/<brand_slug>/projects/<project_id>/working/final.mp4`, NEVER bare
116
- `working/final.mp4`. A bare path 404s in the app even though the render row AND a bare-path
117
- `get_download_url` both "succeed" (they resolve the wrong key). The render `output_url` still
118
- stays the project-relative `...render-file?path=working/final.mp4` — the route re-prepends the
119
- prefix itself. Always verify with `get_download_url` on the FULL `agent-config/...` path (must
120
- be non-empty; curl it for HTTP 200) BEFORE marking the render complete.
121
- - Media generation (FAL / ElevenLabs) through the GooseWorks proxies is the **REAL spend** — billed
122
- to the agent per call as you generate (Step 4). `submit_render { kind: "full" }` additionally
123
- debits **1 nominal ad credit when the render ROW is opened** (a bookkeeping fee, NOT the render's
124
- true cost) — so open it only once you actually have a rendered master (Step 4.1/4.2), and never
125
- re-submit on a guess (that double-bills). The final-video QC gate (Step 4.3) then sits between
126
- that master and PINNING it. Call `get_ad_credits` first; the user can check `gooseworks credits`.
127
-
128
- ## Step 0 — project id, or video BATCH id? (fan out before anything else)
129
-
130
- The handoff you were pasted is EITHER a single `project <id>` OR a `video batch <id>`. A batch is
131
- the app's "N concepts" flow: one composer submission fans out into **N independent concept projects**
132
- (the user picked a concept count, default 3), and the app expects EACH to be rendered. **Handle both:**
133
-
134
- - **`project <id>`** → you have one project. Treat it as a batch of one and continue to Step 1.
135
- - **`video batch <id>`** → call `get_ad_video_batch { video_batch_id }`. It returns every child
136
- concept under `projects[]` — each is a normal project with its own `id`, `variant_index`
137
- (Concept 1..N), and its own `creative_brief` (the per-concept angle/hook/offer/message). **You
138
- MUST process every concept, not just the first** — dropping concepts 2..N is the #1 batch bug.
139
-
140
- **Loop shape (one agent, sequential, ONE approval for the whole batch):**
141
- 1. Run **Step 1 + Step 1.5 + Step 2 + Step 3-assemble** for EACH concept project (each has its own
142
- `project_id`, brief, and `working/` folder — never cross-write between concepts).
143
- 2. Mirror EVERY concept's review set (Step 3's `update_ad_project_script` per project), then stop
144
- for **ONE** approval that covers all concepts — show the per-concept credit estimate and the
145
- batch total. Set the batch to `review` (`update_ad_video_batch { status: "review" }`).
146
- 3. On approval, set the batch to `rendering` and run **Step 4 (the expensive render)** for each
147
- concept **sequentially** (finish Concept 1's master before starting Concept 2 — one machine can't
148
- render them in parallel). Deliver each (Step 5). When all concepts are pinned, set the batch to
149
- `complete`.
150
-
151
- If a single concept fails, keep going with the rest, mark that concept blocked, and report which
152
- ones shipped — never abort the whole batch on one bad concept. Everything below (Steps 1–5) is
153
- written per-project; a batch just runs it N times with the shared approval gate above.
154
-
155
- ## Step 1 — resolve the project, source, brand
156
-
157
- 1. `get_ad_project { project_id }` → keep `brand_id`, `source_sample_id`, `name`, `status`, the
158
- **top-level** `app_url` + `brand_url` (returned alongside `project`, NOT inside it — the links
159
- you hand the user for the in-app review in Step 3 and delivery in Step 5), AND the user's
160
- **`creative_brief`**, project **`assets`**, `character_id`, `default_voice_id` — these are the
161
- authoritative inputs the user chose in the composer (see Step 1.5). Do NOT discard them.
162
-
163
- ### Step 1.5 — the project brief is AUTHORITATIVE (honor it; don't re-ask)
164
-
165
- The composer already collected the user's creative direction onto the project. **Read it and treat
166
- it as ground truth — it OVERRIDES the template recipe's defaults, and it REPLACES the clarifying
167
- questions you would otherwise ask.** Only fall back to the recipe default (then, last, to asking)
168
- for a field the brief leaves empty. Map the fields you WILL honor:
169
-
170
- - `creative_brief.productName` / `.offer` / `.angle` → the product, offer/code, and angle. Do
171
- **not** ask "which product / what offer / what angle" if these are set.
172
- - `creative_brief.concept` (on a batch child) → this concept's **`angle` / `hook` / `offer` /
173
- `message` / `note`** — the per-concept differentiator. Honor it verbatim; it's WHY the user asked
174
- for N concepts. `angle: "auto"` or empty means "you choose."
175
- - Project `assets` + `creative_brief.reference_image_urls` → the user's **own reference images**.
176
- Use them as the product/brand refs (alongside the brand kit), don't ignore them for generic recipe
177
- assets.
178
- - `character_id` → the avatar/creator to use. `default_voice_id` → the voice for any VO (put its
179
- NAME in the review `subtitle`). Use these instead of picking your own.
180
- - `creative_brief.ratio` / `.durationSeconds` → target aspect ratio + length. Honor when the
181
- format's render pipeline supports it; if the format physically can't (e.g. a fixed phone-mockup
182
- aspect), keep the format's native value and note the constraint in the review rather than silently
183
- ignoring the request.
184
- - `polish_policy` (`standard` | `extra`) → `extra` means spend the extra pass on QC/polish.
185
- 2. `get_ad_template { template_id: source_sample_id }` → the source video: `media_url`,
186
- `recipe`, `format` (e.g. "imessage"), `extracted_script`, `how_to`, `remix_spec`.
187
- 3. Brand gate: `get_brand_kit { brand_id }`. If `researchStatus` is `complete`, REUSE it —
188
- never re-research. If not, run brand research first (`gooseworks fetch brand-research`,
189
- follow it, then `finalize_brand_research { brand_id }`) before continuing.
190
-
191
- ## Step 2 — read the template's recipe (it carries everything; NO hardcoded format map)
192
-
193
- The ad format is a **template (data) in the ad_sample DB**, not a per-format skill.
194
- `get_ad_template(source_sample_id)` returns the template's `recipe` — a self-contained brief you
195
- read and execute. **Do NOT map `format` to a hardcoded recipe slug** (there is no such table):
196
-
197
- - `recipe.format` — the format label (e.g. `vignette`), for display only.
198
- - `recipe.atoms` — the **capabilities** this template composes (e.g. `create-video-seedance-2-fal`,
199
- `create-image-gpt-image-fal`, `review-ugc-render`, `watch`). `gooseworks fetch <name>` each — they
200
- live in `skills/ads/capabilities/` and are reused across templates (so they cache).
201
- - `recipe.instructions` — the **playbook** to follow: `instructions.inline` prose, or
202
- `instructions.doc_url` (an S3 markdown doc — fetch it).
203
- - `recipe.config` — every param (prompts, layout, timings, palette, model choices).
204
- - `recipe.inputs` — the brand-asset contract (which product / logo / offer this template needs).
205
- - `recipe.assets` — reference material as S3 links (reference render, style guide, example frames) —
206
- fetch as needed.
207
-
208
- Runtime: **read the recipe → `gooseworks fetch` each capability in `recipe.atoms` → follow
209
- `recipe.instructions` with `recipe.config` + the brand's bound `inputs`.** The template IS the recipe;
210
- there is no `format → recipe-slug` table and no per-format skill to fetch.
211
-
212
- Save each fetched capability's scripts + files under `/tmp/gooseworks-scripts/<name>/`. If a capability
213
- is a Node package (a phone-mockup renderer), `npm install` in its folder so its `generate.js` +
214
- Playwright resolve, and point the recorder's `NODE_PATH` at it.
215
-
216
- > **Migration note:** older phone-mockup formats (`imessage` / `chatgpt` / `apple-notes`) whose DB
217
- > recipe does not yet carry `atoms` / `instructions` still hold the legacy `recipe.thread` payload;
218
- > migrate them to this shape (capabilities + instructions in the DB) — do not reintroduce a CLI map.
219
-
220
- ## Step 3 — assemble the review set, then get ONE approval in the app (before the expensive render)
221
-
222
- This is a **review-once** flow: put the whole review set in the app, get ONE approval, then run the
223
- expensive render + any remaining paid work end-to-end. Never spend on the expensive render before
224
- approval, and don't drip pieces out one at a time and re-pause.
225
-
226
- **What goes in the review — show the REAL cheap pieces, PROMPT only the expensive render.** Split
227
- every piece three ways by cost, NOT just "free vs paid":
228
- - **FREE** (an iMessage / Apple-Notes HTML mockup, a text/CTA line — rendered locally, no proxy
229
- call) → generate NOW and mirror the real asset.
230
- - **CHEAP paid** — a single still/image, the creator/avatar frame, the end card, a short voiceover
231
- or music bed (each costs cents → roughly **≤ 100 credits**) → **generate these NOW too** and
232
- mirror the real asset. The few credits buy a real review: the user SEES the actual creator face
233
- and end card and HEARS the VO, instead of judging a prompt. **This OVERRIDES any recipe rule that
234
- says to gate ALL paid calls** — only the expensive render below is gated.
235
- - **EXPENSIVE paid** — the video take / final AI render (hundreds of credits) → do NOT generate.
236
- Put its **exact prompt/spec** (+ ref image URLs) in the tile. This is the ONE thing approved as a
237
- prompt (you can't preview a hundreds-of-credits video for free); it's generated only in Step 4.
238
-
239
- **The expensive render's exact prompt must be in the panel BEFORE you ask for approval** — so a
240
- single "go" runs it (plus any remaining paid work) without re-pausing mid-run.
241
-
242
- **Show every cost in CREDITS, never dollars.** 1 credit = $0.01 and media generations bill at
243
- provider-cost × 1.2, so **credits ≈ round-up(provider-$ × 120)** per generation, plus a flat
244
- **200-credit base per video**. Convert any $ figures to credits and show ONLY credits to the user —
245
- never print a "$…" amount.
246
-
247
- **Never assemble/stitch the finished video for review.** The review is of the individual pieces (or
248
- their prompts) — never a "full cascade" / "approved cut" clip. Building the whole video before
249
- approval defeats the gate (the user opens the review to an already-finished video) and wastes the
250
- render (GOOSE-2542). The full video is assembled ONLY in Step 4, after approval. A `video`
251
- ingredient here is only a genuinely separate SOURCE clip the format needs (e.g. supplied b-roll).
252
-
253
- 1. **Assemble every piece the format needs — not just the script.** Read the recipe for the exact
254
- list. For an iMessage video that's the **script** (bubble thread), the **conversation image(s)**,
255
- and the **end card**; richer templates add a hook frame, background, product shots, music bed, a
256
- creator/avatar, a voiceover… For each piece, decide FREE / CHEAP-paid / EXPENSIVE-paid (above):
257
- - **FREE or CHEAP paid** (≤ ~100 credits — HTML mockups, a still, the creator frame, the end
258
- card, a short VO/music bed) → generate it now and `get_upload_url` the asset to the project
259
- folder `agent-config/brands/<brand_slug>/projects/<project_id>/working/review/<name>` (same
260
- path-prefix rule as final publish — a bare `working/review/<name>` won't render in the panel);
261
- set that piece's `path` in `script_drafts` to the project-relative `working/review/<name>`.
262
- - **EXPENSIVE paid** (the video take / final render, hundreds of credits) → do NOT generate. Put
263
- the **exact prompt/spec** (and any ref image URLs) in the tile's `text` / `subtitle` so the
264
- user reviews what will be spent on. No `path` yet — it's generated in Step 4.
265
- Include the **estimated cost in CREDITS** (never dollars) of the cheap pieces already generated +
266
- the pending render, so the user approves knowing the total spend. **Answer clarifying questions
267
- from the project brief FIRST (Step 1.5)** — only ask the user for a field (angle, which product,
268
- offer/code) the `creative_brief` leaves empty AND the recipe can't default. Do not re-ask for
269
- anything the composer already captured.
270
- 2. **Mirror the whole ingredient set for review** — `update_ad_project_script { project_id,
271
- script_drafts, script }`. `script_drafts` is a structured payload of **container-tagged
272
- ingredients** so the app renders each piece the right way:
273
- `{ format, scenes?, ingredients: [{ container, label, subtitle?, path?, text? }] }`. Each
274
- ingredient's `container` tells the app HOW to show it:
275
- - `image` (a frame shown in the video), `endcard` (the end card), `avatar` (a character
276
- headshot), `background` → rendered as an image tile.
277
- - `voice` (a voiceover clip — put the voice NAME in `subtitle`), `music` (the bed),
278
- `audio` → rendered as an audio player.
279
- - `video` (a clip) → a video player. `text` (a copy line like the CTA) → a text tile.
280
- - `script` / `thread` / `note` / `conversation` → the written script (or set `scenes[]`
281
- for the podcast shape, or pass the readable `script` string).
282
- `path` = `working/review/<name>` (upload the preview asset first via `get_upload_url`); `url`
283
- works too. **Label every ingredient** ("Hook image", "End card", "Voiceover", "Background
284
- music", "HER"). The `update_ad_project_script` call itself writes no render and costs no credits
285
- (the cheap pieces you already generated above have their own small cost) — it just populates the
286
- review panel.
287
- 3. **STOP — the review happens in the APP's review panel, NOT in this chat.** You've mirrored the
288
- ingredients (3.2); now hand the user the project's `app_url` (from `get_ad_project`) and tell
289
- them to review the pieces there and hit **"Approve & render"**. That button gives them a short
290
- message to paste back into this session — THAT is your go-ahead. Do NOT paste the
291
- script/ingredients into the chat for a thumbs-up, and do NOT render until that approval comes
292
- back from the app. If they want changes (via the app's comments or here), regenerate the
293
- affected ingredient, call `update_ad_project_script` again, tell them it's refreshed in the
294
- app, and wait for a fresh approval. Only AFTER the app approval do Step 4. A single approval
295
- authorises the WHOLE remaining chain — generate every paid piece, render, self-QC, publish —
296
- with NO further pauses (that is exactly why every paid prompt must already be in the panel).
297
-
298
- ## Step 4 — render locally, report stages, publish
299
-
300
- 1. Now generate every PAID piece you showed as a prompt in Step 3 — the AI stills/video, voice,
301
- music, the end-card render — through the media proxies (below), each from its approved prompt.
302
- Then assemble per the recipe (Playwright record where needed → ffmpeg stitch → `mix-master`
303
- audio).
304
- 2. Open the row LAST: `submit_render { project_id, kind: "full" }` → keep `render_id`, then
305
- `update_render_status { render_id, status: "running" }`. The render row tracks status only
306
- (queued / running / complete / failed) — narrate fine-grained progress with
307
- `append_project_message` instead.
308
- 3. **MANDATORY final-video QC gate — YOU review EVERY finished master before `set_final_render`,
309
- whatever the format (UGC or not).** This is your own automated quality check, separate from the
310
- user's Step-3 approval — it does not go back to the user. The render row is already open (its
311
- nominal credit spent, `submit_render` in 4.2);
312
- this gate stands between a rendered master and PINNING/publishing it, so a bad render never gets
313
- set as final. A master that looks fine on a still can still have a mis-voiced word, a caption
314
- drifting off its line, a beat out of order, or a deformation — review the actual VIDEO, not
315
- stills. Run the passes that APPLY to this format:
316
- - **Audio ↔ script** — any master with SPEECH (VO or native/Seedance voice); **skip for
317
- music-only / no-speech formats.** `review-ugc-render` is format-agnostic despite the name —
318
- a deterministic Whisper transcript-vs-script diff, not UGC-specific: persist the approved
319
- spoken lines to `working/approved-script.txt`, then `gooseworks fetch review-ugc-render` and
320
- run `review_render.py --video <master>.mp4 --script-file working/approved-script.txt --json
321
- working/review-verdict.json` (exit 0 PASS / 2 FAIL / 3 ERROR). It blocks a mis-voiced word
322
- (approved "human-vetted" → "human witted"), a dropped phrase, or silence. It routes Whisper
323
- through the gooseworks proxy when `OPENAI_BASE_URL` is set; with no backend at all, run
324
- `fal-ai/whisper` via `fal-proxy` (upload the audio, pass its `get_download_url` as `audio_url`)
325
- and diff the transcript yourself.
326
- - **Captions / subtitles** — ANY captioned format (the most common non-UGC defect); **skip for
327
- UGC/Seedance masters, which carry no subtitle track.** Concrete check: diff the caption file
328
- you burned (SRT/ASS) against the SAME Whisper transcript + word timings from the audio pass —
329
- every caption line must match the heard/scripted words and sit within ~0.3s of when they're
330
- spoken; then in the visual pass below, OCR-read the burned caption off 4–5 sampled frames to
331
- confirm it's actually on screen at that time and not colliding with a hyperframe or the end
332
- card. Mismatched text or >0.3s drift fails the gate.
333
- - **Visual + structure** — always: run the `watch` skill on the master — beat/scene order + SFX,
334
- the brand's product (not the source's) is shown, the end card has the real wordmark + code, no
335
- deformation/artifact, duration within ~20% of the source.
336
- If ANY applicable pass fails, FIX it (regenerate/stitch the offending window, rebuild captions)
337
- and re-review — only a clean pass proceeds to `set_final_render`. **This gate is universal: it
338
- runs from the master skill for every format, so a recipe never has to opt in.**
339
- 4. Publish: `get_upload_url { target: { type: "agent", agent_id: ADS_AGENT } }` → PUT the master
340
- and poster **under the project folder** (see Identity's path-prefix rule) — to
341
- `agent-config/brands/<brand_slug>/projects/<project_id>/working/final.mp4` and
342
- `.../working/final-thumb.jpg`. **Always target ADS_AGENT AND use the full project-folder path**
343
- — a bare `working/final.mp4`, even on the right agent, 404s in the app. Verify servable:
344
- `get_download_url { target: ADS_AGENT, path: "agent-config/brands/<brand_slug>/projects/<project_id>/working/final.mp4" }`
345
- must return a non-empty URL (curl it for HTTP 200).
346
- Then `update_render_status { render_id, status: "complete", output_url, thumbnail_url }` where
347
- **output_url MUST be the durable render-file URL**
348
- `/api/ads/projects/<project_id>/render-file?path=working/final.mp4` (the app re-presigns it on
349
- every view) — NEVER a raw proxy/CDN URL (those expire). Same for `thumbnail_url`.
350
- 5. `set_final_render { project_id, render_id }` to pin it, then return the `app_url` +
351
- `brand_url` (from the project/links) verbatim. Never end on just "done" or a file path.
352
-
353
- Narrate each long step in one line via `append_project_message { project_id, role: "agent",
354
- content }` — never sit silent on a queue > 90s.
355
-
356
- ## Media generation — the GooseWorks proxies (queue loop)
357
-
358
- Media APIs go through GooseWorks proxies with your agent token; do NOT use an SDK's default host
359
- (your token isn't a FAL/ElevenLabs token → 401). Base = `<api_base>/api/internal/<proxy>`; pass
360
- `?token=<api_key>&agent_id=<agent_id>&project_id=<project_id>` (agent_id bills the Ads agent;
361
- `project_id` = the id of the project you're rendering — it attributes this generation's credits to
362
- that ad project so the user sees per-project spend in the app. ALWAYS pass it). FAL = `fal-proxy`
363
- (+ `fal-storage-proxy` to host a local image and get a CDN URL); ElevenLabs = `elevenlabs-proxy`
364
- (VO / music bed).
365
-
366
- **FAL queue gotcha** (#1 waste of generations): submit returns `status_url`/`response_url` on
367
- `queue.fal.run` (the real host, not the proxy). Polling those 401s forever — rewrite their host
368
- to the proxy base (keep the path), re-add `?token=&agent_id=`. Only the final `*.fal.media`
369
- image is a real public URL. Helper:
370
-
371
- ```python
372
- import json, os, pathlib, time, requests
373
- from urllib.parse import urlparse
374
-
375
- def _cfg():
376
- c = json.loads(pathlib.Path(os.path.expanduser("~/.gooseworks/credentials.json")).read_text())
377
- return c["api_base"].rstrip("/"), c["api_key"], c.get("agent_id")
378
-
379
- def _params(tok, agent, project_id=None):
380
- p = {"token": tok}
381
- if agent: p["agent_id"] = agent
382
- if project_id: p["project_id"] = project_id # attributes the spend to this ad project
383
- return p
384
-
385
- def fal_generate(model_path, payload, project_id=None, timeout_s=180, poll_s=3):
386
- """model_path e.g. 'fal-ai/nano-banana-2/edit' (the recipe names the model).
387
- Pass project_id = the ad project you're rendering so credits attribute to it.
388
- Returns the result image URL (a public *.fal.media CDN URL)."""
389
- api_base, tok, agent = _cfg()
390
- base = api_base + "/api/internal/fal-proxy"
391
- sub = requests.post(f"{base}/{model_path}", params=_params(tok, agent, project_id), json=payload).json()
392
- to_proxy = lambda u: base + urlparse(u).path
393
- status_url, response_url = to_proxy(sub["status_url"]), to_proxy(sub["response_url"])
394
- deadline = time.time() + timeout_s
395
- while time.time() < deadline:
396
- st = requests.get(status_url, params=_params(tok, agent, project_id)).json()
397
- if st.get("status") == "COMPLETED":
398
- return requests.get(response_url, params=_params(tok, agent, project_id)).json()["images"][0]["url"]
399
- if st.get("status") in ("FAILED", "ERROR"):
400
- raise RuntimeError(f"FAL failed: {st}")
401
- time.sleep(poll_s)
402
- raise TimeoutError("FAL polling exceeded timeout")
403
- ```
404
-
405
- ElevenLabs (VO / music) is the same shape against `<api_base>/api/internal/elevenlabs-proxy`
406
- with `?token=&agent_id=&project_id=`. Feed FAL a local image by storing it (`get_upload_url`) and passing its
407
- `get_download_url` presigned URL as an `image_urls` / `audio_url` entry — this is the reliable
408
- path. (`fal-storage-proxy` may 404 depending on the install; don't block on it — prefer the
409
- `get_download_url` presigned URL.)
410
-
411
- ## Rules
412
-
413
- - **MCP + ffmpeg + Playwright required** — run `gooseworks doctor` in Phase 0; stop with the
414
- exact fix it prints if anything is ✗.
415
- - **Assemble the whole review set first**, mirror it with `update_ad_project_script`, and get the
416
- user's approval **in the app's review panel** (the "Approve & render" button) BEFORE the expensive
417
- render — never ask for a thumbs-up in this chat (review-once, in-app).
418
- - **Show the REAL cheap pieces; PROMPT only the expensive render.** Generate the FREE + CHEAP-paid
419
- pieces (≤ ~100 credits — stills, creator frame, end card, short VO/music) and mirror the real
420
- assets; put ONLY the expensive video take/render in the panel as its exact prompt. That prompt
421
- must be in the panel before you ask to approve, so a single "go" runs the render + any remaining
422
- paid work (→ QC → publish) with no re-pausing.
423
- - **Costs in CREDITS, never dollars.** credits ≈ round-up(provider-$ × 120) per generation + a flat
424
- 200-credit base per video; never show a "$…" figure to the user.
425
- - **Never assemble the full video before approval.** The review shows the
426
- individual PIECES, never the finished cut (or their prompts) — not a
427
- stitched/composited cut; do not add a "full cascade" / finished-video clip
428
- as a review ingredient (GOOSE-2542). The assembled video is produced only in
429
- Step 4.
430
- - **submit_render only after the master is rendered** (Step 4.2), never on a guess; `output_url` =
431
- the durable render-file URL, never a CDN URL.
432
- - **Always pass `project_id` on media-proxy calls** (fal / ElevenLabs) so the credits attribute
433
- to this ad project — that's what lets the user see per-project spend in the app.
434
- - **Verify a real, non-empty MP4** (watch it) before marking the render complete.
435
- - **Reuse the brand** when its research is complete; never re-research.
436
- - On a hard error (auth/quota/model/timeout) set the render `failed` with a short
437
- `error_message` and stop — don't ship the source unchanged. **Also `log` it** (`gooseworks log`
438
- / `log_cli_event`, `--event-type api_failure|error`) so we can see + fix it (see "Report problems").
439
- - **Report blockers/bugs/confusing instructions via telemetry** (`gooseworks log` or the
440
- `log_cli_event` MCP tool) — not just to the user. Set `GW_RUN_ID` once so events group.
441
- - Always end a successful run with `app_url` + `brand_url`, verbatim.
18
+ # GooseWorks Video Ads — order a video in chat
19
+
20
+ ## Purpose
21
+
22
+ Produces one finished vertical video ad, rendered on the GooseWorks server and billed in credits. Examples: an animated iMessage, ChatGPT or Notes thread that ends on the brand's product, or a kinetic-type explainer. The live catalogue decides what can be ordered.
23
+
24
+ **The customer starts with one sentence.** "Make me a video ad for Bioma" is the normal opening, not an edge case. They will not name a format, a tool or a step. Getting from that sentence to a good choice is this skill's job.
25
+
26
+ **The whole job happens in the chat.** The customer is in Claude Code, ChatGPT or a terminal. They will not open a browser to review a script, compare voices or watch a result. Choosing, previewing, approving and receiving all happen here, as text and links they can click. The app is for **payment and nothing else**.
27
+
28
+ **This is not the Video Ads Lab.** The lab is internal, admin-only and free. This spends a customer's money.
29
+
30
+
31
+ ## Route first: is this a new order?
32
+
33
+ This skill **orders a new video**. Hand off to **`goose-video-local`** and stop following this skill when the customer brings any of these:
34
+
35
+ - a **video batch** id ("for video batch <id>");
36
+ - the app's copy-for-Claude command (it names `goose-video-local`);
37
+ - "remix this video ad template" for a specific app template.
38
+
39
+ Those render on the customer's own machine. Use `goose-video-local` if it is installed; otherwise load it with `fetch_skill("goose-video-local")` on the GooseWorks MCP.
40
+
41
+ A bare **project** id ("make the video for project <id>"): call `video_project_read { brand_id, project_id }` first. **Stay here** when it returns an `order`, or the project's `script_drafts.recipe` is set: that is an order made through this skill, so continue it on the same `project_id`. **Anything else** goes to `goose-video-local`.
42
+
43
+ Everything else, including "make me a video ad for <brand>", starts at step 1 below.
44
+
45
+ ## Inputs
46
+
47
+ - A brand, usually named in the opening sentence. Resolved to `brand_id`; with one brand in the org it needs no input.
48
+ - What the ad is for, in the customer's words (optional; asked once, never forced). **It is sent with the order** as `brief.prompt` and reaches the script. It is not only for sorting the table.
49
+ - Anything else they volunteer about the ad: who it's for, names or terms it must say, things to stay away from. These are never asked for; they're kept when the customer offers them.
50
+ - The picked format's own answers (voice, music, angle…). The catalogue says which apply; some formats ask nothing.
51
+
52
+ Never an input: a reference video (these formats don't use one), or a promo code (it comes from the brand).
53
+
54
+ ## Composed Atoms
55
+
56
+ MCP tools; the work happens in the app.
57
+
58
+ - `brand_list`: brand NAME → `brand_id`. Pass `query` when they named one.
59
+ - `brand_create { name, website_url }`: only when the customer asks to add a brand that isn't there. Free.
60
+ - `video_catalog_list { kind: "formats", brand_id }`: what can be ordered. Each format has `card.description`, `card.best_for`, `card.needs`, `ask[]`, `brand_requirements[]`, `default_credits` and `examples[]` (demo videos: a curated sample first, then real runs). The response also carries `brief_fields`: the intent fields every format's `brief` accepts on top of its `ask[]` (`prompt`, `audience`, `must_mention`, `avoid`, `notes`).
61
+ - `video_catalog_list { kind: "voices" }`: voices with playable `preview_url`s, for formats that speak.
62
+ - `video_project_upsert`: the free draft. Returns `project_id`, `quote`, `ready`, `missing` and `questions` (0–2 things worth asking before the script is written; usually none).
63
+ - `video_project_upsert { project_id, patch: { brief } }`: changes the brief, checked the same way as when it was created. A key replaces its value, `null` removes it. Free. Once ordered, only the brief fields change (never the format's own answers), and they reach the script on the next `redraft`. A brief change never moves the quote.
64
+ - `video_render_run { dry_run: true }`: re-reads the price. Free.
65
+ - `video_render_run { kind: "partial" }`: **writes the script and stops.** Reserves the quote and shows the script before the expensive work. Works for every orderable format, chat or voiceover.
66
+ - `video_render_run { kind: "full" }`: finishes an approved preview.
67
+ - `video_render_run { kind: "edit", edit: { script } }`: puts the customer's OWN words into the draft, verbatim, through the format's own guards. Free before approval, and the **only** route that keeps their copy. Offered where the format's `edits.script` is non-empty; refused as `script_not_editable` elsewhere.
68
+ - `video_render_run { kind: "redraft", reason }`: another draft, before approval, written from why they turned this one down, or with no `reason` after a brief change (the change is the reason). It **re-runs the writer** either way, so every word (and the picture on a character format) is replaced — it does not keep copy the customer supplied. Same order, **no new hold**, but **not free**: it adds a few credits of model spend inside the hold already placed. Up to 3, and refused if it would pass the hold. `dry_run: true` says what it would add.
69
+ - `video_project_read { brand_id, project_id }`: status, the drafted script while previewing, and the finished video. Returns an `order` object for recipe projects.
70
+ - `job_cancel { job_id }`: declines a preview. `job_id` is the order id (the project id also works). Releases the whole hold.
71
+
72
+ ## Workflow
73
+
74
+ The opening is fixed: **brand → what it's for → suggested formats → first question.** Do each step without waiting for the customer to ask for it.
75
+
76
+ ### 1. Resolve the brand, quietly when you can
77
+
78
+ Call `brand_list`, with `query` when they named a brand. `query` is a case-insensitive substring match, so "Kolkata Chai" also finds "Kolkata Chai Co". If it finds nothing, call `brand_list` once more with no query before deciding.
79
+
80
+ - **The org has exactly one brand** → use it. Say which in one line ("Making this for **Bioma**.") and move on. Don't ask.
81
+ - **The name they said matches exactly one brand** → use it. Say which.
82
+ - **Several match, or they named none and the org has several** → show a table (name, website) and ask. The wrong brand is a wasted order.
83
+ - **Nothing matches** → say so, list the brands they do have in a table, and offer to add the new one here: "Or send me its website and I'll add it." With a website, call `brand_create { name, website_url }` (free). It starts brand research, which fills in the logo and colours in a few minutes. Say so, then carry on from step 2 while it runs. Before step 5, check `brand_get_context` shows `research_status: complete`; until then drafts are blocked. Adding a brand is not a trip to the app. Never create a brand they didn't ask for, and never guess the website.
84
+
85
+ If the GooseWorks MCP's own instructions have you check onboarding first and it turns out unfinished, finish it, then come back here with the customer's original sentence.
86
+
87
+ ### 2. Ask what the ad is for, in one open question
88
+
89
+ Unless the opening sentence already said it, ask **one** plain question and wait:
90
+
91
+ > What's this ad for? For example: launching something, a sale, explaining how it works, or showing real results. Anything you tell me helps me pick the right format.
92
+
93
+ This is a free-text question: **no menu, no table, no list of formats yet.** Take whatever they say, even "not sure" or "just something good". Never ask it twice, and never block on it: a vague answer is an answer.
94
+
95
+ **Keep the answer. It goes into the order in step 5**, not just into the table's order. Write it down as they said it; that becomes `brief.prompt`. If their words (here or in the opening) also say who the ad is for, a name or term the ad must say, or something to stay away from, note those too:
96
+
97
+ | They said | Goes in | Example |
98
+ |---|---|---|
99
+ | What the ad is for (the whole answer) | `prompt` | "creative is the bottleneck for small teams, make it a problem-solver" |
100
+ | Who it's for | `audience` | "heads of growth at seed-stage startups" |
101
+ | A name or term it must say | `must_mention` (a list) | `["Claude", "ChatGPT"]` |
102
+ | Something to keep out | `avoid` (a list) | `["no villain", "don't talk down to marketers"]` |
103
+
104
+ Never ask for the last three and never fill them with a guess. A `must_mention` term the customer didn't say is a claim we made for them.
105
+
106
+ Skip the question when the opening already names a goal ("…a video ad for our summer sale") or a format ("…an iMessage video ad"). With a format named, go to the table with that format first and marked, then step 4.
107
+
108
+ ### 3. Suggest formats in a table, best fit first
109
+
110
+ `video_catalog_list { kind: "formats", brand_id }`, then **always a markdown table in your message**.
111
+
112
+ Order the rows by how well each format fits their answer. Judge fit from `card.description`, `card.best_for` **and the format's `ask[]` options** against what they said. An option can make a format fit: a cartoon explainer whose `ask[]` offers `arc: hero-helper` fits "a friendly hero mascot, no villain"; one without that option does not, because its card says the character is the problem and loses. **A format whose card contradicts what they asked for is never Suggested**, however well its keywords match. When nothing fits, say so before the table ("None of our formats does X; the closest is Y, which gives up Z") and still show the table. Mark **exactly one** row **Suggested** with a few words on why ("real results → before/after"); a close second can be **Also good**. One suggestion keeps "the suggested one" unambiguous. With no goal given, put the formats that have demos first.
113
+
114
+ | | Format | What it looks like | Price | Demo |
115
+ |---|---|---|---|---|
116
+ | **Suggested** | iMessage chat reveal | Two friends texting; ends on your product | ~60 credits | [watch](https://…) |
117
+ | **Also good** | Apple Notes reveal | A diary-style note typed out; ends on your product | ~15 credits | [watch](https://…) |
118
+ | | Kinetic type explainer | Bold on-brand text timed to a voiceover | ~80 credits | [watch](https://…) |
119
+
120
+ - **Demo** is `examples[].output_url`. When a format has none, write "no demo yet" in the cell; never leave it blank.
121
+ - **"What it looks like" is `card.description`, quoted.** Copy it word for word; you may cut it at a sentence boundary, never re-word it. A paraphrase once turned "narrates how it gets beaten" into "narrates the fix", which made a villain format look right for a no-villain brief. `card.best_for` often carries a dollar figure; never copy it into the table.
122
+ - **When the pick depends on an option, say which.** "Cartoon explainer (as a friendly helper)" in the Format cell, and pre-fill that answer in step 4.
123
+ - **Price** is `default_credits`, written approximately ("~60 credits"). It is a guide for choosing. The price they agree to is the server's quote in step 5.
124
+ - **Say which formats won't take their words.** A format whose `edits.script` is empty writes its own copy and accepts no hand edit; the only lever is a redraft, which rewrites everything. Put "writes its own words" in that row's "What it looks like" cell. A customer who arrives with a script already written needs to know this **before** they pick, not after they hand it over.
125
+ - **Formats the brand may not be able to run** go last, with `card.needs` in plain words, e.g. "needs real before/after photos". Don't hide them; don't suggest them. Judge logo and product photos from the `brand_list` row. For anything else (before/after photos, say) you can't see, treat it as missing rather than make extra calls. `video_project_upsert` returns `ready`/`missing`, which is the real check.
126
+
127
+ **Print the table in your message, THEN ask which one** ("Want the suggested one, or another?"). Never put the formats only inside a structured question control: it renders plain option labels, not links, so the customer would be picking a format they were never able to watch. That happened on the first real run: the picker appeared, the demos didn't, and the customer had to ask where they were. A question control may follow the table to capture the answer; it never replaces it.
128
+
129
+ ### 4. Ask the picked format's questions (only these)
130
+
131
+ Its `ask[]` list is the whole question set. Anything with `source: "recipe"` is the recipe's call, never the customer's. **An empty `ask[]` means no questions**: say so ("This one needs nothing else from you") and go straight to step 5.
132
+
133
+ Ask them all in **one message**, the choice tables first. Put yes/no questions that have a default at the end as a statement they can override ("Music on and a selfie in the thread; say if you want either off"). Every question with an optional answer gets a "leave it to the writer" option.
134
+
135
+ **The rule most easily got wrong:** anything the customer should *see or hear before choosing* goes in a **markdown table in your message**. The structured question control only captures the answer afterwards; it cannot render a link, so a demo or a voice sample placed only in the widget is a choice nobody can actually evaluate. Table first, question second. Yes/no questions need no table, and a choice with no sample (an angle list) is still a table, just without that column.
136
+
137
+ | Angle | The thread | Demo |
138
+ |---|---|---|
139
+ | friend-asks-friend | A friend notices something and asks | [watch](…) |
140
+ | setup-flex | You send a photo, the friend reacts | [watch](…) |
141
+ | swap-moment | You quit something worse for this | [watch](…) |
142
+ | feature-as-punchline | The product's own mechanic is the reveal | [watch](…) |
143
+
144
+ Same for voices: `video_catalog_list { kind: "voices" }` gives name, gender, accent and a `preview_url`. Put the name in a link so it plays. This was the clearest moment of the first real run: a voice table with playable samples. Do the same for any avatar or style choice a format exposes.
145
+
146
+ A choice with an `enum` and no samples (the cartoon explainer's `style`, say) is still a table: one row per value, described in the words of the field's own `description`, with "no demo yet" in the sample column. Don't invent what a style looks like.
147
+
148
+ Never ask for a promo code.
149
+
150
+ ### 5. Draft and price
151
+
152
+ `video_project_upsert { brand_id, name, format, brief: { …answers, prompt, audience?, must_mention?, avoid? } }` is free. `brief` holds the picked format's `ask[]` answers **plus** the step-2 intent. Leave out any intent field the customer never gave. If `ready` is false, **stop** and relay `missing` in plain words.
153
+
154
+ The server refuses any other key with `invalid_brief` and names every key it accepts. Fix the brief from that message; never drop the customer's intent to make the error go away.
155
+
156
+ **If `questions` is not empty, ask them now, in one message, before showing the price.** Each one says which brief field its answer fills (`fills`). Save the answers with `video_project_upsert { project_id, patch: { brief: { <fills>: <their answer> } } }`. The bounds:
157
+ - **One round.** Never ask a follow-up, and never ask the same thing twice.
158
+ - **Never required.** If they skip them or say "just make it", carry on with the draft as it is.
159
+ - **Empty `questions` means ask nothing.** The server skips it when the brief already says what the ad is for and who it's for, and never asks what the brand record already knows. "Make me a video ad for X" stays a complete request.
160
+
161
+ Show the price the draft returned (the `quote`), **never a number from this page or the catalogue**. If it differs from the table's "~N", the quote is right. Convert at **100 credits = $1**.
162
+
163
+ ### 6. Write the script and show it, before the expensive work
164
+
165
+ `video_render_run { brand_id, project_id, kind: "partial" }`.
166
+
167
+ This reserves the credits and writes the script only: **no video is rendered.** It returns immediately with `status: "previewing"` and no script yet; the writing happens in the background. Poll `video_project_read` every 20 seconds (it lands in about 40) and read `order.preview`. Calling `kind: "full"` while it is still `previewing` is refused with `preview_in_progress`.
168
+
169
+ `order.preview` is shaped by the format, and **every** orderable format has one. A preview was chat-only until 2026-09-25, and a kinetic-type customer approved a price and first saw the copy in the finished video.
170
+
171
+ **A chat format** (iMessage / ChatGPT / Notes) fills `thread` (the message list), `angle`, `cta_text` and `selfie_url`. Show it as a readable transcript, not JSON:
172
+
173
+ > **them:** wait why do you look so into your phone rn 😭
174
+ > **me:** I literally just chose to betray the duke
175
+ > **them:** omg is this a game or a book
176
+
177
+ **`selfie_url` is a generated face — show it as a link.** These formats draw the selfie INSIDE the script step, so it never appears in `anchor_images` and `stage` stays `"script"`. It is still a face that will be in the ad, so it still needs their yes.
178
+
179
+ **Any preview with `order.preview.stage: "image"`** stopped after the **picture**, not just the words, so they judge the face before paying for the video. Go by that field, never by a list of format names — it follows what the recipe's steps produce, so a format joins this branch without a skill edit. Today: the animated character, the reaction selfie, the podcast hosts, the UGC creator, and the voiceless dance story (four to six stills, and no spoken script at all — its stills and `detail` ARE the draft, so say that rather than reporting an empty script).
180
+
181
+ Show every URL in `order.preview.anchor_images` as a link (the podcast format has two, one per host), alongside the script. Then say plainly, in this order: what they are looking at; that **nothing has been rendered yet and the pause is deliberate**, because the video is generated FROM this picture and changing the face now is cheap; that **approving starts the render and commits the credits already held**; and that cancelling instead releases the whole hold, so the picture costs them nothing. A pause with no reason given reads as a broken order.
182
+
183
+ **A voiceover format** (kinetic type) fills `beats` instead, and `thread` is null. Each beat has `vo_lines` (what is spoken) and a `beat` label. `detail.hyperframe.plan.slates[].props` holds the words that go **on screen**, which for this format is most of the ad. Show both columns, in order:
184
+
185
+ | # | On screen | Voiceover |
186
+ |---|---|---|
187
+ | 1 | Sunscreen that pills | Most sunscreens pill under makeup. |
188
+ | 2 | Myth: all SPF is greasy → Fact: not a fluid one | This one is a fluid. It sinks in. |
189
+
190
+ Read the spoken lines out as one paragraph underneath, so they can hear the pacing.
191
+
192
+ Whatever the format, **quote it back verbatim.** Do not paraphrase, tidy or shorten the copy; they are approving the exact words that will be rendered.
193
+
194
+ If `order.preview.brief_check` is present, read it before asking for approval:
195
+ - `missing_mentions`: terms they asked for that the script never says. Tell them which, plainly ("It doesn't mention ChatGPT yet").
196
+ - `endorsement_flags`: lines that make another brand sound like it endorses, partners with or ranks theirs. Show the line and say it has to change: a listed name may appear as "works with", never as "recommends" or "#1 for".
197
+
198
+ If `order.brief_changed_since_draft` is present, the brief was changed after this draft was written. **Approving renders the draft as shown**, so redraft first (no reason needed), or tell them the change won't be in this video.
199
+
200
+ Then ask: **use this, change it, or stop?**
201
+
202
+ **One question decides how to change it: did they give you the actual WORDS, or did they tell you what is wrong?** Only the first route below keeps their words. The other two write new ones — which is right when the customer wants something different, and is a silent rewrite when they wanted what they wrote.
203
+
204
+ - **They gave you the words** ("use these lines", "the end card must say Meet Goose", "keep this but change the second bubble") → `video_render_run { kind: "edit", edit: { script: { … } } }`. **This is the only route that keeps copy verbatim.** It puts their text through the format's own guards and costs nothing extra before approval. Send the shape the preview handed you: `script.thread` for a chat format, `script.beats[i].vo_lines` (one entry per beat, `{}` for a beat you are not changing) for a voiceover format, `script.slates[{ beat_idx, props }]` for on-screen words, `script.cta_text` for the end card, `ingredient: "character"` + `script.character.description` for the character itself.
205
+ - **An edit before approval IS the approval**: it applies the copy and starts the render at once (status → `running`). So show the exact words you are about to send, get a yes, and only then send it. An unapproved fix stays a proposal in the chat.
206
+ - Refused with `script_not_editable` → this format writes its own copy and takes no hand edit. **Say so, and do not paraphrase their lines into a redraft** — that hands them a video that is not what they wrote. Their real options are a redraft (new words, not theirs), a re-order with different answers, or cancel.
207
+ - Refused with `script_rejected` → a line makes a claim the brand's facts don't support. Relay the reason and offer a rewrite; never argue it through.
208
+ - **They changed the BRIEF, not the copy** — the problem it shows, who it's for, a name to say, something to avoid, a note like "keep it dry" → save it with `video_project_upsert { project_id, patch: { brief } }` (use `notes` for anything that isn't one of the other fields; keep their original `prompt`), then `video_render_run { kind: "redraft" }` with no `reason`. The new draft is written from the updated brief, so **the words will be new** — this changes the instructions, not the script. Same cost wording as below: no new order, no new hold, a few credits inside the hold.
209
+ - **They only said what's wrong** ("too salesy", "the character looks like a mug") → ask **why** in one line, then `video_render_run { kind: "redraft", reason: <their words> }`. **A redraft RE-RUNS the writer: every word, and the picture on a character format, is replaced.** Their reason steers the next draft; it is not copied into it. Never put exact lines in `reason` expecting them back. Say what it costs, precisely: "no new order and no new hold; it adds about N credits to what this video costs, inside the hold" (N from the response's `redraft.credits_estimate`). **Never call it free.** Poll `video_project_read` until `preview` again and show the new draft plus `order.spend` (what each draft added, how much of the hold is left). A `redraft_exceeds_hold` or `redraft_limit` refusal means: approve a draft or cancel.
210
+ - Different answers altogether (another format, another angle) → a fresh project, and cancel this one.
211
+ - Stop → `job_cancel { job_id: <order id> }`. The whole hold is released and they pay nothing. Cancel only works while the order is `queued`, `previewing` or `preview`; once they approve and it is `running`, it is being made and cannot be refunded.
212
+
213
+ This is the review. It happens here, not in the app.
214
+
215
+ **If the server answers `preview_not_available`,** that is a bug: every sold format has a script preview. Say plainly "I can't show you the script for this one before it's made", and stop. Do not quietly skip the step and charge as if the gate had passed.
216
+
217
+ ### 7. Finish it
218
+
219
+ Only after they approve the script: `video_render_run { brand_id, project_id, kind: "full" }`.
220
+
221
+ Poll `video_project_read { brand_id, project_id }` **every 30 seconds.** Done means `order.status` is `done`; the same response then carries `video_url` (the project page) and `mp4_url` (the file).
222
+
223
+ **How long depends on the format.** One number is wrong by 2x across the catalogue, and a customer told "about 5 minutes" at minute 9 thinks it has failed:
224
+
225
+ | Format | Typical | Give up after | Why |
226
+ |---|---|---|---|
227
+ | iMessage / ChatGPT / Notes chat reveal | ~5 min | 10 min | No video is generated; a browser renders the phone UI, then ffmpeg cuts it |
228
+ | Kinetic type explainer | **~10 min** | 20 min | Two AI b-roll clips, a voiceover, a music bed, HTML slides and a stitch |
229
+
230
+ An unlisted or new format: assume the longer budget. Say the number you are working to up front ("this takes about ten minutes; I'll keep checking"), and say something at the halfway mark rather than going silent. Waiting is not failing; silence feels like it.
231
+
232
+ ### 8. Deliver in the chat
233
+
234
+ **Lead with `video_url`** — the project page, where the video plays and they can come back to it. Give `mp4_url` second and name it as the file ("and the raw MP4, if you want to download or upload it"). Never hand over the CloudFront `.mp4` on its own: it is a file, not a place — nothing to return to, nothing to edit from, and it reads like a debug artifact rather than a delivery. If `video_url` is missing, poll once more; never substitute the mp4 for it silently.
235
+
236
+ Say what the video is: length, ratio, what's in it. This is delivery **in the chat** — the link is the video, not an instruction to go to the app. If they want a change, offer to make another with different answers. That is a new order and a new charge; say so.
237
+
238
+ The only reason to send someone to the app is **payment**: not enough credits, or a plan without video.
239
+
240
+ ## Decision Rules
241
+
242
+ - **One sentence is a complete request.** Never answer "make me a video ad for X" by asking which format, which tool or what to do next. Run steps 1–3 and let the table do the asking.
243
+ - **One brand in the org → never ask which brand.** State the one you used.
244
+ - **Open question first, table second.** Don't lead with the whole catalogue. The goal question comes before any list, and the table comes ordered, with one suggestion.
245
+ - **No approval of the SCRIPT → do not call `kind: "full"`.** The price gate is not the script gate. They approve a number in step 5 and the actual ad in step 6.
246
+ - **More than two options → table in the message.** Every time. Options with a sample or demo the customer can't hear or see are not really choices. A question control may capture the answer after the table, never instead of it.
247
+ - **`ready: false` → stop.** Ordering anyway fails and wastes their time.
248
+ - **Unsure whether an order went through → read `video_project_read` first.** Never use the charging tool as a status probe. Only if the project shows nothing at all, re-call `video_render_run` on the SAME `project_id` (idempotent per project). Never create a second project; that is a second charge.
249
+ - **Quote cards, never paraphrase them.** The card is the server's promise about the format. A reworded card can promise something the format cannot do.
250
+ - **A contradiction with the card outranks every keyword match.** Check what they asked for against what the card and the `ask[]` options say the format does; a format that can't do the ask is never Suggested.
251
+ - **They asked for a format that isn't in the catalogue** → it isn't available yet. Say so, show the table, and don't improvise a lab recipe.
252
+ - **Run failed → say so plainly.** The credits are released automatically. Offer a retry; never retry unasked.
253
+ - **Their words go in an `edit`, never in a `redraft` reason.** A `reason` steers the next draft; it is not copied into it. The moment a customer gives you actual copy, the only honest routes are `kind: "edit"` or telling them this format will not take it.
254
+ - **Deliver the page, not the file.** `video_url` leads, `mp4_url` follows. A bare CloudFront link is not a delivery.
255
+ - **The brief is the customer's words, not yours.** `prompt` is their answer, verbatim or close to it. `audience`, `must_mention` and `avoid` hold only what they said. Another brand in `must_mention` means they asked for it; it can be named as "works with", never as an endorsement or ranking.
256
+
257
+ ## Output
258
+
259
+ The finished video in the chat: `video_url` (the project page) first, `mp4_url` (the file) second, with its length and ratio.
260
+
261
+ The credits leave the balance when the order is placed (a hold). They are **captured** on success or **released** on failure or cancellation. A balance that dropped does not prove a charge, so don't describe it to the customer that way.
262
+
263
+ ## Quality Checks
264
+
265
+ - A one-sentence opening got: the brand resolved (unasked when there is one), one open goal question, then a format table with demo links and one suggestion.
266
+ - The table was ordered by the customer's goal, not the catalogue's order.
267
+ - Every "What it looks like" cell is the card's own words. No Suggested format's card contradicts what the customer asked for; when nothing fit, you said so and named the closest with its trade-off.
268
+ - Every multi-option question was a table in the message, with a demo or sample column wherever one exists.
269
+ - Only the picked format's `ask[]` questions were asked.
270
+ - The step-2 answer went into the order as `brief.prompt`, and no intent field held anything the customer didn't say.
271
+ - They approved the **script**, not just the price, before the expensive work ran: the thread for a chat format, the on-screen words *and* the voiceover for a kinetic-type one.
272
+ - You told them how long the render would take **for the format they picked**, and said something at the halfway mark instead of going quiet.
273
+ - The quote you showed came from the server, never from memory or the catalogue.
274
+ - Every line of copy the customer wrote is in the finished video word for word — it went through `kind: "edit"`, or you told them plainly that this format would not take it. No supplied copy was ever paraphrased into a `redraft` reason.
275
+ - The video was delivered in the chat as `video_url` first and `mp4_url` second. You did not send them to the app for anything but payment.
276
+ - You asked for no promo code or reference video, and made no product claim the brand's own facts don't support.
277
+
278
+ ## Failure Modes
279
+
280
+ | Symptom | Cause | Fix |
281
+ | --- | --- | --- |
282
+ | "Make me a video ad for X" got back "which format / what would you like?" | Treated the one-liner as incomplete | Run steps 1–3 unprompted: resolve the brand, ask the goal, show the table |
283
+ | Asked which brand in a one-brand org | Skipped the count check in step 1 | Use the only brand and say which |
284
+ | Opened with the full format list | Skipped the goal question | Ask what the ad is for first; order the table by the answer |
285
+ | Demo links invisible to the customer | The choices went only into the structured question control | Table in the message first; the control only takes the answer |
286
+ | `format_not_available` | Not an orderable format | Re-read the catalogue; offer what's in it |
287
+ | `brand_not_ready` | Brand has no product photos or logo | Relay `missing` in plain words; stop |
288
+ | `invalid_brief` | The brief carried a key this format doesn't accept (e.g. another format's question, or a made-up field like `tone`) | The message lists what is accepted. Move the customer's words into `prompt`/`audience`/`must_mention`/`avoid`; never drop them |
289
+ | The script ignores what they asked for | The step-2 answer wasn't sent (it only sorted the table), or they added something later that never reached the brief | Send it as `brief.prompt` in step 5. Anything they add later goes in with `patch.brief` (`notes`) and then a redraft. The preview's `brief_check` shows missing must-mention terms |
290
+ | Video not on this plan | Lite and trial have no video entitlement | Say so and point at the upgrade; this is the one app trip that's allowed |
291
+ | Insufficient credits | Wallet short | Nothing was created or charged; report the shortfall |
292
+ | Preview looks wrong | The script or the picture isn't what they wanted | They gave you the words → `kind: "edit"`. They changed the brief → `patch.brief` then a redraft with no reason. They only said what's wrong → `kind: "redraft"` with their reason. Same order, no new hold, a few credits inside it; never "free". Or `job_cancel` this one |
293
+ | **The customer wrote the script and the finished video says something else** | Their lines went into a `redraft` `reason`. A redraft re-runs the writer: the reason steers the next draft, it is never copied into it. Seen on staging — "Keep this exact draft, use these lines: …" came back paraphrased, twice, and was paid for both times | Copy goes in `kind: "edit"`. If the format refuses it (`script_not_editable`), say so and let them choose a redraft, a re-order or cancel — knowing the words will be new |
294
+ | Video started rendering before the customer approved | `kind: "edit"` on a preview applies the copy **and** starts the full render (status → `running`) | Show the exact words you're about to send, get a yes, then send the edit. An unapproved fix stays a proposal in the chat |
295
+ | `draft_is_your_copy` on a redraft | The current draft is the customer's own edited copy; another draft would replace their words | Correct behaviour. Edit their copy again with `kind: "edit"`, approve it, or cancel |
296
+ | `script_rejected` on an edit | A line makes a claim the brand's facts don't support | Relay the reason verbatim and offer a rewrite; nothing was charged |
297
+ | The customer was sent a raw `cloudfront.net/….mp4` | Delivered `mp4_url` (or `order.output_url`) instead of `video_url` | `video_url` leads and `mp4_url` follows. Both come back from `video_project_read` once `order.status` is `done` |
298
+ | `recipe_brief_locked` | You patched `script_drafts` raw on a format project, which would have skipped the brief's checks | Send the change as `patch.brief` instead |
299
+ | `format_answers_locked` | After ordering, you tried to change the format's own answer (voice, angle, selfie) through the brief | Those change what the run costs. Use `kind: "edit"` where the format lists it, or start a new project |
300
+ | Asked the customer three rounds of questions before anything was made | Treated `questions` as a form, or asked your own follow-ups | Ask what `questions` holds, once, in one message. Nothing else. Skipping is fine |
301
+ | `preview_in_progress` | You called `full` while the script was still being written | Keep polling `video_project_read` until `order.preview` appears |
302
+ | Cancel refused | The order is already `running`; they approved it | Say it's being made; a refund isn't possible now |
303
+ | No render after the format's budget (10 min chat, 20 min kinetic) | The worker didn't pick the run up | Credits are held, not spent. Say it's queued and you'll follow up; don't re-order |
304
+ | `preview_not_available` on an orderable format | A bug; every sold format has a script preview | Say so and stop. Do not order without showing the script |
305
+ | An edited script is refused (`script_not_editable`) | This format's writer takes no hand edit (its `edits.script` is empty). Only some formats do | Should have been said at the table. Their words cannot be rendered verbatim here: offer a redraft (new words), a re-order with different answers, or cancel — and never quietly paraphrase their copy into the redraft reason |
306
+ | A "hero, no villain" brief got the villain cartoon | The card was paraphrased ("narrates the fix") and the fit check never read what the format can't do | Quote `card.description`; check the ask against the card and `ask[]`; never Suggest a contradiction |
307
+ | Two videos, two charges | A second project was created instead of continuing the first | Continue on the SAME `project_id` |
308
+