Turn a brand URL + product URL into on-brand static ads (TOFU/MOFU/BOFU, 4 sizes) with the real product photo, an AI critic and Meta/Google compliance checks. Convex + React.
Обзор
This document uses ASD-STE100 Simplified Technical English. This is an internal tool. It makes a locked brand kit from a brand URL. Then it makes four 9:16 static ads from a product URL with the a11 Studio recipe. The image model paints the headline. You approve each headline before the tool renders. Then you fix text, blend the real packshot and mark the image that you use. The tool uses Convex + React. https://glad-dodo-78.convex.site (allow-listed accounts only) · automatic when a PR merges to main Paste the URL of a brand website. The tool gives you a locked brand kit. Paste a product URL. Start a batch. The tool gives you a maximum of four draft ads. The approval step always has four concepts: when compliance or the copy rules reject a written concept for good, a code-built product-as-subject concept takes its slot: - The run config is fixed: look bold (Bold colour studio), engines Original, Original, Wild, Wild, funnel jobs ToF pain, MoF mechanism, MoF proof, BoF objection.
README
AI Ad Generator (internal MVP)
This document uses ASD-STE100 Simplified Technical English.
TL;DR: This is an internal tool. It makes a locked brand kit from a brand URL. Then it makes four 9:16 static ads from a product URL with the a11 Studio Bold colour studio recipe. The image model paints the headline. You approve each headline before the tool renders. Then you fix text, blend the real packshot and mark the image that you use. The tool uses Convex + React.
Live: https://glad-dodo-78.convex.site (allow-listed accounts only) · Deploys: automatic when a PR merges to
main
Paste the URL of a brand website. The tool gives you a locked brand kit. Paste a product URL. Start a batch. The tool gives you a maximum of four draft ads. The approval step always has four concepts: when compliance or the copy rules reject a written concept for good, a code-built product-as-subject concept takes its slot:
- The run config is fixed: look
bold(Bold colour studio), engines Original, Original, Wild, Wild, funnel jobs ToF pain, MoF mechanism, MoF proof, BoF objection. - Each ad is 9:16 (1152×2048). The tool composes it for the 4:5 feed area.
- GPT Image 2.5 Sunburst paints the headline, the sticker and the scene text (
ai_painted). The image has no CTA. The CTA is the Meta button. - The market is Singapore by default (S$, Singapore English). You can change it in Settings → Pipeline.
The source of the recipe is docs/a11-bold-spec.md (the a11 Studio spec). The prompts in the code are the verbatim text of that spec, with the deviations that the note at the top of the spec lists (safe zone, prop limit, palette size, no CTA). docs/ad-research.md gives the research rules that the tool also uses.
Stack: Vite + React + TypeScript SPA · Convex (DB, auth, storage, scheduled actions, live queries) ·
Convex static hosting (.convex.site) · Convex Auth (email + password, allow-listed emails) ·
Tailwind v4 + shadcn/ui + Fluid Functionalism (@fluid registry) ·
Firecrawl (scrape + search) · OpenRouter (anthropic/claude-haiku-5.5, strict JSON schema when the provider supports it, else JSON-object mode) · Replicate (GPT Image 2.5 Sunburst, background remover) · jimp + resvg (crops and composites in a Convex Node action).
The tool has no payments and no public links. Each allow-listed user who is signed in can open all workspaces. That user can also write comments.
How it works
Rule of the owner: an LLM does every step that an LLM can do. The only required human step is the approval stop: read and choose the headlines and stickers, approve, then click Render. After that, everything is automatic. You click Mark used on the image that you spend on. The manual text-fix, blend and regenerate are optional advanced tools under More.
A batch is one run of the recipe. It goes through these steps (convex/pipeline.ts). Each automatic step is a scheduled action. The batch view shows the current step.
| Step | Where | What occurs |
|---|---|---|
| Brand extraction | convex/brandExtract.ts |
Round 1: Firecrawl reads the homepage with branding + markdown + images. Round 2 occurs if the logo, colours, fonts or images are missing. In this round, /map finds the about, press and brand-kit pages, and Firecrawl scrapes a maximum of 3. Round 3: a screenshot goes to a vision LLM. Then one LLM pass sets the palette roles, the fonts, a photo style and a voice profile. The site images for this pass go in as data URIs. The tool downloads each image first (public hosts only, each redirect checked, 6 at a time, with one 20 s limit for all) and sends only JPEG, PNG or WebP files of 3.5 MB or less. The call with images has one try. If it fails, the tool does the pass without images (with the normal retries). The tool saves the result as a brand kit version and keeps the last 5. If the extraction fails before a brand kit exists, the error banner has a Retry extraction button. |
| 1. Intake | convex/productIngest.ts |
For a Shopify product page, the tool reads the /products/{handle}.json gallery first. Firecrawl reads the page (markdown, images, branding for the logo and fonts, product). The tool keeps the title, description, vendor, body text (12,000 characters by default; Settings → Pipeline allows up to 30,000), product photos, logo candidates and font families. If the page is blocked, the tool waits 45 s and tries again. The second try accepts the cached copy of Firecrawl. The product shows “cached copy” only when Firecrawl reports a cache hit. A new read keeps the old images and data that the new read does not return. You cannot read the page again while a batch for the product runs. The tool reads only public web addresses (no localhost or private IP addresses). Before a direct fetch (the Shopify JSON and each product image), the tool resolves the host name and refuses a private address. It follows image redirects by hand and checks each hop. |
| 2. Research | pipeline.research |
Firecrawl search (v2 /search, country = market code) does 3 searches. The brand domain comes from the brand kit URL first, then from the product URL. Search 1 uses includeDomains = the brand domain. Searches 2 and 3 use excludeDomains = the brand domain. Each finding keeps its URL. Research plan (1 small LLM call, researchPlan): before the searches, the LLM names the product category and writes keyword queries for search 2 (buyer voices: reviews, Reddit, forums) and search 3 (alternatives). Code removes quotes, operators and the brand and product names, and keeps at most 12 words (convex/lib/research.ts). If the call fails or a query has fewer than 3 words, the tool uses the templates in Settings. The old sentence templates with the product name found 0 of 8 relevant pages for each Moom product; the keyword queries found 17 of 18 relevant pages on the same products (measured 2026-10-15). A retry of the step uses the saved queries: searches that worked are not paid again; only a search that failed runs again. The brief asks for buyer phrases that show a reservation, a workaround or the moment the problem hits, not generic praise. |
| 3. Brief | pipeline.research |
The LLM writes the brief (thinking effort medium). The brief also has an offer line: the price and what it buys, as the product page gives it (for example “S$24.60 for 12 cans”). Then a code claim filter keeps a claim only if its sourceUrl is on the brand domain or a subdomain of it (an exact label match, so notbrand.com is not brand.com) and is a page that the tool read (the product page or a search 1 page). The brief shows how many claims the filter dropped. |
| 4. Asset pick | pipeline.assets |
A vision call picks the packshot, unit, in-use photo, scale cue and logo from the numbered candidates. A candidate can have only one role. |
| 5. Reference crop | pipeline.assets |
For each photo, a vision call looks for page text around the product. If it finds text, the tool crops to the keep box and checks again. The default is 3 rounds, and 3 is also the maximum. If no word-free box is possible, the tool drops the photo. If the brief has no packshot, logo, brand name or product name, the batch stops as incomplete. The tool does not invent a product. |
| 6. Art direction | pipeline.artDirection |
A vision call (packshot, then logo) sets one Bold colour studio look for the batch. The code builds the batch look string from it. All later calls get this string. |
| 7. Concepts | pipeline.concepts |
One Original call writes ads 1 and 2 (thinking effort high). One Wild call for each Wild slot (effort high, always GROUNDED WILD) writes a problem map, headlines and 5 metaphors. The tool rates each concept for compliance (effort low). For Wild, it tries the metaphors best-first and skips high risk. Each slot has its own layout archetype (see Layout archetypes). If two ads share a visual genre, or their layouts are the same or very similar (word overlap 50% or more), the tool writes the later one again (avoidGenres). Every scene is staged in the studio look: no real room, café, street or table. The creative director reviews the batch with the 7-point checklist. A code lint also flags banned words, em dashes and long headlines. Pushiness lint (pushyProblem in convex/lib/compose.ts): a headline or sticker (also the variants) with an exclamation mark, urgency (hurry, limited time, while stocks last), a hype cliché (game-changer, say goodbye to, you deserve, one X at a time), or an opener that diagnoses the reader (Struggling with…?) or two stacked commands (“Keep the jar. Refill the pouch.”) is blocking. A headline with more than 7 words (HEADLINE_MAX_WORDS) is blocking. When a clean variant exists, code uses it (repairByCode, no LLM call). The writers and the reviewer get the same rule in the shared rules block. On the 151 headline and sticker lines of the B1 to B3 batches it flags 2 lines. If the review gives no entry for an ad, the tool marks that ad blocking. The tool rewrites only blocking failures, high risk and medium risk, for 2 rounds by default (Settings allows 0 to 2). A concept that is still high risk gets one more round on the safer angle. If it is still high risk, the tool drops it. Each funnel job has a contract in the prompts: ToF names the moment, MoF mechanism says why it works, MoF proof gives one number or comparison from the brief, BoF gives the offer with what it buys. The code removes the CTA, button words (“Shop now”), the headline, the sticker and the brand name from the in-scene text. The code also removes a callout that uses a word that is not in the brief’s claims, the product name or the brand name (no invented claims). A sticker that repeats the sticker of an earlier ad in the batch is blocking. A price sticker states what the price buys, then the price (for example “12 cans, S$24.60”, made by code from the brief’s offer). A price sticker with a deposit, the product name or more than 6 words is blocking. If the brief has no clear price with what it buys, no sticker has a price. Price lint (priceProblem in convex/lib/compose.ts): a price in the headline or sticker is blocking when the brief does not state that price, when the brief offer does not name what the price buys (a unit such as cans, carton, sachets or box), when the copy does not say what the price buys, or when the copy gives a count and unit that the brief does not give (for example “One can, S$24.60” when S$24.60 buys a carton of 12 cans). Code removes a headline variant or sticker variant with such a price (no LLM call). Headline craft: the Original and Wild prompts include HEADLINE_CRAFT (convex/lib/defaults.ts): specific moments and objects, one tension for each line, Singapore moments when they fit, and strong DTC headline shapes. It adds no LLM call. A code check blocks a headline that only restates the product name, a proof ad with no number or comparison (when the brief has one) and a BoF ad with no offer or a bare price (when the brief has an offer). One price rule (priceRule in convex/lib/compose.ts) goes to the concept writers and to the concept review: the exact offer line (“12 cans, S$24.60”) when the brief says what the price buys, else “no price in any headline or sticker”. When no price is usable, a BoF ad passes with another offer that the brief states (a bundle, a pack size, delivery, shipping, a subscription, a refill, a gift or a guarantee; each offer word in the copy must be in the brief offer, so “Free shipping” needs shipping in the brief). Only when the brief states no other offer does a BoF ad pass by removing the last objection (taste, convenience, shelf life). The reviewer and the lint get the same rule, so they do not ask for opposite things. The offer line takes the count from the clause of the price (“12 cans; S$20 for 6 bottles” gives “6 bottles, S$20”) and must pass the price lint itself. The pipeline resolves blocking reviewer notes and risk itself: a concept with a blocking note, high risk or medium risk gets up to reviewRounds rewrite rounds that apply the blocking fixes and the compliance reason. After the rounds, medium risk is accepted. A concept that is still blocking or high risk gets one safer-angle rewrite, then a new concept for the slot. A slot that is still blocking or high risk is dropped. No blocking note and no high risk gets to the approval stop. A polish note stays as data in Details; it does not cause a rewrite. Claim notes are blocking (isClaimNote): a review problem about claims (checklist point 5: an unproven, unpublished or medical claim, or an ingredient that the brief does not list) makes the review blocking, so the rewrite rounds apply its fix. The reviewer marked such notes “polish”, and B7 Mama Water ad 4 shipped “Water people actually finish” (flagged as an unproven outcome). In the stored B1 to B3 and B7 reviews, 5 of 31 final concepts had a polish claim note. Code adds rules to the concept review: a studio set passes checklist point 3 when the archetype and one or two props suggest the moment; a BoF price or a MoF proof number passes point 2; the review never asks for a CTA; blocking is only for a clear rule break. Time plan: the step must end in 8.5 minutes (STAGE_DEADLINE_MS). A rewrite round starts only when about 95 s (ROUND_MS) plus 50 s (FINAL_MS) are left, and a new concept only when about 200 s (NEW_CONCEPT_MS) plus 50 s are left. The last 50 s are for the code repair and the save. Valid offer lines (offerOptions in convex/lib/compose.ts): code makes short offer lines from the brief, best first: the price with what it buys (“One bottle, S$43.90”), a saving, a free gift (“Free Moom Mini Shopper with every purchase”), free shipping with its threshold (“Free shipping over S$30”), a bundle (“6-can variety pack”), subscription terms (“Subscribe, pause or skip anytime”), a refill option and a guarantee. The writers, the reviewer and the lint get the same rules block (conceptCodeRules: price rule, offer lines, proof rule). The BoF sticker uses one offer line word for word. The lint accepts an offer line as it is, and the price lint accepts a free-shipping threshold that the brief states. A saving keeps “up to”, and a shipping threshold keeps its currency and word (“over”, “from”). A threshold that code cannot read gives no “Free shipping” line. Code repair (repairByCode, no LLM call): before the rewrite rounds for a concept that only the code lint blocks (the reviewer LLM passed it and the risk is low), and after the rounds for every concept that the lint still blocks, code removes variants that fail, uses a clean variant for a failing headline, sets the BoF sticker to the first offer line that no other ad uses, and sets the proof sticker to a claim fragment with a number. The proof check stops when other ads already show every number of the brief. After the drops, code checks the live concepts again (the proof check counts only the ads that stay). Code-built offer concept (offerConcept): when no written BoF concept is left, or the BoF concept is still high risk, still fails the lint or was not reviewed, code builds one (the pack in a hand, an offer line as the sticker, a headline from the writer’s options), with one compliance rating. At the end, a concept that only the reviewer LLM still blocks goes to the approval stop with its note in Details. A written concept is dropped only when it is still high risk or the code lint still fails after the code repair. Product-as-subject fallback (fallbackCandidates in convex/lib/fallback.ts): the dropped slot then gets a code-built concept: the pack alone on the batch backdrop, no person, no claim. Its headline candidates, in order: a buyer phrase from the brief (buyerLanguage, at most 6 words, no “you” or “your”, no forum slang), the slot’s own Wild headline options, then a product headline (“A closer look at .”). A candidate must pass the code lint and must not address the viewer (assumesViewerProblem). The BoF and proof stickers come from the code repair. The tool rates at most 3 candidates (one compliance call each) and keeps the first that is not high risk. The last candidate (a product headline) is safe by construction and is kept without a rating, so a slot never drops. The reason of the drop goes in the review note in Details and to Sentry at level info. B7 cramp/less lost its ToF slot because each rewrite still assumed the viewer’s routine (“Cramps before the 9am meeting”); offline, all 8 slots dropped in B1 to B7 get a fallback (tests/fallback.test.ts). The concept review gets at least 6,000 output tokens and the Wild writer at least 9,000 (cut-off replies caused paid retries). If the step fails, the batch error includes the last review of each ad (this text does not go to Sentry). |
| 8. Approval (the only human stop) | src/features/batch/ConceptCard.tsx |
Each card shows only the ad number and funnel job, the headline and the sticker (click to edit), their variant chips (click to use one), Approve and Swap concept (a new angle for the slot; the pipeline resolves it before it shows. Swap writes up to two new concepts at the same time. The second one starts 20 seconds later, and only if the first is not resolved yet. A candidate stops at its next check when Swap has already chosen. The first concept that the pipeline resolves is used. After 7 minutes, Swap uses a checked concept that is not blocking and not high risk (the first candidate first). If there is no such concept, the previous concept stays). Approve all approves every card that is not high risk. A card in the silent check after an edit or a variant click gets its approval when the check ends (approveAfterCheck), unless the check makes the concept high risk or the check fails; then the card stays unapproved. Approve all skips a card that is high risk when you click it, says which one, and still approves the other cards. A waiting card whose check later makes it high risk stays unapproved without a message; its card shows Approve again. Swap concept and Improve write a concept that you did not see yet, so they still block approval until they end. A new edit, Swap concept, Improve or the watchdog clears a waiting approval. The Approve button on a card also works during the check (“Approves after check”; click again to cancel). The sticky render bar says how many approvals are left and shows the cost estimate on the Render button. Scene, metaphor, in-scene text, risk and reviewer notes stay in the closed Details (the data is kept). After an edit or a variant click, a silent check rewrites the scene, the other sticker lines and the in-scene text to match. Only when your own edit breaks a rule (your headline breaks a code rule, or the edited concept is high risk, for example a claim the brand does not publish) does the card show a short warning and Use safe version. A high-risk rewrite is never used. |
| 9. Render | pipeline.renderDraft |
The prompt writer makes the image prompt (effort medium). It gets the compositions of the other ads, so this ad looks different. The batch look goes in without its button line. Band render (BAND in convex/lib/frame.ts): Sunburst renders each draft as a landscape 1536x1152 picture. Code scales it to 1152x864 and places it at rows 364 to 1228 (17.8% to 60%) of the 1152x2048 canvas (bandToCanvas in convex/lib/imaging.ts). Each element in the picture then sits inside the safe zones: under the profile row, above the 62% target and the like/share rail. The fill above and below continues each column of the picture edge, with a short mirror and grain. When an object or text crosses the top or bottom edge of the picture (busy), the continued columns streak that object down the canvas. Then the first draft uses the ad’s one automatic fix at once: a regenerate with a note that names the edge (bandRegenerate in convex/lib/frame.ts). Then the cut draft gets no review and stays as a version. A regenerate, an operator’s regenerate, a spent fix or a budget that cannot pay keeps the code fill, and the draft goes to the review as usual. There is no model extension edit: on 4 of 4 probe canvases it showed a seam at the band. Under a plain bottom edge (at least 24 plain rows at the bottom of the band; else the fill stays as it is), code also lights the fill as the near part of the same photo (sceneBelow, convex/lib/below.ts): the surface’s light trend continues and the light falls off towards the bottom and the sides. A blind probe on 4 band canvases: first-glance premium 7.23 to 7.47. The prompt writer gets no percentages: it places elements with words, in 250 to 350 words, with OpenAI’s realism wording. Code adds the band format (BAND_RULES) and the no-CTA rule (NO_CTA_RULE) once to the end of every render and regenerate prompt (renderPrompt in convex/lib/compose.ts; the local edits have their own prompts), so a prompt edit in Settings cannot remove them. renderPromptProblems logs a percentage outside quotes, a writer prompt over 400 words or a duplicate rule. Sunburst renders ad 1 first at the draft quality (low). Ad 1 is the style anchor. Then ads 2 to 4 render with the current image of ad 1 attached last. |
| 10. Draft review | pipeline.reviewDraft, pipeline.autoEdit |
A vision review compares each draft with its concept. It also says if the product label or shape is warped (packshotWarped) and where the product is (packshotBox). Code adds rules E to H to the review prompt: a painted button or CTA is blocking and the fix removes it; the review never asks for a button; the review judges the batch look, the headline, the product, the composition archetype and the placement zones, not the literal scene words (a studio set is never blocking because it is not a real place); placement is checked in numbers. If the review is blocking, the tool does one automatic fix: a local edit at the edit quality (high) or a new render with the notes. The fix runs only when the review has fix text and names a hard AI tell or a real defect of the image (isRealDefect, an allow-list: text that does not read or is wrong or invented, a painted CTA or button, a warped product, hand or label, a missing or wrong product, letters cut by a colour split, a floating object, a claim). Placement breaks (also under the Stories or Reels buttons), composition, size, a mirrored side and problems that the review itself marks as polish (“(polish)”, “polish: …”) do not use the fix. A local edit does not move elements reliably. The text check then measures the boxes, and the code repairs (sticker lift, safe fit) and the layout edit handle the placement. A polish review stays as a note. AI-tell gate (rule J, convex/lib/tells.ts, same review call): the review fills aiTells. It writes what it sees first (observed), then one true/false item for each hard tell: garbled or clipped painted text, a warped hand or pack label, impossible physics, objects floating in a liquid (for example graphic stars from a brand photo painted inside a drink; a tell even when the reference shows them), a prepared product in the wrong colour (the review gets the in-use reference description when the asset step found one) and a cold product with no condensation (only when the brief or concept says iced, chilled or cold). A hard tell makes the review blocking and uses the one automatic fix (a regenerate with the scene change, or the review’s own fix with the tell first). Soft tells (waxy surfaces, a perfect backdrop, a stock prop, a blob sticker, no imperfection) never block. Only a hard tell or a real defect uses the one automatic fix (spendsAutoFix). A blocking review without one (placement, composition, polish or soft tells only, for an edit or a regenerate) keeps the fix for a later draft. Each soft tell takes 4 points from the version score (SOFT_TELL_PENALTY). The review also gets the stage of the ad; a different field or side is polish only. Code adds rule I: a logo in a colour near its backdrop is blocking, and the fix repaints the logo in a palette colour with high contrast. Logo colour at render (logoPlan, logoChoice in convex/lib/color.ts, convex/lib/logoContrast.ts): code reads the colour behind the logo in the stage of each ad (logoGround in convex/lib/stage.ts: the brand field, the off-white field, the page of a frame, or the first block of a split; the stage tells the render that the logo sits in that block). When the logo’s own colour has 3:1 contrast or more with that colour, for a flat logo, the prompt tells the model to keep the logo’s own colour and not to copy the logo colour of the attached campaign ad. Else code makes a one-colour version of a flat logo in the first palette colour with at least 4.5:1 contrast (plateHex; when no colour reaches 4.5:1, the colour of the palette, white and near-black with the highest contrast), attaches it instead of the logo and adds a prompt line with the colours. The batch stores one version for each colour behind the logo (logoVariants, added in a transaction). A photo logo (not flat) gets no colour line. Rule I of the draft review uses the same colour. |
| 11. Text check and automatic repair | pipeline.textCheck, planRepair |
A vision call reads all text in the draft and compares it with the expected strings (headline, sticker, scene text, packaging text). It gets the real logo and packaging as references. It also reports each readable painted word that is not allowed (an invented callout, a button) with an empty expected string. Spelling read (convex/lib/spelling.ts): the text check sees the expected strings, so it can read what it expects (B4: it read a sticker painted “Himalavan” as “Himalayan”). Code crops the boxes that the check matched to the headline, the sticker or approved scene text from the preview and sends them in one small call with no expected text (one try, at most 60 s). Code compares each read with the approved text. A read with at most 2 different letters (or a fifth of the string) is a misspelling and becomes a text-check mismatch, so the text-fix repaints it. A read that is the start or the end of the text (a tight box) is not a misspelling. If the call fails, the text check continues without it. Estimates and budget checks count a text check as two LLM calls. Then the repair is automatic. Product presence (packMissing in convex/lib/frame.ts): when the brief has a pack or unit image and the text check reads no text on packaging and no product name outside the approved copy, the text check stores packMissing: true. This is a hard defect: when the ad still has its one automatic fix, it uses it as a regenerate with the note that the pack is missing (a local edit cannot add a whole product). The draft review passed both real cases (B2 Mama ad 3, B6 Mama ad 1). pickBestDraft ranks a version with no product below every version with one. The concept step also prevents it: asForms adds the pack (or the unit) to a concept that shows only the drink (inUse), and the product-forms text says that every scene shows the pack or the unit. Offline on 67 stored B1 to B6 drafts: 3 flagged, all with no product; 0 false flags. Split field (splitCheck in convex/lib/split.ts, splitProblem): on the proof slot, whose stage is a two-colour split, code reads the preview. It finds the best two-block split of the backdrop-only strips (rows 3% to 12% for a vertical split, the outer 1% to 4% columns for a horizontal one). When the two block colours have less than 2:1 contrast, or the colour does not change sharply at the line (a gradient), the split is missing; else each text placement that the line passes through is listed (pack text is not checked). Either one is stored as textCheck.fieldProblem and, when the ad still has its one automatic fix and the budget allows, it becomes a regenerate with that note. A version that keeps the problem scores 10 points lower and shows the note in Details. A failed download or decode goes to Sentry as a warning, and the text check continues. The plan side: fieldColours takes a role match only when the colour has at least 3:1 contrast with the brand field (FIELD_MIN_CONTRAST); B5 and B6 Mama Water paired the brand green with a green ink whose role named the label type, and B6 cramp/less took the brand green as its off-white because its role said “paper sweep”. Offline on the 6 stored split ads: the two green-on-green ads are missing, the four real splits are found, and only B6 cramp ad 3 has a text crossed (“Period week, planned, not guessed.”). First, two code repairs that do not use the image model to move things (a local edit does not move elements reliably at the edit quality). Sticker lift (liftPlan in convex/lib/frame.ts, pipeline.liftSticker, 1 per ad): when the approved sticker is under the Reels buttons or ends below 64%, code finds a clear place with the same size and a centre near 54% (when the product fills that band, up to 20% higher, never above 18%) (no overlap with other text; when the review gives the product box, at most 30% of the sticker sits over the product). The sticker box is the union of the text boxes of the sticker, with a badge margin. It cuts the sticker out (background removal on a crop), removes it from its old place with one local edit on a crop, and pastes the cut-out at the new place. Safe fit (fitPlan in convex/lib/frame.ts, fitToSafe in convex/lib/imaging.ts, 1 per ad, no model call): the key elements are each text, logo, sticker and badge, and the product’s label panel (labelPanel: each pack line with the brand or product name, isIdentityText, and the pack lines under it in the same columns with no gap larger than 3% of the height; a flavour tag or the net weight after a larger gap is the pack’s base). The panel is fitted to 62%; when that needs a scale below 80%, the panel is fitted to the 65% Reels caption line; only when that also fails, the fit uses the name lines only (to 62%, then 65%). B6: the name lines ended at 61% to 64%, but the panel ran to 65% to 68%; offline the panel fit moves B6 Mama ad 3 and cramp ad 2 to 62%, and the edge gate refuses two more (a hand and a level cut by an edge). When the highest text starts above 16%, a key element ends below 64% (the generation targets with a 2% margin) or crosses a side margin, code scales the whole picture down and moves it. Text (headline, sticker, callout, logo) gets no tolerance at the side margins, and the fit aims it 1% inside them (7% to 93%, TEXT_SIDE_PAD); B6 had three ads with text ending at 94.1% to 94.9%, inside the old 1% tolerance. A busy edge that must stay flush still wins over that pad. The highest text goes to about 20.5% when it was too high, else it keeps its place. The lowest key element ends at most at 62%. The scale centre is the middle of the width. Code does not fit when the scale would be below 80% (FIT_MIN_SCALE; then the layout edit handles it) or when the product box would end below 90% (the 4:5 area bottom + 5%). The open edges continue the picture’s own edge colours: each column for the top and the bottom, each row for the sides. Near the picture the fill mirrors the picture and fades into the edge colour with the grain measured on that edge, so a paper sweep, a soft falloff or a two-colour split continues. Code does not fit when an open edge is not plain: an object or text cut by the edge (a colour difference in CIELAB above 20 from both ends of the edge, lightness weighted down), fine detail along the edge, or a frame line (the edge differs from the field 24 to 30 px in). Offline on the B4 finals: 3 of 8 ads fitted (logo or headline at 14% to 15% moved to about 20.5%; a jar label at about 66% moved to 62%), a framed page and two hands cut by an edge were refused. It also does not run when the batch budget cannot pay for the new review and text check. Each code repair makes a new draft with a new draft review and text check. Then one layout and clean-up local edit at the edit quality (1 per ad): it removes painted buttons, CTA words and words that are not allowed (when it only removes words, the edit runs on a crop around them and code pastes it back, so the rest of the frame cannot change; a replay on a B7 final measured a whole-image edit changing 1.67% of the pixels outside the area it was asked to change), and it moves each element that breaks a safe zone to a target position in percent (zoneBreaches, moveInstruction). Any element outside the 4:5 area breaks a safe zone. An element that is not product text also breaks a safe zone when it is under the Reels buttons, starts above 16% or ends below 64% (the generation targets with a 2% margin). Product text gets only the 4:5 check, and the repair does not move it. Only the approved value sticker gets the sticker-centre target; its bottom edge stays at or above 62%. The edited image gets a new draft review and text check. Placement repair (layoutRepair): when the code measures no breach but the draft review still blocks, and the block does not use the automatic fix (spendsAutoFix; for example placement, composition or size), this one layout repair uses the review’s own fix: a local edit, or a new render when the review asks for one. B7 Mama Water ad 1 ended blocking with no repair (the safe fit refused, and the text-check boxes showed no breach); offline on the 15 B6 and B7 ads, 6 final drafts ended blocking with no repair left before, 0 after (tests/repair.test.ts). Then a text-fix on the boxes that the check found (up to autoTextFixMax, default 2, per ad; one pass fixes all boxes when their union is small, else only the largest box, so some errors can stay), then a packshot blend in the review box when the review found the product warped (up to autoBlendMax, default 1; the blend makes the cut-out itself, one at a time for each batch). A blended image gets a new draft review. Each pass runs only when the per-batch cap keeps enough money for the ads that wait for their first draft; the budget reservation checks this in the same transaction (keepUsd). Logo pill (logoFix, 1 per ad, no model call): code measures the painted logo in its text-check box (the text check names it “logo: …”). Below 2.5:1 contrast, code finds the real extent of the logo, draws a rounded pill in a palette colour that contrasts with the backdrop and puts the real logo on it. The pill stays clear of the text above and below. The new draft gets a new draft review and text check. Sticker contrast (stickerContrastFix, 1 per ad): code finds the approved sticker in the text-check boxes (stickerBox in convex/lib/frame.ts) and measures its text against what is directly behind it (regionContrast). Below 4.5:1 (STICKER_MIN_CONTRAST, WCAG AA), one local edit at the edit quality, on a padded crop that code pastes back (cropEditBox, as the text-fix), repaints only the sticker letters in the palette colour with the highest contrast on that background (at least 4.5:1; no such colour: no repair). The new draft gets a new draft review and text check. Label in the safe area: the product’s label (pack text that has the brand name or the product name, isIdentityText in convex/lib/frame.ts) must sit inside SAFE. When it ends under the Reels caption (REELS_COVER_TOP), sits in a side margin or under the rail, zoneBreaches reports label_covered, and the layout edit moves the whole product up or sideways until the label ends at most at PLACE.bottomMax. The prompt placement rules and the draft-review rule H state the same rule; the pack’s base, its shadow and decorative props may stay lower. Then the tool makes the version with the best score current (review severity, open text errors, a warped product, the layout warning, a logo that code measured as unreadable). When the best version still fails, the note goes in the closed Details of the card. Photo finish (convex/lib/finish.ts, photoFinish, no model call): the best version gets film grain (about 1.6%), a soft light falloff (5% at the far corners) and a faint paper texture (2%), as a new version (auto-finish). Every text-check box (text, logo, sticker) gets nothing, with a feathered edge, so letters stay sharp. The product box gets the grain only, so the label keeps its colours. A chain gets the finish once: an operator edit on a finished version is not finished again. Why: a clean CG backdrop with no grain, falloff or texture (rubric K5) was the most common soft tell in B6 and B7. Blind A/B on the 15 B6 and B7 finals (one grader, rubric K): K 8.80 to 9.30, soft tells 12 to 7, 6 wins, 8 ties, 1 loss. The finish uses no model call; each ad stores one more version (about 2 MB). If the finish fails, the ad keeps the best version and Sentry gets a warning. |
| 12. Text-fix (advanced tool) | pipeline.textFix, TextFixDialog.tsx |
Under More on the draft card. You draw a box around garbled text (or, with the keyboard, focus the image, press Enter and use the arrow keys; Shift and the arrow keys change the size) and type the correct text. The tool makes the box larger for context, edits the crop with Sunburst at the edit quality, and blends the crop back with a soft edge. Then the text check runs again. |
| 13. Packshot blend (advanced tool) | pipeline.cutout, pipeline.blend, BlendDialog.tsx |
Under More on the draft card. The background remover cuts out the packshot one time for each batch. Only one removal runs at a time. If it fails, the dialog shows the error and a retry button. An empty cut-out is an error. You drag and scale the cut-out on the ad (cx, cy, width). All of the cut-out must stay in the image. If the ad changes while the dialog is open, place the cut-out again. The tool pastes it in, and Sunburst harmonizes the light at the edit quality. Then the text check runs again. |
| 14. Mark used | batches.setUsed |
Mark used flags the image that you spend on. A download does not mark an image as used. |
Time limits, progress and retry
-
Time limits. Each provider request has a time limit: Firecrawl scrape 100 s, search 120 s, map 60 s; OpenRouter 120 s for each try; Replicate 90 s to start a prediction, 30 s for each status check and 6 minutes in total (then the tool tries to cancel the prediction; the cancel request can fail); a candidate image download 20 s in total (all redirects and the full body), a Replicate output download 60 s; the DNS check of a candidate image 5 s. One candidate image (download, decode and store) has 45 s. After that, the tool skips it and continues with the other candidates. A Firecrawl, OpenRouter or Replicate timeout (also while the body is read) gives a
TIMEOUT: ...error. An image download timeout drops that candidate. A Replicate status check that gives 408, 409, 425 or 429 is tried again. Another 4xx status stops the prediction wait at once, also when the body is empty. A Shopify JSON timeout makes intake use Firecrawl only. A timed-out OpenRouter call keeps its reserved cost on the ledger, because the provider can still bill it. -
Step deadline. Each automatic step must finish in 8.5 minutes (
convex/lib/http.ts). Convex stops an action at 10 minutes, and then the action cannot write its status. Thus the step writes failed and sends a Sentry report before that limit. -
Watchdog. Every 5 minutes,
convex/watchdog.ts(convex/crons.ts) looks for work with no progress for 12 minutes. Each progress write setsheartbeatAt. A batch in research or concepts becomes failed. An ad that is working becomes failed (or ready with a note when it has an image). An ad at the approval stop that is busy (Swap concept, the silent check after an edit, Improve) goes back to its concept. Each case goes to Sentry (stepad-stalledfor ads). -
Progress text. The batch shows the sub-step, for example “Searching the web (2 of 3 done: brand pages, reviews)…”, “Downloading candidate photos (3 of 8)…”, “Checking photo 2 of 4 (unit) for page text, round 1…” and “Writing concepts (1 of 3 writers done)…”.
-
Retry from this step. A failed batch shows Retry from … when an automatic step failed before rendering started. The retry keeps the work that the batch has already paid for and uses the settings that the batch started with. It starts at the first step that has no saved result:
- research;
- the brief (it uses the saved search results again when the search queries did not change);
- reference photos;
- art direction;
- concepts (it writes all 4 concepts again);
- approval (all concepts are saved: no paid call).
After a timeout, wait 2 minutes before you retry. The old step can still be stopping. After rendering started, use the actions on each ad.
-
Image memory. Pages often list one photo at many sizes (for example Webflow
-p-500…-p-2600). The tool keeps one size for each photo, near 1600 px. It asks the Shopify CDN for a 1600 px copy. It downloads 2 candidates at a time, decodes photos above 2 megapixels one at a time and keeps reference images at a maximum of 2048 px. The tool does not decode an image above 6 megapixels or above 6000 px on one side (a 3840×5760 JPEG used about 600 MB in jimp). It reads the pixel size from the PNG, JPEG, GIF or BMP header. It does not decode a file in another format (except SVG and WebP) above 2 MB. For a Webflow original that is too large (in pixels or above the 15 MB download limit), it tries the-p-1600,-p-1080and-p-800sizes of the same photo. If no size is small enough, it skips the image. A candidate that is still working after its time limit deletes its stored file when it ends. For other hosts, a?w=or?width=value selects the size, like the Webflow rule. Vision models in the batch pipeline get each image as a URL when the file is 3.5 MB or less (brand analysis is different: see Brand extraction). A larger file (for example a full-size PNG draft) goes as a data URI at a maximum of 1568 px, because Anthropic models refuse images above 5 MB. The data URI is JPEG. An image with transparency stays PNG; if that PNG is still above 3.5 MB, it becomes JPEG on white at 1024 px, then at 768 px. If it is still too large, the tool sends the original URL. The asset-pick vision call gets copies at a maximum of 1024 px (made one at a time). Exception: a WebP image cannot be decoded here. The tool keeps the original WebP file, does not crop it and does not make it smaller. We expect these limits to keep the asset step below the memory limit of a Convex action. This is not yet confirmed on Convex. -
Research retry. A retry of the research step uses the saved search results again. Only the searches that failed run again.
-
Thinking effort. Some models think before they answer (for example
anthropic/claude-haiku-5.5). The thinking tokens count againstmax_tokens. Haiku 5.5 has no thinking budget in tokens: it takes an effort level (low,medium,high). Each LLM job sends its effort (reasoning.effort, with the reasoning excluded from the reply):lowfor the text check, reference crop, asset pick, compliance rating, research plan and brand vision;mediumfor the brief, art direction, image prompt writer, draft review and brand kit analysis;highfor the Original and Wild copywriters and the concept review (Anthropic: high effort for strict instruction following). The effort is a setting for each job (LLM_JOBSinconvex/lib/settingsRegistry.ts). This applies only when the model endpoints acceptreasoning(OpenRouter/models/{id}/endpoints); when the lookup fails or the model does not reason, the call sends no effort andmax_tokensis the answer room. With reasoning,max_tokenskeeps the full answer room after 8,000 thinking tokens and after a thinking budget of the effort’s share ofmax_tokens(low 0.2, medium 0.5, high 0.8: how OpenRouter can turn an effort into a budget for Anthropic models) (REASONING_HEADROOM,completionCapinconvex/lib/openrouter.ts).max_tokensis only a limit, and OpenRouter charges only for the tokens that the model uses. The old token budget (reasoning.max_tokens) overran by 1.5 times, and with a cap of answer + 4 × budget, 7 replies in the B3 batches were all thinking and 0 characters of text. A reply with no JSON is noted in the spend log with its thinking tokens and effort. If a provider refusesreasoningwith a 400, the tool sends the call one more time without it. -
Strict JSON and model parameters. Before an LLM call, the tool reads the supported parameters of the model from OpenRouter (
/models/{id}/endpoints, kept for the life of the process). It uses strict output only when an endpoint supports it. The tool sends notemperature(Haiku 5.5 refuses a value other than the default). If the lookup fails, the tool sends noreasoning. A selected WebP unit, in-use or scale photo that needs a crop is dropped; only a WebP packshot stays (uncropped). -
Product name. Intake keeps the raw page title in
titleand a clean name inname(for example “Iced Oat Latte”). The name comes from the Shopify product title, the Firecrawl product title, or a short LLM clean-up of the page title,og:titleand first heading (model of the Brief job, effort low). If the LLM call fails, takes longer than 45 s or hits a spend cap, a rule-based clean-up is used (intake does not fail because of the name). Product cards, batch labels and research queries use the name. Read the page again to get a name for an old product.
There is no promote step and there are no finals. Each ad keeps its image versions (the last 10, Settings → Pipeline). The version that is marked used is always kept. You can restore an old version, download the image, write comments and export a ZIP (the images in the format of each stored draft, JPEG for new drafts and PNG for old drafts, + concepts.csv). The CSV has a row for each concept, also before its draft renders. The CSV prefixes a cell that starts with =, +, - or @ with ', so a spreadsheet does not run it as a formula.
Meta safe zones (approved change to the spec)
han approved this change to the spec: Meta’s 9:16 Reels and Stories interface covers more than the spec says. The values come from Meta’s safe-zone help page (https://www.facebook.com/business/help/980593475366490) and Meta’s Ads Guide numbers. convex/lib/frame.ts has the sources.
| Zone | Value (share of the 9:16 image) | Constant in convex/lib/frame.ts |
|---|---|---|
| 4:5 feed crop (the feed shows the centred 4:5 band) | 14.84% to 85.16% of the height | FEED_TOP, FEED_BOTTOM |
| Top: profile row and “Ad” label | top 14% | META_UI.top |
| Bottom: caption and CTA button | bottom 35% (below 65%) | META_UI.bottom, REELS_COVER_TOP |
| Sides | 6% on each side | META_UI.side |
| Like, comment and share buttons (rail) | right 21% of the width, from 60% down | META_UI.rail, RAIL |
| Safe area for key elements | 14.84% to 65% of the height, 6% to 94% of the width, not in the rail | SAFE, inRail, inSideMargin |
- The tool composes for the 4:5 feed area.
- Each sticker, badge, logo and important text keeps its bottom edge at or above 65% of the height, stays clear of the 6% side margins (within 6% to 94% of the image width) and stays out of the rail.
- Reels ads with a disclaimer need the bottom 40% clear. The tool does not add disclaimers.
convex/lib/frame.tsis the one source of the numbers. The safe-zone overlay, draft-review rule H, the layout warning,zoneBreaches,moveInstruction, the safe fit and the sticker lift read it.convex/lib/defaults.tsuses the same constants in the spec text. Do not write the numbers as literals in other files.assertZoneNumberschecks the code defaults of the safe-zone prompts (the draft-review default and draft-review rules E to H) when the module loads: a percentage that is not a value fromframe.tsstops the Convex deploy. It does not check prompt text that a user saves on the Settings page. Rule H is always added by code, so its numbers stay correct after a Settings edit. The band-render prompt defaults (imagePromptSystem,textModeRules, the four composition guides) have no percentages at all:noPercentinconvex/lib/defaults.tsstops the deploy when one has a percentage, and a module-load check inframe.tsstops it whenBANDleaves the safe area.- Generation uses stricter targets than the safe area, because image placement is not precise (
PLACEandAIM): the logo and the headline start at least 18% down from the top, and the prompt aims at about 21% for the logo and 23% for the headline (the model places elements a few percent higher than asked); each text, logo, sticker and badge ends at most 62% down; the sticker aim is a centre at about 54% (an aim, not a limit); below 65% there are only the product base, props and the backdrop. - The Safe zones button on a draft card shows the overlay: the area outside the 4:5 band is dark, the Meta interface zones have rose hatching with dashed edges, and the safe area has a green outline. There is no sticker guide.
Layout archetypes
Each slot has a different composition, so the four ads look different in the feed (Meta’s Andromeda delivery needs different creatives). All four use the shared Bold colour studio look. The two Wild slots break the grid.
| Slot | Funnel job | Archetype |
|---|---|---|
| 1 (Original) | ToF pain | Giant type, small product: the headline is the hero; a small product off-centre with one prop. |
| 2 (Original) | MoF mechanism | Flat-lay on colour: shot from above on one flat colour, with pointer callouts. |
| 3 (Wild) | MoF proof | Prop metaphor on colour: the Wild metaphor object is a large hero prop next to the product, on one or two colour blocks; it can go off one edge. |
| 4 (Wild) | BoF objection | Hand-held, off-frame angle: a tilted product in a hand that comes in from an edge; the product can go off one side. |
The archetype text is in Settings → Prompts (Composition). The names are in convex/lib/recipe.ts. These archetypes replace the real-place compositions of the spec (wide real moment, top-down flatlay, macro close-up, in hand), because the draft review blocked studio renders that did not show a real place.
Stage per ad (colour field, layout, sticker style, real-world state)
Code sets the stage of each ad (convex/lib/stage.ts). The stage is not stored. Code makes it again from the batch palette, the slot position and the batch id (stageOf). It costs nothing.
- Colour field: each ad of a batch gets a different field from the art-direction palette. The MoF proof slot always gets a two-colour split (brand colour and pack colour, one straight line). The other three slots get the brand colour on a paper sweep, a warm off-white with one brand-colour block, and a printed page with a thin brand-colour border, in an order that changes with the batch. The render prompt does not get the look’s
Background:line. The stage block replaces it. - Layout: the product side (left or right) alternates from slot to slot. The sticker sits under the headline or on the side opposite the product. It never sits on the product side. The headline is aligned opposite the product (centred on the flat-lay). The band render gives the heights.
- Sticker style: a plain line of set type in the supporting typeface, with an optional thin rule. There is no blob, cloud, scalloped, starburst or tilted badge. Only the BoF offer ad (slot 4) can use one flat tag.
- Real-world state (
physicsBlock): the prompt states the real state of the product (condensation on a cold product, a drip on a poured drink, a torn sachet edge), the real colour of the prepared product (from the in-use reference description), one small imperfection, contact shadows, true sizes and no stock props (closed laptop, blank notepad, blank paper bag, plain pillow, bedside lamp, generic mug) unless the headline names them. - Scene rules for the writers (
SCENE_RULES): the concept writers and the concept review get the same physics and prop rules, and they do not describe the backdrop colour. - Scene lint (
sceneProblems, no LLM call): code reads the concept scene and layout text before the render. An object that floats or balances, an object on a pouring or tipped object (not one held in a hand), a stock prop that the headline does not name, an empty glass, cup or bottle when the headline says the product is stirred or mixed in, or an abstract prop (abstractProps: a sphere, disc, plain ball, cube, cylinder, plinth or blank card; a block only in the look’s props line, because in a scene a block is the split field) becomes a line inSCENE CORRECTIONSat the top of the real-world state block. The prompt writer follows the correction. The concept is not dropped and not rewritten. - Real props: the look text asks for concrete everyday objects from the buyer’s world (a ceramic cup, a measuring spoon, a ginger root) and never abstract shapes. The spec text “at most two props, simple in shape” made the art director write “a single lemon-yellow disc or a pale pink block” (B6, B7). When the batch look’s props line still names an abstract shape, each render prompt gets a correction to ignore it. Offline on B1 to B7: 8 of 16 batch looks and 4 of 56 concept scenes named a primitive (
tests/props.test.ts). - Brand colour (
brandBackdropinconvex/lib/color.ts): the art direction gets the brand kit’s primary colour. When the palette that the art direction reads off the pack and the logo has a close colour (RGB distance 40 or less), code puts that colour first as the backdrop, so the brand field, the logo contrast and the review use the brand colour. The label colours stay the second field and accents. A kit primary that the pack and logo do not show (for example a site link colour) changes nothing. - The style anchor (ad 1, attached to ads 2 to 4) sets the type, the palette, the light and the finish. It does not set the colour field.
Why: the graded B2 and B3 ads had one backdrop colour for the whole batch, a perfect seamless sweep, the same blob sticker on 10 of 11 ads, the product on the right in 9 of 11 ads, stock props and impossible physics. The reference brands (Graza, Olipop, Ritual) change the field and the layout from ad to ad.
Copy tournament (diagnostics only)
convex/lib/tournament.ts picks the best headline of N for one ad. One call asks for 8 candidates (Original copywriter model and effort); the model can return fewer. Code lints each candidate for format (2 words or more, a capital first letter, a full stop) and with the rules of the concept (length, pushiness, spec list, sticker echo, product name, funnel job), the viewer rule (no “you”, assumesViewerProblem) and no repeat of another ad’s headline. The current headline and the clean candidates then go into a knockout. A judge (concept review model, effort low) compares two headlines at a time, with reference headlines (Graza, Olipop, Ritual) and the buyer’s own words as anchors. The A and B sides come from a hash of the pair, not from the list order. Operator edits that are headline-sized go in as `` blocks (at most 5). The prompts put the data in XML tags first and the task last.
The pipeline does not use it yet. diagnostics:copyTournament runs it on a stored batch and writes nothing: npx convex run diagnostics:copyTournament '{"batchId":"..."}'. It returns the candidates, the lint drops, the duels and the winner for each ad, for a blind comparison with the shipped headlines.
Playbook
The Settings → Playbook tab has the playbook sections. Their first text is from the appendix of the spec. Each step gets only its sections, as ## title and then the body (spec §6):
| Step | Sections |
|---|---|
| Brief | Market context, compliance |
| Copy (Original) | Angles and funnel rules, Original method, copy rules, banned words, research copy rules, market, compliance |
| Metaphors (Wild) | Wild method, anti-homogenization, metaphor catalog, copy rules, banned words, research copy rules, market, compliance |
| Compliance | Compliance |
| Image prompt | Anti-slop, image prompt templates, research image rules |
The copy and metaphor steps also get the last 8 headline edits (before → after) that operators made for the brand.
Prompt hygiene (PLAYBOOK in convex/lib/defaults.ts, tests/prompts.test.ts): the playbook text of each step agrees with what the pipeline does. The copy writers get no “present the headlines for approval” line (the approval stop is in the app), no Meta UI frame and no CTA bar (no CTA is painted), and the headline rules once (in the copy rules). The Wild method has no image-prompt phase (the image-prompt writer does that) and is always grounded wild. The image-prompt writer gets no halation, theatre spotlight, phone-flash, illustration mode or “different genre per card” (the batch has one studio look). A test fails when one of these comes back. The LLM steps for concepts (Original, Wild, rewrite, compliance rating, concept review) send their data in XML tags (, , , , , , ``) and put the task last (tag, userPrompt in convex/lib/compose.ts; Anthropic prompting guide).
Budget
Before each paid call, the tool records an estimated cost in the spend table (convex/lib/metered.ts). When a billed attempt is not used (a reply that is not JSON, a reply that is cut off, a provider error or a timeout), the row also gets a short note with the reason. The tool blocks the call if the cost goes above a cap:
- The monthly cap (default $20) applies to all paid calls.
- The per-batch cap (default $1, han’s limit for 4 ads) is a hard stop. It applies to all calls of a batch (OpenRouter, Replicate and Firecrawl), also the text-fixes and blends that you do later. While the automatic steps run, the batch keeps the cap from its start. Operator actions (Swap concept, the silent check after an edit, the advanced text-fix and blend) always use the current cap, also while the batch renders. The automatic repairs after the text check use the cap from the start of the batch.
OpenRouter reports a real cost, and that cost replaces the estimate. Firecrawl and Replicate calls keep the estimate. Sunburst has no published price for each image. Thus the image costs are settings (draft low, medium, edit high) in Settings → Costs & budget. Check the Replicate bill and change them if necessary. convex/lib/estimate.ts calculates the estimate for each batch. The Settings page preview uses the same function. The estimate has a typical value and a worst case. The worst case counts every retry path (Wild metaphor ratings, duplicate repairs, all rewrite rounds and the safer angle), 3 paid attempts for each LLM call and the more expensive automatic fix for each ad. If the typical cost is more than the per-batch cap, the tool refuses the batch before it starts. The worst case can be more than the cap: then the budget reservation refuses the paid calls past the cap. Research, references, art direction and concept calls keep money for the four first drafts (when the optional rewrite rounds, the safer angle or the new concepts would use that money, they stop, and the batch goes on with the concepts it has), and the automatic repairs keep money for the drafts that wait (keepUsd), so such a batch loses repairs, not drafts. The cap applies to reservations: an OpenRouter call reserves its estimate and then settles at its real cost, so the real total can pass the cap by the difference on the last calls (cents). The Start dialog shows the typical cost, the worst case and the hard stop.
Convex usage (database I/O and data egress)
The owner uses the Convex Starter plan (since 2026-10-16). The plan includes 1 GB of database I/O and 1 GB of data egress each month. Usage past these amounts is billed to the owner’s card ($0.22 for each GB of database I/O, $0.132 for each GB of egress, US prices at https://www.convex.dev/pricing). The owner wants this cost as low as possible. In October 2026 the app used more than the included amounts in 15 days. Thus the code obeys these rules:
-
Cost contract tests.
tests/cost.test.tsruns the hot queries and mutations on realistic data (tests/fixtures.ts: 30 batches, a live batch with 4 ads and 12 drafts, 90 spend rows) through a meter (tests/meter.ts). The meter counts the bytes of each document that a function reads and writes (a patch writes the full document again). Each function has a byte budget. When a change makes a hot path read more,npm testfails. Lower a budget when a change makes a path cheaper. Raise a budget only with the reason in the PR. -
Measure prod, do not guess.
CONVEX_DEPLOY_KEY=... python3 scripts/convex-usage.py --minutes 12prints one row for each function from the execution log of the deployment (the newest ~1000 executions): the calls, the cache hits, the total database I/O, and the averages per call of the bytes read, written and returned. Use--minutesto follow a batch while it runs. Use--jsonl out.jsonlto keep the raw row of each execution. The dashboard bars cover the full billing period and can include old code. -
Spend totals.
budget.reserveandbudget.settlekeep running totals in thespendTotalstable, in the same transaction as thespendrow. The key ismonth:orbatch:. When the totals exist, the cap checks, the budget meter (budget.summary) and the batch view read one small document. They do not read all spend rows. When a total does not exist (a new month or an old batch beforerebuildTotals), the code reads the spend rows one time. A mutation then saves the total; a query does not. Do not writespendrows in other code, because the totals do not see them. If a total is wrong, runnpx convex run budget:rebuildTotals '{"month":"2026-10"}'. This mutation calculates the totals again from the rows. You can run it again with no effect. The list of recent calls is a separate query (budget.recent, 40 rows). Only Settings → Spend reads it. -
Small batch documents. Every status write rewrites the whole batch document, and the batch list (30 batches), the budget checks and the watchdog read batch documents. Thus the batch document holds only the status fields and small metadata (ids, the settings snapshot, the product label, the cut-out state; about 0.5 KB with the default settings). The settings snapshot holds only the settings that differ from the code defaults; a long prompt override on the Settings page makes it, and each batch read and write, larger. The plan of a batch (research summary, brief, references, art direction, look, logo variants) is in
batchPlans(planFieldsinconvex/schema.ts).withPlan(convex/lib/plan.ts) adds the plan to a batch for the batch view and the pipeline, andpatchBatchsends plan fields tobatchPlans. The research text (about 40 KB for each batch) is inbatchResearch; only the research step reads it. The batch keeps a copy of the product label (productTitle,productUrl), so the batch list does not read the product page text. Do not add large fields tobatches. Put them in their own table. -
Small draft documents. The Sunburst or edit prompt of a draft (about 2.4 KB) is in
draftPrompts. No code reads it; it is only for diagnosis. The batch view, the version strip and the pipeline read the draft without it. -
No live re-run for each paid call. A paid call writes the spend totals up to two times (the reserve, and the settle when the real cost or a note arrives), about 140 to 180 times for each batch. The batch cost is its own query (
budget.batchSpend), so the batch view (batches.get, about 27 KB for each run) does not run again for each paid call.tests/cost.test.tschecks which writes re-run which live queries. -
Product page only where needed.
loadForPipelinereads the product page and the brand kit (about 20 KB) only withinputs: true(research, references, art direction). The concept, render and repair steps do not read them. -
JPEG draft images. The Sunburst request asks for JPEG (
output_format: "jpeg",output_compression95), andstoreDraftstores each new full-size draft as JPEG (it re-encodes any other format it gets) quality 95 with full colour resolution (encodeMaster,MASTER_JPEG_QUALITYinconvex/lib/imaging.ts). A full-size draft is about 0.6 MB instead of a 3 MB PNG (44.5 to 47 dB PSNR on four B5 drafts; no visible change in 3x crops of small label text; three more encodes lose less than 0.1 dB). Each full-size image moves several times for each draft: the download from Replicate, the image edits (Replicate reads the base image), the code repairs (safe fit, logo pill, sticker lift, text-fix read it), the download and the ZIP export. Drafts before 2026-10-16 stay PNG; the download and the ZIP use the extension of the file. The field keeps the namepngStorageId. Reference images, the logo, logo variants and cut-outs stay PNG (they need transparency). Exception: the asset pick keeps a chosen WebP candidate as WebP (it cannot be decoded here). -
Draft previews. Each new draft normally also has a JPEG preview (
previewStorageId, long side 1568 px, about 125 KB). If the preview step fails, the draft has no preview and these places use the full-size image (more egress). The cards, the version strip, the text-fix and blend dialogs, the draft review, the text check and the style anchor use the preview. The download, the ZIP export and the pixel edits use the full-size image. Each download from Convex storage counts as data egress, also a download by OpenRouter or Replicate and a download by our own actions. -
Reference copies for vision. For a reference above 150 KB,
visionRefs(convex/pipeline.ts, tablevisionCopies) tries to make one small JPEG copy (long side 1568 px, on white when the reference is a cut-out) at its first vision check. The later draft reviews and text checks of the batch send the copy when it exists. If the copy fails (for example a WebP file or a decode error), they send the original. Renders and edits still send the original reference to Replicate. -
No download to measure a file.
visionSafeUrlsgets the size of our own stored files (refBytes, the draftbytes). It does not download a file only to measure it. -
One-time data moves (run them after the deploy; the deploy does not run them). Each move is idempotent. Each call does one page: run it again with
{"cursor":""}untilisDoneis true, also in a dry run. The plan and prompt move (2026-10-16) hasdryRun: true: runnpx convex run migrations:splitColdFields '{"dryRun":true}'for every page, then the same pages withoutdryRun; do the same formigrations:moveDraftPrompts; thennpx convex run migrations:verifySplit(both counts must be 0). The legacylogoVariantfiles are not deleted by the move; remove orphaned files with a dry run and a backup list first. Older moves (no dry run):npx convex run migrations:slimBatches(25 batches for each call; run it again with{"cursor":""}untilisDoneis true),npx convex run budget:rebuildTotals, and for the newest batchesnpx convex run previews:backfill '{"batches":3}'. Each download for a preview backfill counts as egress, so do not run it for all batches.
Settings (/settings, gear icon in the top bar)
The Settings page is one global location for the recipe. It applies to all brand workspaces. A workspace cannot override it. Each allow-listed user who is signed in can see and edit it.
Precedence: Settings page value (DB) > Convex env var > code default.
convex/lib/settingsRegistry.tsdeclares each tunable one time (type, range, default, optional env var).- The default prompts and playbook sections come from
convex/lib/defaults.ts. That file usesconvex/lib/specText.ts, whichscripts/gen-spec-text.pymakes from the verbatim code blocks ofdocs/a11-bold-spec.md. - Provider API keys,
ALLOWED_EMAILSandALLOWED_EMAIL_DOMAINare env-only. They never show on the page.
| Tab | What you can change |
|---|---|
| Models | For each LLM step, you set the model, the thinking effort (low, medium, high) and the max tokens. The code default model is anthropic/claude-haiku-5.5. It takes no temperature, so Settings has no temperature. The steps are: brief, asset pick, reference crop, art direction, Original concepts, Wild concepts, compliance rating, concept review, image prompt writer, draft review, text check, brand vision fallback, brand kit analysis. Vision steps show only models that accept images. |
| Image | Sunburst model, draft quality (low), edit quality (high), moderation, background-removal model. The size is always 1152×2048. |
| Pipeline | Market (country, search country code, language, currency), research result counts and page text length, intake retry delay, reference crop rounds, review rewrite rounds, recent Wild metaphors, headline edits fed back, text-fix padding, versions kept, brand extraction. |
| Playbook | All playbook sections (see “Playbook”). |
| Prompts | All prompts of the spec, with their {{placeholders}}. The JSON schema of each reply shows under the prompt. You cannot edit the schema. OpenRouter enforces the schema only when the provider supports strict output. In the JSON-object fallback, the schema is a hint and code normalises the reply. The brand prompts also show the fixed JSON format that the tool adds. |
| Costs & budget | Monthly and per-batch caps. Unit cost estimates: Sunburst image (low, medium, high), background removal, Firecrawl scrape, map and search, the LLM call reservation. A live estimate for each batch. |
| History | Each save makes a version, and the tool keeps the last 20. When you restore an old version, the tool saves it as a new version. |
Each batch keeps a record of its settings (batches.settingsVersion / batches.settingsSnapshot). The automatic steps use that snapshot, also if the settings change during the batch. The operator actions (Swap concept, the silent check after an edit, regenerate, the advanced text-fix and blend) use the current settings. The automatic repairs use the snapshot.
Setup
Prerequisites: Node 22 (CI uses this version), a Convex account, and API keys for Firecrawl, OpenRouter and Replicate.
1. Install and create the Convex project
npm install
npx convex dev # log in, create a project; writes CONVEX_DEPLOYMENT + VITE_CONVEX_URL to .env.local
Keep npx convex dev in operation in one terminal. It pushes the functions and makes convex/_generated again.
2. Convex Auth keys
npx @convex-dev/auth # sets SITE_URL, JWT_PRIVATE_KEY and JWKS on the dev deployment
SITE_URL must be the address of the SPA. For dev, use http://localhost:5173. For prod, see step 6.
3. Access list (email + password)
Sign-in uses the Password provider of Convex Auth. You do not need a Google Cloud setup. Only allow-listed emails can create an account:
npx convex env set ALLOWED_EMAILS "[email protected],[email protected]"
# optional: let anyone on a domain in
npx convex env set ALLOWED_EMAIL_DOMAIN yourcompany.com
The server applies the list in three locations:
- The Password
profile()(sign-up and sign-in). beforeSessionCreation.requireUser()in each query/mutation.
If neither variable is set, no person can sign in. The tool fails closed. If you remove an email from the list, that person loses access at their next request. Passwords must have 10 or more characters.
Note: the tool does not verify emails. For strict access, add the exact emails of teammates. Do not add a full domain.
4. Provider keys and budget
npx convex env set FIRECRAWL_API_KEY fc-...
npx convex env set OPENROUTER_API_KEY sk-or-v1-...
npx convex env set REPLICATE_API_TOKEN r8_...
npx convex env set MONTHLY_SPEND_CAP_USD 20
npx convex env set PER_RUN_SPEND_CAP_USD 2
FIRECRAWL_BASE_URL, OPENROUTER_BASE_URL and REPLICATE_BASE_URL are optional. Do not set them in production. A local test deployment can set them to a mock server. Then the full pipeline runs with no paid calls.
You change all other values on the Settings page (models, prompts, pipeline, cost estimates, caps). The env vars in .env.example also work. They are a fallback with a lower precedence than the Settings page.
5. Run locally
npm run dev # vite on :5173 + convex dev
6. Deploy (Convex static hosting)
Set the same env vars on the production deployment (add --prod). Set SITE_URL to the static site:
npx convex env set --prod SITE_URL https://.convex.site
npx convex env set --prod ALLOWED_EMAIL_DOMAIN yourcompany.com
npx convex env set --prod ALLOWED_EMAILS ... # etc. for every variable above
# JWT_PRIVATE_KEY / JWKS for prod:
npx @convex-dev/auth --prod
Then use one command to build, push the backend and upload the SPA:
npm run deploy # = npx @convex-dev/static-hosting deploy
The app is live at https://.convex.site. The HTTP routing is “app-owned root”. convex/http.ts registers the Convex Auth routes (/api/auth/*, /.well-known/*) first. It registers the static SPA catch-all last.
Production, CI/CD and workflow
| Prod deployment | Convex glad-dodo-78 → app at https://glad-dodo-78.convex.site, API at https://glad-dodo-78.convex.cloud |
| Prod env vars (set) | ALLOWED_EMAILS, FIRECRAWL_API_KEY, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, SITE_URL, JWT_PRIVATE_KEY, JWKS. All tunables come from the Settings page. If the Settings page has no value, the code default applies. |
.github/workflows/ci.yml |
On each PR to main: npm ci → npm run typecheck → npm test → vite build. This workflow does not deploy. |
.github/workflows/deploy.yml |
On each push to main (and on manual dispatch): npx @convex-dev/static-hosting deploy. This pushes the Convex functions and uploads the SPA. It uses the repo secret CONVEX_DEPLOY_KEY (the prod deploy key). |
Workflow rules (for people and AI agents):
- Do not commit to
maindirectly. - Make a branch (
feat/…,fix/…,ci/…,docs/…) and open a PR. - Wait until CI and the Cubic AI review are complete.
- Correct the valid Cubic findings.
- Squash-merge the PR. Do not merge while Cubic is in progress.
- A merge to
mainis the deploy. Do not runnpm run deploymanually. Run it only if CI is broken. - Do not commit secrets or
.env.local. Keep provider keys only in Convex env vars. Keep the deploy key only in GitHub Actions secrets. - If you change the schema, make it backwards compatible with the prod data. Convex validates the schema on deploy. If the schema is not compatible, the deploy fails.
- Cost is important. Keep the code default draft quality at
low. Change it only if the owner tells you to. (The team changes these values on the Settings page.)
Monitoring (Sentry)
The tool sends errors to Sentry (org ai-ad-generator, project ai-ad-generator, US region). The DSN is public because it can only send events. Thus the repo has the DSN as a fallback. Env vars can override it.
- Frontend (
src/main.tsx):@sentry/reactwith anErrorBoundary, React 19 root error hooks andbrowserTracingIntegration(tracesSampleRate0.2).- No session replay.
environment= Vite mode.release= the commit SHA (GITHUB_SHAin CI).- No automatic PII. Sentry gets only the email of the signed-in user.
- DSN:
VITE_SENTRY_DSN(build time) or the committed fallback.
- Backend (
convex/lib/sentry.ts):reportError(err, context)has no dependencies. It usesfetchto POST an event envelope. It never throws, and it has a 3 s timeout.- The code calls it each time a pipeline step, a batch, a product ingest or a brand extraction fails. The steps are: research, assets, art-direction, concepts, improve, render, auto-regenerate, draft-review, auto-edit, text-check, text-fix, cutout and blend. The watchdog also reports stalled batches (with their step) and stalled ads (step
ad-stalled). - Tags:
kind(pipeline|batch|product-ingest|brand-extraction),step,adId/batchId/productId/workspaceId. - The code reports
BUDGET(spend-cap) errors at levelinfo, not as errors. - Mutations cannot use
fetch. Thus they scheduleinternal.monitoring.report. - The code redacts raw model output in error messages.
- DSN:
SENTRY_DSNon the Convex deployment (npx convex env set SENTRY_DSN ... --prod) or the committed fallback. - The default environment is
production. To label a dev deployment, setSENTRY_ENVIRONMENTon it.
- Triage: Hark (the assistant of the owner) reads Sentry through the Sentry MCP server each morning at 09:00 SGT. Hark does these tasks:
- It corrects real bugs through the usual flow: branch → PR → CI + Cubic → merge.
- It resolves noise and expected errors (for example,
BUDGET). - It escalates all issues that touch auth, billing or secrets.
Engineering principles: the team works by poteto’s pstack principles (Lauren Tan, MIT, https://github.com/cursor/plugins/tree/main/pstack). AGENTS.md section 4, “How we work (pstack)”, gives the rules for this repo.
Notes for AI agents
- Before you change
convex/, readAGENTS.md/CLAUDE.mdandconvex/_generated/ai/guidelines.md(official Convex guidance). The Convex agent skills are in.agents/skills. - The recipe is
docs/a11-bold-spec.md. Do not change the prompt text inconvex/lib/specText.tsby hand. Change the spec and runpython3 scripts/gen-spec-text.py. Put changes to the spec (for example the Reels safe zone) inconvex/lib/defaults.ts, with a comment. convex/lib/settingsRegistry.tsdeclares each tunable (precedence: DB > env > default).convex/lib/config.tsis env-only (access, provider keys, provider base URLs). To add a setting:- Add it to the registry, with a default.
- Read it from the resolved
Settingsobject. - The Settings page then shows it automatically.
- Queries/mutations load the settings with
loadSettings(ctx)(convex/lib/settings.ts). Automatic pipeline steps use the snapshot of the batch (settingsFromSnapshot). Operator actions callinternal.settings.loadInternal. Do not read tunables fromprocess.envdirectly. - LLM calls go through
chatJson(convex/lib/openrouter.ts) with aschemaname fromconvex/lib/schemas.ts. The call asks for a strict JSON schema (response_format: json_schema,strict: true) andprovider.require_parameters. If no provider supports it, the call uses JSON-object mode with the schema in the prompt. The code still checks each field (asStr,asConcept, …). It makes a maximum of 3 attempts. It does not try again after aBUDGETerror or a missing API key. - Image calls go through
sunburst()(convex/lib/replicate.ts). It always sends the input object of the spec:aspect_ratio1536x1152 for a draft (band render), 1152x2048 for a whole-image edit (for a crop edit such as a text-fix or the sticker removal: the accepted size nearest to the crop aspect,EDIT_SIZESinconvex/lib/imaging.ts; the crop grows to that aspect first. Sunburst refuses other sizes with a 422 error),quality,output_formatpng,number_of_images1,backgroundopaque,moderation. Do not leave size or quality to the model defaults. - Image work (crop, paste back, composite) is in
convex/lib/imaging.ts("use node", jimp + resvg). Only"use node"files can import it. - Message formats (brief as text, look string, reference block, playbook) are in
convex/lib/compose.ts. The frozen run config is inconvex/lib/recipe.ts. The safe zones are inconvex/lib/frame.ts. - The access control is in
convex/lib/access.ts. Each paid call goes throughconvex/lib/metered.tswith anestUsdfrom a cost setting and thebatchId. - Use
reportError(convex/lib/sentry.ts) with astepname to report new failure paths. In a mutation, scheduleinternal.monitoring.reportinstead. - The tables
runs,ads,adVersionsandfontsare legacy. They stay in the schema only so that prod data validates. Runlegacy:purgeto delete their rows. A later change can then remove them. - To check your work, run
npm run typecheck,npm testandnpx vite build. CI runs the same steps. - The UI primitives are in
src/components/ui/. Use them again. Do not add new component libraries.Buttoniswhitespace-nowrap/shrink-0. Thus, in a row, give the inputs next to itmin-w-0 flex-1. - Auth uses email + password with an allow-list. To give a person access, add their exact email to
ALLOWED_EMAILS(comma-separated) on the prod deployment.
UI components (Fluid Functionalism)
components.json registers the @fluid registry. The Fluid components in src/components/ui/
(button, card, badge, tabs, dialog, tooltip, banner, thinking-indicator) came from these commands:
npx shadcn@latest registry add @fluid
npx shadcn@latest add @fluid/button @fluid/card @fluid/badge @fluid/tabs @fluid/dialog @fluid/tooltip @fluid/thinking-indicator @fluid/banner
The repo has two local changes:
- Upstream,
card.tsximportsnext/link. For Vite, a plain `` shim replaces it. src/lib/utils.tsusesclsx+tailwind-merge. The registry changed it to use acnpackage that does not exist.
Plain inputs are in src/components/ui/field.tsx. To add more components, use npx shadcn@latest add @fluid/.
Motion (transitions.dev)
The UI transitions come from transitions.dev by Jakub Antalik (free tier). Thank you to Jakub Antalik for these transitions.
- The CSS is in
src/styles/transitions.css.src/index.cssimports it. - The React helpers are in
src/lib/motion.tsx. The menu dropdown is insrc/components/ui/menu-dropdown.tsx. - The app uses these free transitions: modal open and close, menu dropdown, panel reveal, card resize, text states swap, number pop-in, icon swap, success check and error state shake. The app uses no Pro transitions.
- Each transition has a
prefers-reduced-motion: reduceguard. When the user’s system asks for reduced motion, the content changes immediately, with no movement. - The Transitions.dev License allows use in a product. Do not publish
src/styles/transitions.cssas a separate transitions library.
Project layout
docs/
a11-bold-spec.md the recipe (verbatim prompts)
ad-research.md research rules
convex/
schema.ts tables: workspaces, brandKits, products, batches, batchResearch, batchAds, adDrafts, visionCopies, playbookEdits,
comments, spend, spendTotals, settingsVersions, modelCatalog (+ auth; + legacy runs, ads, adVersions, fonts)
auth.ts, auth.config.ts, http.ts, convex.config.ts
workspaces.ts list/create/re-extract (one workspace per brand)
brandExtract.ts escalating brand extraction (action)
brandKits.ts versions (keep 5), roll back, edit
products.ts add/list/read again
productIngest.ts intake: Shopify JSON + Firecrawl, blocked-page retry (action)
batches.ts batch queries, start (budget pre-flight), approval, text-fix, blend, Mark used, versions
pipeline.ts research → brief → assets → art direction → concepts; render → review → auto fix → text check;
text-fix, cut-out, blend ("use node" actions)
legacy.ts purge the rows of the removed pipeline
settings.ts global settings: get/save/restore/history (keep 20), OpenRouter model catalogue
comments.ts, budget.ts budget: reserve/settle keep spendTotals; summary reads one document
migrations.ts, previews.ts one-time data moves (slimBatches, preview backfill); run by hand after deploy
monitoring.ts Sentry report action (for mutations, which can't fetch)
probe.ts internal image probe: one metered Sunburst call (spend kind "probe"); run from the admin API
lib/ settingsRegistry + defaults + specText (tunables, prompts, playbook), schemas (strict JSON),
compose (message formats), recipe, frame (safe zones), estimate, imaging, config, access,
provider clients, metering, sentry
src/
App.tsx, main.tsx, pages/, features/ (batch/: BatchesPanel, ConceptCard, DraftCard, TextFixDialog, BlendDialog), components/ui/, lib/
tests/ behavior tests (Vitest): price lint, safe zones, JSON repair, budget
scripts/
gen-spec-text.py makes convex/lib/specText.ts from the spec
Scripts
npm run dev: starts the frontend and backend dev servers.npm run build: does a type check and a Vite build.npm run typecheck: does a type check of the app, Convex and the tests.npm test: runs the behavior tests intests/(Vitest, one run). The tests make no network calls and no paid calls.tests/budget.test.tsandtests/cost.test.tsuseconvex-test, an in-memory Convex backend.npm run deploy: deploys the backend and the static site to Convex production.python3 scripts/convex-usage.py: prints the Convex cost of each function for each call, from the prod execution log (read-only; needsCONVEX_DEPLOY_KEYin the environment).
Known limitations (MVP)
- Sunburst has no published price for each image. The image costs are estimates (Settings → Costs & budget). Check the Replicate bill.
- jimp cannot read WebP. The tool keeps a WebP candidate photo without a crop. It drops a WebP photo that needs a crop (but it keeps a WebP packshot without a crop).
- The claim filter compares URLs by host and path. A claim from a brand page that the search did not return is dropped, also if it is true.
- The text check and the draft review are vision models. They can miss errors. Use the text-fix tool for text that they miss.
- For successful Firecrawl/Replicate calls, the cost records use estimates. For OpenRouter, the cost records use the cost that the provider reports, when it is available.
Рекомендуемые инструменты
Попробуйте другой запрос или уберите фильтр.
Установка
npx skillfish add han669669/ai-ad-generator