name: create-landing-page-brunobracaioli
description: Gera de forma 100% autônoma e headless o RASCUNHO de uma landing page profissional de alta conversão para um PRODUTO do cliente brunobracaioli (catálogo em lista-de-produtos) e o escreve AO VIVO no Supabase como blocos editáveis (landing_pages.settings + .theme + landing_page_sections), depois ENFILEIRA a publicação (job landing_publish) que faz build + deploy no Cloudflare Pages. Fluxo: brief do produto (catálogo) → arquitetura de conversão → copy long-form pt-BR → hero/OG → escrita ao vivo no Supabase → enfileira publish. Use quando pedirem "criar landing page para brunobracaioli" (ex.: produto cca ou imersao-agencia), ou quando disparada via Ultron/headless (claude -p --dangerously-skip-permissions ".claude/skills/create-landing-page-brunobracaioli product=cca nome=cca"). NÃO faz build nem deploy aqui (isso é do job landing_publish / skill publish-landing-page-*). NÃO cria campanha Meta.
argument-hint: "product= nome= [ref-url=...] [cart-state=open] [noindex=1]"
allowed-tools: Read, Bash, Glob, Write, Agent, Skill
Skill: /create-landing-page-brunobracaioli
Gera, de ponta a ponta e sem intervenção humana, o rascunho editável de uma landing
page profissional de alta conversão para o cliente brunobracaioli e enfileira a
publicação no Cloudflare Pages sob <nome>.b2tech.io:
brief do catálogo → arquitetura de conversão → copy long-form pt-BR → visual hero/OG →
escrita ao vivo no Supabase (blocos editáveis) → enfileira landing_publish.
Esta é a superfície de geração da SPEC-012 (CMS editável). A fonte de verdade do rascunho passa a ser o Supabase:
landing_pages.settings+landing_pages.theme+ as linhaslanding_page_sections(uma por bloco). O operador (UI) e o Ultron (voz) editam esses blocos depois; publicar (joblanding_publish→ skillpublish-landing-page-*) serializa o rascunho →next build→wrangler deploy. Esta skill NÃO builda nem deploya — só popula o rascunho e enfileira o publish.Disparada pela fila
agent_jobs(ADR 0009 / 0012) no runner Fly. Toda a inteligência está aqui; o runner é casca fina (timeout claude -p --dangerously-skip-permissions ...). Spec:docs/specs/SPEC-012-landing-page-editor.md(+ SPEC-011 geração). ADRs: 0012 (hosting), 0013 (design), 0014 (catálogo), 0015 (rascunho no Supabase), 0017 (pacote render).
1. Modo de operação — AUTONOMIA TOTAL (leia primeiro)
Roda em headless (claude -p). Regras inegociáveis:
- NUNCA chame
AskUserQuestion. Sem humano, a sessão entra em deadlock. Em qualquer dúvida ou erro: decida sozinho com os defaults da §3, registre no manifest (Passo 8) e siga em frente. - Resolva erros por conta própria. Só aborte se for impossível prosseguir — e mesmo aí,
grave o manifest com
verified:falseexplicando o bloqueio. Se já marcoudraft_status='generating', reponha parareadyantes de sair (Passo 7-abort). - Cliente é fixo:
brunobracaioli. Não generalize. - Supabase é via REST/curl com
SUPABASE_SECRET_KEY(service_role). NÃO use o MCP do Supabase: ele é OAuth-gated e não autentica no runner headless. Toda leitura/escrita no banco usacurlno endpoint REST (mesmo padrão descripts/poll-agent-jobs.she da skillpublish-landing-page-*). - Esta skill NÃO faz build nem deploy. Não roda
next build,tsc, nemwrangler. Ela escreve o rascunho no Supabase, gera as imagens noLP_DIR/public, e enfileira o joblanding_publish(que faz serialize → build → deploy). Segredos de deploy (CLOUDFLARE_*) não são necessários aqui. - Limites duros / segurança:
noindex=1por padrão. A página nasce em preview (não indexável). Go-live (noindex=0) só se um argumento pedir explicitamente; o valor é repassado ao publish.SUPABASE_SECRET_KEYnunca vai para o manifest, logs,operation_logs, stdout, ou qualquer arquivo commitado. Nunca a ecoe.- Prefira reusar scrape/copy/imagens já gerados hoje a regerar (cap de LLM).
2. Constantes do cliente + produto (catálogo)
Cliente — fonte de verdade: .claude/skills/lista-de-clientes/SKILL.md. No início, faça
lookup de clients WHERE slug='brunobracaioli' no Supabase (REST) para o client_id (uuid) —
não hardcode.
| Campo | Valor |
|---|---|
| slug | brunobracaioli |
| Domínio | <nome>.b2tech.io (zona b2tech.io na conta CF) |
| Materiais | .claude/materiais-das-empresas/brunobracaioli/ (logo, mascote, exemplo-de-ads, produtos/) |
| Marca | navy #0A0F1A→#0E1422, laranja #FF6B1A |
| Tracking | IDs públicos (multi) semeados de lista-de-clientes: META_PIXELS/GA4_IDS/GOOGLE_ADS_IDS, consent-gated. Segredos CAPI ficam no cofre isolado (Fase 2). Ver ADR 0021 / SPEC-015 |
Produto — NÃO é hardcoded. Vem do catálogo (skill lista-de-produtos, ADR 0014):
o brief estruturado fica em ${MAT}/produtos/${product}.json e é lido via Read (headless-safe;
o .claude/ é COPY-ado para a imagem Fly). O arg product=<slug> seleciona qual (default cca).
O brief traz tudo que os subagents precisam: name, shortCode, tagline, positioning,
tone, offer (priceCents, anchorPriceCents, checkoutUrl, waitlistUrl, cartState, deadline,
payments, guarantee, scarcity), o conteúdo de copy (dores, mecanismo, stack, prereqs,
agenda, entregaveis, persona, comparison, autoridade, numeros, faqHints), seo,
assets (logo/foto do instrutor), defaultSubdomain e brand (alimenta theme). Nunca
invente dados de produto — use o brief. Produtos atuais: cca e imersao-agencia.
3. Defaults autônomos (decisões já tomadas — não reabrir)
| Decisão | Valor | Por quê |
|---|---|---|
product (slug do catálogo) |
cca (default) |
Seleciona o brief ${MAT}/produtos/${product}.json. Se o arquivo não existir → aborta (verified:false). |
nome (subdomínio) |
obrigatório (sem default) | Vira <nome>.b2tech.io + projeto CF b2tech-<nome> + landing_pages.subdomain. Sem nome → aborta. Nunca assuma cca (é uma página de produção). O brief tem defaultSubdomain, mas nome ainda precisa ser passado explicitamente. |
| Sink do conteúdo | Supabase (rows landing_page_sections + settings/theme) |
SPEC-012 — fonte de verdade do rascunho. NÃO escreve messages/pt.json/content-spec.json (o publish serializa do Supabase). |
| Build + deploy | job landing_publish (enfileirado no fim) |
Esta skill não builda/deploya — ver §1.5. |
| Template | landing-pages/_template/ → landing-pages/<nome>/ (só p/ imagens + reuso no publish) |
Clonável |
| Seções | enum: hero·urgency·problem·comparison·solution·features·curriculum·stats·proof·logos·persona·authority·offer·guarantee·faq·finalCta·footer | Template (ADR 0013) |
| Design system | claro + blocos escuros, Inter/DM Sans (@fontsource), accent laranja, motion leve | ADR 0013 |
cart-state |
open (ou do brief) |
closed → CTA waitlist WhatsApp |
noindex |
1 (preview) |
Repassado ao publish; go-live exige noindex=0 |
| Tom da copy | tech-hacker, pt-BR, sênior (sem clichês) | Marca |
Validação de nome: ^[a-z0-9-]{2,40}$ (vira subdomínio + nome de projeto CF). Se
inválido → manifest verified:false e sair.
Args via $ARGUMENTS (key=value): nome (obrigatório), product (default cca),
ref-url (opcional), cart-state, noindex. Sem nome → aborta (manifest verified:false).
Nunca use cca como fallback de nome. checkout-url/cart-state/deadline vêm do brief do
produto; um arg explícito, se passado, sobrescreve o brief.
4. Passo a passo
Passo 0 — Setup
Em uma chamada Bash:
DATE=$(TZ=America/Sao_Paulo date +%F),STAMP=$(TZ=America/Sao_Paulo date +%Y%m%d-%H%M).REPO="$(pwd)"(no runner é/app). Guarde — você vaicdpara dirs de LP.- Env (REST do Supabase + imagens):
[ -f .env.local ] && set -a && eval "$(tr -d '\r' < .env.local)" && set +a || true SUPABASE_URL="$(printf '%s' "${SUPABASE_URL:-}" | tr -d '[:space:]')" SUPABASE_KEY="$(printf '%s' "${SUPABASE_SECRET_KEY:-${SUPABASE_SERVICE_ROLE_KEY:-}}" | tr -d '[:space:]')" REST="${SUPABASE_URL%/}/rest/v1"OPENAI_API_KEYé necessário para oimage-generate(Passo 6). SeSUPABASE_URL/SUPABASE_KEYvazios → manifestverified:false(errors:["supabase creds ausentes"]), sair. - Parse dos args; aplicar defaults da §3 (
product=cca).nomeé obrigatório: se ausente → manifestverified:false(errors:["nome obrigatório"]) e sair. Validarnome =~ ^[a-z0-9-]{2,40}$eproduct =~ ^[a-z0-9-]{2,40}$. Nunca assumirccacomonome. - Paths:
LP_DIR="${REPO}/landing-pages/${nome}",TRY_DIR=tentativas-geracao-de-campanhas,MAT=.claude/materiais-das-empresas/brunobracaioli,BRIEF_FILE="${MAT}/produtos/${product}.json".mkdir -p "${TRY_DIR}" "${LP_DIR}/.gen".GEN=$(mktemp -d)para corpos REST intermediários. - Carregar o brief do produto (catálogo, ADR 0014):
Read${BRIEF_FILE}→ objetoPRODUCT. Se não existir → manifestverified:false(errors:["produto '${product}' não está no catálogo (${MAT}/produtos/)"]) e sair. Derivar (viajqdoBRIEF_FILE):PROD_NAME=.name,SHORT=.shortCode,PRICE_CENTS=.offer.priceCents,CHECKOUT_URL=.offer.checkoutUrl,WAITLIST_URL=.offer.waitlistUrl,CART=.offer.cartState(argcart-statesobrescreve),DEADLINE=.offer.deadline,DEFAULT_SUB=.defaultSubdomain. OPRODUCTinteiro alimenta os subagents (Passos 3/4). - Resolver os assets do brief (ADR 0014/0018) — fonte de verdade é
assets.*, com fallback de convenção (caminhos relativos ao repo). Use o que o brief declara; só caia pro padrão se o campo faltar. Resolva e confira existência:
Asset ausente (resolve_asset() { # $1 = jq path no brief $2 = caminho-convenção de fallback local p; p=$(jq -r "$1 // \"\"" "${BRIEF_FILE}") [ -n "${p}" ] && [ "${p}" != "null" ] || p="$2" [ -f "${REPO}/${p}" ] && printf '%s' "${REPO}/${p}" || printf '' # vazio = ausente } LOGO_SRC=$(resolve_asset '.assets.logo' "${MAT}/logo/logo.png") INSTRUCTOR_SRC=$(resolve_asset '.assets.instructorPhoto' "${MAT}/logo/foto-do-infoprodutor/bruno-bracaioli.png") HERO_IMG_SRC=$(resolve_asset '.assets.heroImage' "") # retrato do hero (lado direito); opcional, sem fallback STAGE_MODEL_SRC=$(resolve_asset '.assets.stage3d.model' "") # modelo .glb do painel 3D; opcional, sem fallback STAGE_LOGO_SRC=$(resolve_asset '.assets.stage3d.logo' "") # logo do treinamento (reveal no painel); opcional STAGE_RAIN=$(jq -r '.assets.stage3d.rain // true' "${BRIEF_FILE}") # chuva Matrix on/off (default on) MASCOTE_SRC=$(resolve_asset '.assets.mascote' "${MAT}/mascote/claude-lendo.png") EXAMPLE_ADS_DIR=$(jq -r '.assets.exampleAds // ""' "${BRIEF_FILE}"); [ -n "${EXAMPLE_ADS_DIR}" ] || EXAMPLE_ADS_DIR="${MAT}/exemplo-de-ads/"*_SRCvazio) não aborta — degrada (sem logo/foto). Esses caminhos alimentam o Passo 6 (refs doimage-prompt-generator, cópia da foto, upload da logo). - Constantes derivadas:
NOINDEX_BOOL=$([ "${noindex:-1}" = "0" ] && echo false || echo true) # Tracking = IDs PÚBLICOS apenas (vão pro content-spec.json do site estático). Semeie das # listas do cliente em `lista-de-clientes` (META_PIXELS/GA4_IDS/GOOGLE_ADS_IDS); o operador # refina por LP na aba "Tracking" do editor. Mantém fb_pixel_id/ga4_id = 1º item p/ back-compat. # ⚠️ NUNCA coloque CAPI token / GA4 API secret aqui — são segredos (cofre isolado, Fase 2). META_PIXELS='["653995666521954"]' GA4_IDS='["G-Z60CJ7W2Z8"]' GOOGLE_ADS_IDS='[]' TRACKING=$(jq -cn \ --argjson mp "${META_PIXELS}" --argjson g4 "${GA4_IDS}" --argjson ga "${GOOGLE_ADS_IDS}" \ '{fb_pixel_id: ($mp[0] // ""), ga4_id: ($g4[0] // ""), consent_key: "b2tech_consent_v1", meta_pixels: $mp, ga4_ids: $g4, google_ads_ids: $ga}')
Helper REST (use em todas as chamadas ao Supabase): sempre os headers
apikey: ${SUPABASE_KEY}eAuthorization: Bearer ${SUPABASE_KEY},--max-time 15. Para escrita use-H "Content-Type: application/json"; para upsert-H "Prefer: resolution=merge-duplicates,return=representation"+?on_conflict=<cols>. Trate corpo vazio/erro como falha transitória (re-tente 1x antes de abortar).
Passo 1 — Client lookup + upsert products + upsert landing_pages (draft generating)
- Client lookup (REST):
Vazio → manifestCLIENT=$(curl -fsS "${REST}/clients?slug=eq.brunobracaioli&select=id,materials_path" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" --max-time 15) CLIENT_ID=$(echo "${CLIENT}" | jq -r '.[0].id // empty')verified:false(errors:["cliente brunobracaioli não encontrado"]), sair. - Upsert
products(read-model do catálogo, ADR 0016)ON CONFLICT (client_id,slug):PBODY=$(jq -nc --arg cid "${CLIENT_ID}" --arg slug "${product}" --arg name "${PROD_NAME}" \ --arg bp "${BRIEF_FILE}" --arg ds "${DEFAULT_SUB}" --slurpfile brief "${BRIEF_FILE}" \ '{client_id:$cid, slug:$slug, name:$name, brief_path:$bp, brief:$brief[0], default_subdomain:(if $ds=="" or $ds=="null" then null else $ds end), status:"active"}') PROW=$(curl -fsS -X POST "${REST}/products?on_conflict=client_id,slug" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "Content-Type: application/json" -H "Prefer: resolution=merge-duplicates,return=representation" \ --max-time 15 -d "${PBODY}") PRODUCT_ID=$(echo "${PROW}" | jq -r '.[0].id // empty') theme(tokens de design por LP) a partir debrief.brand(navy→navy900, navyAlt→navy800, orange→orange; fonts/scale ficam default — editor ajusta na Wave 4):THEME=$(jq -c '{colors: ({} + (if .brand.orange then {orange:.brand.orange} else {} end) + (if .brand.navy then {navy900:.brand.navy} else {} end) + (if .brand.navyAlt then {navy800:.brand.navyAlt} else {} end))}' "${BRIEF_FILE}")settingsparcial (o resto — seo/cartClosed — entra no Passo 4, quando a copy existe):SETTINGS=$(jq -nc --arg sub "${nome}" --arg name "${SHORT}" --arg product "${PROD_NAME}" \ --arg site "https://${nome}.b2tech.io" --argjson price "${PRICE_CENTS:-null}" \ --arg checkout "${CHECKOUT_URL}" --arg waitlist "${WAITLIST_URL}" \ --arg cart "${CART}" --argjson ni "${NOINDEX_BOOL}" --arg deadline "${DEADLINE}" \ --argjson tracking "${TRACKING}" \ '{subdomain:$sub, name:$name, product:$product, site_url:$site, tracking:$tracking, checkout_url:$checkout, price_cents:$price, cart_state:$cart, noindex:$ni} + (if $waitlist=="" or $waitlist=="null" then {} else {waitlist_url:$waitlist} end) + (if $deadline=="" or $deadline=="null" then {} else {deadline:$deadline} end)')- Upsert
landing_pagesON CONFLICT (subdomain)(colunas NOT NULL: client_id, name, subdomain, fqdn, url, repo_path):
SemLBODY=$(jq -nc --arg cid "${CLIENT_ID}" \ --argjson pid "$([ -n "${PRODUCT_ID}" ] && echo "\"${PRODUCT_ID}\"" || echo null)" \ --arg name "${SHORT}" --arg sub "${nome}" --arg fqdn "${nome}.b2tech.io" \ --arg url "https://${nome}.b2tech.io" --arg repo "landing-pages/${nome}" \ --arg cfp "b2tech-${nome}" --argjson theme "${THEME}" --argjson settings "${SETTINGS}" \ --arg checkout "${CHECKOUT_URL}" --argjson price "${PRICE_CENTS:-null}" \ --arg cart "${CART}" --argjson ni "${NOINDEX_BOOL}" --argjson tracking "${TRACKING}" \ '{client_id:$cid, product_id:$pid, name:$name, subdomain:$sub, fqdn:$fqdn, url:$url, repo_path:$repo, cloudflare_project_id:$cfp, theme:$theme, settings:$settings, draft_status:"generating", cart_state:$cart, noindex:$ni, tracking:$tracking, checkout_url:$checkout, price_cents:$price, status:"draft"}') LROW=$(curl -fsS -X POST "${REST}/landing_pages?on_conflict=subdomain" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "Content-Type: application/json" -H "Prefer: resolution=merge-duplicates,return=representation" \ --max-time 15 -d "${LBODY}") LP_ID=$(echo "${LROW}" | jq -r '.[0].id // empty')LP_ID→ manifestverified:false(errors:["falha ao upsert landing_pages"]), sair. A partir daqui, qualquer abort DEVE repordraft_status='ready'(Passo 7-abort).
Passo 2 — Scrape da referência (OPCIONAL, idempotente)
O brief do catálogo (PRODUCT) é a fonte primária — não precisa de scrape. Só rode scrape
se ref-url for passado (para suplementar tom/visual de uma referência externa):
Agent(subagent_type="scrape-extractor")comref-url→ salve em${LP_DIR}/.gen/scrape.json. Semref-url→scrape=null.
Passo 3 — Arquitetura de conversão → INSERT das linhas de seção
Agent(subagent_type="landing-page-architect")passando o brief do produto (catálogo). MapeiePRODUCTpara o contratoproduct(estendido) +scrapeopcional:
→ JSON de arquitetura ({ "scrape": <scrape.json ou null>, "product": { "name": "<PROD_NAME>", "shortCode": "<SHORT>", "priceCents": <PRICE_CENTS>, "anchorPriceCents": <PRODUCT.offer.anchorPriceCents>, "checkoutUrl": "<CHECKOUT_URL>", "cartState": "<CART>", "deadline": "<DEADLINE>", "tagline": "<PRODUCT.tagline>", "positioning": "<PRODUCT.positioning>", "offerDetails": "<PRODUCT.whatItIs>", "dores": <PRODUCT.dores>, "mecanismo": <PRODUCT.mecanismo>, "stack": <PRODUCT.stack>, "prereqs": <PRODUCT.prereqs>, "agenda": <PRODUCT.agenda>, "entregaveis": <PRODUCT.entregaveis>, "persona": <PRODUCT.persona>, "comparison": <PRODUCT.comparison>, "autoridade": <PRODUCT.autoridade>, "numeros": <PRODUCT.numeros>, "scarcity": "<PRODUCT.offer.scarcity>", "guarantee": "<PRODUCT.offer.guarantee>" }, "constraints": {"language": "<PRODUCT.language>", "style": "<PRODUCT.tone>", "maxSections": 17} }sections[]comtype/order/goal,heroAngle, CTA,seoIntent). Salve em${LP_DIR}/.gen/architecture.json. Se vier{"error":...}→ repordraft_status='ready', manifestverified:false, sair.- INSERT das rows
landing_page_sections— uma por seção da arquitetura,fieldsvazio (a copy preenche no Passo 4). Idempotente:ON CONFLICT (landing_page_id,type)atualiza sóposition/enabled(sem mandarfields, para não apagar copy de uma re-run):SECROWS=$(jq -c --arg lp "${LP_ID}" \ '[.sections[] | {landing_page_id:$lp, type:.type, position:(.order-1), enabled:true, updated_by:"generator"}]' \ "${LP_DIR}/.gen/architecture.json") curl -fsS -X POST "${REST}/landing_page_sections?on_conflict=landing_page_id,type" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "Content-Type: application/json" -H "Prefer: resolution=merge-duplicates,return=minimal" \ --max-time 15 -d "${SECROWS}" >/dev/null N_SECTIONS=$(jq '.sections | length' "${LP_DIR}/.gen/architecture.json")
Passo 4 — Copy long-form → UPDATE de fields por seção + settings
Agent(subagent_type="lp-copywriter")com{architecture, product:<mesmo objeto do Passo 3>, scrape:<ou null>, tone:"<PRODUCT.tone>", language:"<PRODUCT.language>"}→ copy JSON no shape demessages/pt.json(incluiseo,hero,sections.*,offer,faq(array),finalCta,cartClosed,footer). Salve em${LP_DIR}/.gen/copy.json. A copy sai do brief — não inventar dados. Se vier{"error":...}→ repordraft_status='ready', manifestverified:false, sair.- UPDATE de
fieldspor seção (cada PATCH é um marco de progresso visível no dashboard). O mapeamento é o inverso do serializer (packages/lp-render/src/serialize.ts):hero/offer/finalCta/footer→ o objeto direto;faq→{items:<array>}; as seções "middle" (urgency/problem/comparison/solution/features/curriculum/stats/proof/logos/persona/authority/guarantee) → o objeto sobsections.<type>. PATCH só casa rows que existem (as que o Passo 3 criou); chaves sem row viram no-op:jq -c '({hero:.hero, offer:.offer, finalCta:.finalCta, footer:.footer, faq:{items:.faq}} + (.sections // {})) | to_entries[] | select(.value != null)' \ "${LP_DIR}/.gen/copy.json" > "${GEN}/fieldmap.jsonl" while IFS= read -r entry; do t=$(echo "${entry}" | jq -r '.key') [[ "${t}" =~ ^[a-zA-Z]+$ ]] || continue # Deterministic normalization to the canonical lp-render contract (defense at the write # boundary — the LLM copywriter can drift; the DB must NOT store render-breaking shapes). # Mirrors normalizeSectionFields() in serialize.ts: heading←headline, card desc←body, and # problem.bullets coerced to STRINGS (objects {title,body} crash React #31 and the publish build). fv=$(echo "${entry}" | jq -c --arg t "${t}" ' def headingSections: ["problem","comparison","solution","features","curriculum","stats","proof","logos","persona","guarantee","offer"]; def coerceCard: if type=="object" and (has("desc")|not) and (.body|type=="string") then .desc=.body else . end; .value | (if (headingSections|index($t)) and has("headline") and (has("heading")|not) then .heading=.headline | del(.headline) else . end) | (if $t=="problem" then (if has("subhead") and (has("body")|not) then .body=.subhead | del(.subhead) else . end) | (if (.bullets|type)=="array" then .bullets |= map(if type=="string" then . else ([.title,.body]|map(select(type=="string" and .!=""))|join(" — ")) end) else . end) else . end) | (if ($t=="features" or $t=="persona") and (.items|type)=="array" then .items |= map(coerceCard) else . end) | (if $t=="curriculum" and (.modules|type)=="array" then .modules |= map(coerceCard) else . end) ') curl -fsS -X PATCH "${REST}/landing_page_sections?landing_page_id=eq.${LP_ID}&type=eq.${t}" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "Content-Type: application/json" -H "Prefer: return=minimal" --max-time 15 \ -d "$(jq -nc --argjson f "${fv}" '{fields:$f, updated_by:"generator"}')" >/dev/null done < "${GEN}/fieldmap.jsonl" - UPDATE de
landing_pages.settings(substituição completa — agora comseo+cartClosedda copy, sobre o parcial do Passo 1). O publish valida quesettingstem subdomain/site_url/seo/tracking/checkout_url/price_cents/cart_state/noindex/cartClosed:SETTINGS_FULL=$(jq -nc --arg sub "${nome}" --arg name "${SHORT}" --arg product "${PROD_NAME}" \ --arg site "https://${nome}.b2tech.io" --argjson price "${PRICE_CENTS:-null}" \ --arg checkout "${CHECKOUT_URL}" --arg waitlist "${WAITLIST_URL}" \ --arg cart "${CART}" --argjson ni "${NOINDEX_BOOL}" --arg deadline "${DEADLINE}" \ --argjson tracking "${TRACKING}" --slurpfile copy "${LP_DIR}/.gen/copy.json" \ '{subdomain:$sub, name:$name, product:$product, site_url:$site, tracking:$tracking, checkout_url:$checkout, price_cents:$price, cart_state:$cart, noindex:$ni, seo: ($copy[0].seo // {title:"",description:""}), cartClosed: ($copy[0].cartClosed // {})} + (if $waitlist=="" or $waitlist=="null" then {} else {waitlist_url:$waitlist} end) + (if $deadline=="" or $deadline=="null" then {} else {deadline:$deadline} end)') curl -fsS -X PATCH "${REST}/landing_pages?id=eq.${LP_ID}" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "Content-Type: application/json" -H "Prefer: return=minimal" --max-time 15 \ -d "$(jq -nc --argjson s "${SETTINGS_FULL}" '{settings:$s}')" >/dev/null
Passo 5 — Scaffold do template (para o publish reusar; não builda aqui)
- Se
${LP_DIR}/package.jsonnão existe:cp -r "${REPO}/landing-pages/_template/." "${LP_DIR}/"(use a forma/.; copiar sem o/.aninha o template). Removaout//.next/se vierem. - No runner Fly o
_templatejá traznode_modulespré-bakeado (incluitsx+ o symlink@b2tech/lp-render); ocpos leva junto → o joblanding_publish(mesma máquina) achapackage.json+public/presentes e pula o scaffold e onpm ci. Esta skill não escrevemessages/pt.json/content-spec.json(o publish serializa do Supabase).
Passo 6 — Visual hero + OG + foto do instrutor → ${LP_DIR}/public + Storage (idempotente, best-effort)
Gera os visuais localmente E os persiste no bucket público landing-assets + grava as URLs
no Supabase, para que sobrevivam a republish/edição (SPEC-012 Wave 4). As imagens passam a
ser renderizadas na página: settings.logo (logo no topo do hero), hero.image (visual do
hero), authority.image (foto do instrutor), e settings.seo.ogImage (preview social). Os
caminhos de origem (LOGO_SRC/INSTRUCTOR_SRC/MASCOTE_SRC/EXAMPLE_ADS_DIR) vêm de
assets.* do brief (Passo 0). Falha de imagem/upload não quebra o publish
(images.unoptimized) — degrada para texto.
- Reuse: se
${LP_DIR}/public/hero.pngeog.pngjá existem do dia, pule a geração. Senão:Agent(subagent_type="image-prompt-generator")(variant A) com:{scrape, brief:<PRODUCT (tagline/positioning/numeros)>, aspectRatio:"1920x1080", referenceImagePaths:[ <LOGO_SRC>, <MASCOTE_SRC>, <EXAMPLE_ADS_DIR>/*.png ] (use os*_SRCresolvidos no Passo 0 — pule os vazios), configHints:{brandName:"<PROD_NAME>"}}→ prompt do hero.Skill(skill="image-generate", args="prompt-file=<prompt> aspect=1.91:1 out-dir=${LP_DIR}/public out-name=hero")→hero.png. Deriveog.png(1200×630) do hero (ou gere um segundo). Registre o custo estimado (manifest doimage-generate) para oimage_cost_usd_estimate(Passo 8).
- Foto do instrutor (seção authority): se
INSTRUCTOR_SRC(Passo 0) existe, copie-o para${LP_DIR}/public/instrutor.jpg([ -n "${INSTRUCTOR_SRC}" ] && cp "${INSTRUCTOR_SRC}" "${LP_DIR}/public/instrutor.jpg"). Sem foto, o template degrada para painel só-texto. 2b. Logo da marca: seLOGO_SRC(Passo 0) existe, copie-o para${LP_DIR}/public/logo.png([ -n "${LOGO_SRC}" ] && cp "${LOGO_SRC}" "${LP_DIR}/public/logo.png"). A logo é renderizada no topo do hero (settings.logo) — ver Passo 5 da persistência abaixo. Sem logo, degrada. 2c. Retrato do hero (lado direito, layout split): seHERO_IMG_SRC(Passo 0) existe, copie-o para${LP_DIR}/public/hero-portrait.png([ -n "${HERO_IMG_SRC}" ] && cp "${HERO_IMG_SRC}" "${LP_DIR}/public/hero-portrait.png"). Quando há retrato, ele vira o campohero.portrait(distinto dehero.image) e o hero renderiza em 2 colunas: copy à esquerda, retrato à direita. Sem retrato,hero.portraitfica ausente e o hero permanece em coluna única centralizada, com ohero.pnggerado por IA comohero.image(banner abaixo do CTA) — comportamento inalterado para produtos semassets.heroImage(ex.:cca). 2d. Modelo 3D do painel (acima do hero): seSTAGE_MODEL_SRC(Passo 0) existe, ele será subido direto pro Storage no Passo 4 (NÃO copie propublic/— o painel carrega da URL do Storage, e o.glbtem ~3MB; copiar propublic/só inflaria o deploy do Cloudflare à toa). Quando há modelo, virasettings.stage3d→ o template renderiza um painel 3D cinematográfico (holograma ciano + chuva Matrix, pinned-scroll) acima do hero. Sem modelo,settings.stage3dfica ausente e a página não tem painel (inalterado p/cca). - Bucket (idempotente — ignore "já existe"): garanta
landing-assetspúblico:curl -sS -X POST "${SUPABASE_URL%/}/storage/v1/bucket" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "Content-Type: application/json" --max-time 15 \ -d '{"id":"landing-assets","name":"landing-assets","public":true}' >/dev/null 2>&1 || true - Upload de cada PNG/JPG presente (
x-upsert: truepara regravar numa re-run), caminho estável${LP_ID}/<file>→ ecoa a URL pública (vazio se o arquivo não existe ou o upload falhou):
(upload_asset() { # $1=arquivo local $2=nome no bucket $3=content-type [ -f "$1" ] || return 1 curl -sS -X POST "${SUPABASE_URL%/}/storage/v1/object/landing-assets/${LP_ID}/$2" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "x-upsert: true" -H "Content-Type: $3" --max-time 30 --data-binary @"$1" >/dev/null 2>&1 \ && printf '%s' "${SUPABASE_URL%/}/storage/v1/object/public/landing-assets/${LP_ID}/$2" } OG_URL=$(upload_asset "${LP_DIR}/public/og.png" og.png image/png || true) INSTR_URL=$(upload_asset "${LP_DIR}/public/instrutor.jpg" instrutor.jpg image/jpeg || true) LOGO_URL=$(upload_asset "${LP_DIR}/public/logo.png" logo.png image/png || true) # hero.image = SEMPRE o hero.png gerado por IA (banner da coluna única + base do og/preview social). HERO_URL=$(upload_asset "${LP_DIR}/public/hero.png" hero.png image/png || true) # hero.portrait = retrato recortado (assets.heroImage), SE fornecido → dispara o layout split. PORTRAIT_URL="" if [ -f "${LP_DIR}/public/hero-portrait.png" ]; then PORTRAIT_URL=$(upload_asset "${LP_DIR}/public/hero-portrait.png" hero-portrait.png image/png || true) fi # modelo 3D do painel (.glb) — subido DIRETO da origem (sem cópia pro public/) STAGE_URL="" if [ -n "${STAGE_MODEL_SRC}" ]; then STAGE_URL=$(upload_asset "${STAGE_MODEL_SRC}" stage.glb model/gltf-binary || true) fi # logo do treinamento (reveal no painel) — subido direto da origem STAGE_LOGO_URL="" if [ -n "${STAGE_LOGO_SRC}" ]; then STAGE_LOGO_URL=$(upload_asset "${STAGE_LOGO_SRC}" stage-logo.png image/png || true) fihero.imageeog.pngsaem sempre do hero gerado por IA; o retrato, quando existe, vai num campo separadohero.portrait— nunca sobrescreve ohero.image.) - Persistir as URLs no Supabase (sempre merge — NÃO clobber a copy do Passo 4: GET →
+no jq → PATCH):
Imagens faltando não quebram o publish (patch_section_field() { # $1=type $2=field $3=url (no-op se url vazia) [ -n "$3" ] || return 0 local cur new cur=$(curl -fsS "${REST}/landing_page_sections?landing_page_id=eq.${LP_ID}&type=eq.$1&select=fields" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" --max-time 15 \ | jq -c '.[0].fields // {}') || return 0 [ -n "${cur}" ] || cur='{}' new=$(jq -nc --argjson f "${cur}" --arg k "$2" --arg u "$3" '$f + {($k):$u}') curl -fsS -X PATCH "${REST}/landing_page_sections?landing_page_id=eq.${LP_ID}&type=eq.$1" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "Content-Type: application/json" -H "Prefer: return=minimal" --max-time 15 \ -d "$(jq -nc --argjson f "${new}" '{fields:$f, updated_by:"generator"}')" >/dev/null 2>&1 || true } patch_section_field hero image "${HERO_URL:-}" # banner de IA (coluna única) patch_section_field hero portrait "${PORTRAIT_URL:-}" # retrato recortado → split (no-op se vazio) patch_section_field authority image "${INSTR_URL:-}" # no-op se não há row authority # Page-level: og → settings.seo.ogImage; logo → settings.logo; stage3d (1 GET/merge/PATCH): if [ -n "${OG_URL:-}" ] || [ -n "${LOGO_URL:-}" ] || [ -n "${STAGE_URL:-}" ]; then CURS=$(curl -fsS "${REST}/landing_pages?id=eq.${LP_ID}&select=settings" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" --max-time 15 \ | jq -c '.[0].settings // {}') [ -n "${CURS}" ] || CURS='{}' STAGE_RAIN_BOOL=$([ "${STAGE_RAIN:-true}" = "false" ] && echo false || echo true) NEWS=$(jq -nc --argjson s "${CURS}" --arg og "${OG_URL:-}" --arg logo "${LOGO_URL:-}" \ --arg stage "${STAGE_URL:-}" --arg stagelogo "${STAGE_LOGO_URL:-}" --argjson rain "${STAGE_RAIN_BOOL}" \ '$s + (if $og != "" then {seo: (($s.seo // {}) + {ogImage:$og})} else {} end) + (if $logo != "" then {logo:$logo} else {} end) + (if $stage != "" then {stage3d: ({model:$stage, rain:$rain} + (if $stagelogo != "" then {logo:$stagelogo} else {} end))} else {} end)') curl -fsS -X PATCH "${REST}/landing_pages?id=eq.${LP_ID}" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "Content-Type: application/json" -H "Prefer: return=minimal" --max-time 15 \ -d "$(jq -nc --argjson s "${NEWS}" '{settings:$s}')" >/dev/null 2>&1 || true fiimages.unoptimized); o publish baixa as URLs dolanding-assetsde volta parapublic/(skillpublish-*Passo 5).
Passo 7 — Marcar ready + enfileirar landing_publish + operation_logs
draft_status='ready'(rascunho pronto para editar/publicar):curl -fsS -X PATCH "${REST}/landing_pages?id=eq.${LP_ID}" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "Content-Type: application/json" -H "Prefer: return=minimal" --max-time 15 \ -d '{"draft_status":"ready"}' >/dev/null- Enfileirar
landing_publish(INSERT emagent_jobs; o poller do Fly dispara a skillpublish-landing-page-brunobracaioli, que serializa→build→deploy). O dedup per-LP (agent_jobs_one_active_per_lp_kind) cobre concorrência —409/23505= "já há publish em voo", trate como ok:JOB=$(jq -nc --arg cid "${CLIENT_ID}" --arg lp "${LP_ID}" --arg ni "${noindex:-1}" \ '{client_id:$cid, skill:"publish-landing-page-brunobracaioli", kind:"landing_publish", landing_page_id:$lp, requested_by:"generator", args:{landing_page_id:$lp, noindex:$ni}}') PUB_CODE=$(curl -sS -o "${GEN}/job.json" -w "%{http_code}" -X POST "${REST}/agent_jobs" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "Content-Type: application/json" -H "Prefer: return=representation" --max-time 15 \ -d "${JOB}") # 201 = enfileirado; 409 (dedup) = já há publish em voo → ok. Outro código → registre como aviso. operation_logs— uma linha (sem segredos):curl -fsS -X POST "${REST}/operation_logs" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "Content-Type: application/json" -H "Prefer: return=minimal" --max-time 15 \ -d "$(jq -nc --arg c "${CLIENT_ID}" --arg e "${LP_ID}" \ --arg s "LP ${nome}.b2tech.io: rascunho gerado (${N_SECTIONS} seções) e publish enfileirado (noindex=${noindex:-1})" \ '{client_id:$c, entity_type:"landing_page", entity_id:$e, action:"create", actor:"claude-code", summary:$s}')" >/dev/null
Passo 7-abort — Reposição em caso de falha (obrigatório)
Se abortar após o Passo 1 (já marcou draft_status='generating'), antes de sair sempre
reponha para ready (para o dashboard não ficar preso em "gerando") e grave o manifest
verified:false com errors[]:
curl -fsS -X PATCH "${REST}/landing_pages?id=eq.${LP_ID}" \
-H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \
-H "Content-Type: application/json" -H "Prefer: return=minimal" --max-time 15 \
-d '{"draft_status":"ready"}' >/dev/null
Passo 8 — Manifest da run
Escrever ${TRY_DIR}/${STAMP}-landing-page.json (sempre, mesmo em falha):
{
"skill": "create-landing-page-brunobracaioli",
"client": "brunobracaioli",
"date": "${DATE}",
"verified": true,
"product": "${product}",
"nome": "${nome}",
"subdomain": "${nome}",
"url": "https://${nome}.b2tech.io",
"landing_page_id": "${LP_ID}",
"product_id": "${PRODUCT_ID}",
"repo_path": "landing-pages/${nome}",
"draft_status": "ready",
"sections_count": ${N_SECTIONS},
"publish_enqueued": true,
"noindex": ${NOINDEX_BOOL},
"cart_state": "${CART}",
"content_source": "generated|reused",
"image_cost_usd_estimate": 0.0,
"decisions": ["sink=supabase-draft", "noindex=${noindex:-1} (preview)", "publish via landing_publish job"],
"errors": []
}
Nunca inclua a SUPABASE_SECRET_KEY. Se algo falhou, verified:false + errors[] descritivo.
Passo 9 — Resumo final (stdout)
LP id, subdomínio (https://${nome}.b2tech.io), nº de seções gravadas, draft_status='ready',
estado noindex, e a frase: "Rascunho no Supabase pronto para edição. Publicação enfileirada
(job landing_publish) — a página vai nascer em PREVIEW (noindex). Go-live = publicar com
noindex=0."
5. Critério de sucesso
clientsresolvido (REST);productselanding_pagesupsertados (draft_statuspassougenerating→ready);product_id/theme/settingspreenchidos.- N linhas em
landing_page_sections(uma por seção da arquitetura), comfieldspreenchido pela copy (hero/offer/finalCta/footer/faq + middle),positionna ordem da arquitetura. landing_pages.settingscompleto (subdomain, site_url, seo, tracking, checkout_url, price_cents, cart_state, noindex, cartClosed) — pronto para o publish validar.- Imagens em
${LP_DIR}/public/(hero/og; instrutor/logo se houver) + template scaffoldado, e (best-effort) subidas ao bucketlanding-assetscom as URLs persistidas emlanding_page_sections.fields.image(hero=banner de IA / authority=foto),fields.portrait(hero, só quando háassets.heroImage→ layout split),settings.seo.ogImage,settings.logoesettings.stage3d(painel 3D, só quando háassets.stage3d.model) — assets resolvidos deassets.*do brief. - Job
landing_publishenfileirado emagent_jobs(ou409dedup) + 1operation_logs. - Manifest JSON gravado em
${TRY_DIR}/.
6. Anti-padrões (NÃO faça)
- ❌
AskUserQuestion/ parar para perguntar. - ❌ Usar o MCP do Supabase (não autentica headless) — só REST/curl +
SUPABASE_SECRET_KEY. - ❌ Escrever
messages/pt.json/content-spec.jsonou rodartsc/next build/wrangleraqui — build/deploy é do joblanding_publish(skillpublish-landing-page-*). - ❌ Ecoar/commitar
SUPABASE_SECRET_KEY(manifest, logs, stdout, operation_logs). - ❌ Mandar
fieldsno upsert de seções do Passo 3 (apagaria a copy de uma re-run; ofieldsé preenchido só no Passo 4 via PATCH). - ❌ Gravar
settingsincompleto e enfileirar publish (o publish aborta sem seo/cartClosed) — só enfileire após o Passo 4.3. - ❌ Assumir
nome=cca(ou qualquer default) —nomeé obrigatório; sem ele, aborte. - ❌ Sair com
draft_status='generating'preso após uma falha (sempre reponha — Passo 7-abort). - ❌ Inventar dados de produto — a copy/arquitetura saem do brief (
PRODUCT). - ❌ Generalizar para outros clientes.
7. Gotchas obrigatórios
Supabase headless = REST/curl. SUPABASE_URL + SUPABASE_SECRET_KEY (service_role,
bypassa RLS). Strip de CR/espaço nas duas (secret de fonte CRLF carrega \r e quebra a URL).
O MCP do Supabase é OAuth-gated → não autentica no runner (gotcha conhecido, igual ao publish).
Upsert PostgREST. Use ?on_conflict=<cols> + Prefer: resolution=merge-duplicates. No
upsert de seções (Passo 3), omita fields do payload: no INSERT ele assume o default
'{}'; no conflito, colunas ausentes do payload não são tocadas → a copy de uma run
anterior sobrevive. As fields são preenchidas no Passo 4 via PATCH por type.
Sink é o Supabase, não arquivo. O serializer (packages/lp-render/serialize-cli.ts, rodado
pelo publish) reconstrói messages/pt.json + content-spec.json + theme.css a partir de
settings+theme+landing_page_sections. Mapeamento (inverso): hero/offer/finalCta/footer
→ fields direto; faq → fields.items; middle → messages.sections.<type>; settings.seo
→ messages.seo; settings.cartClosed → messages.cartClosed; theme.colors.* → CSS vars.
Posição dos blocos = landing_page_sections.position (da order da arquitetura).
Build/deploy moveram para o job landing_publish. Esta skill termina enfileirando o publish.
O publish (skill publish-landing-page-brunobracaioli) faz scaffold-se-preciso, serializa,
next build (static export), wrangler deploy, bind de domínio + CNAME + SSL, e persiste
published_snapshot. Os gotchas de output:'export', @fontsource, CNAME/SSL e wrangler
headless vivem lá.
noindex é build-time — o valor (0|1) é gravado em settings.noindex e repassado ao job
publish em args.noindex; o flip de preview→go-live exige republicar (rebuild+redeploy). Default
1 (seguro).
Peso do scaffold no runner Fly — cp -r _template/. ${LP_DIR}/ leva o node_modules
pré-bakeado (com tsx + symlink @b2tech/lp-render); o job publish (mesma máquina) reusa e
pula o npm ci. Não rode npm ci aqui.
Headless — .claude/HEADLESS.md. Sem AskUserQuestion. --dangerously-skip-permissions
destrava writes. Confiamos no contrato deste markdown (noindex default + sem segredos vazados).
8. Pré-requisitos
- Env:
SUPABASE_URL,SUPABASE_SECRET_KEY(secrets do Fly no runner;.env.locallocalmente);OPENAI_API_KEYpara oimage-generate. Não precisa deCLOUDFLARE_*(deploy é do publish). - Migrations da SPEC-012 aplicadas (
products,landing_page_sections,landing_pages.{product_id, theme,settings,draft_status,published_snapshot},agent_jobs.{landing_publish kind,landing_page_id}) — já em prod (2026-06-03). Migrationlanding_pagesbase (20260530000008). - Brief do produto no catálogo:
${MAT}/produtos/${product}.json(skilllista-de-produtos, ADR 0014). Sem ele, a skill aborta. Produtos atuais:cca,imersao-agencia. - Skill
publish-landing-page-brunobracaiolino disco (o poller a dispara pelo job). landing-pages/_template/presente (comnode_modulesno runner Fly).- Skill
image-generatee subagents (landing-page-architect,lp-copywriter,image-prompt-generator,scrape-extractor) disponíveis. - Pasta
tentativas-geracao-de-campanhas/(criada se faltar).