Creative Signal
Correlate creative attributes with hook rate (3-second views ÷ impressions) and hold rate (thruplay ÷ 3-second views) across every video ad that delivered in the window. Scripts pull metrics into a local SQLite store, tag each creative once (Gemini gemini-3.1-flash-lite + ffmpeg features), and compute per-attribute lift with explicit confidence. The agent reads signals.json and authors brief.md — judgment on top, never re-derived math.
Arguments: $ARGUMENTS — optional window (default last_14d; valid last_7d, last_14d, last_30d), or reconfigure, or backfill.
v1 makes no revenue or ROAS claim. Hook and hold are attention proxies. State this in every brief.
Data Architecture
<skill-dir>/ # this folder — Base directory injected when the skill loads
├── .venv/ # created by onboarding; every script runs via .venv/bin/python
├── scripts/ # run.py orchestrates the rest
└── references/ # onboarding.md, taxonomy.md, gemini-prompt.md
~/.meta-ads-intel/ # shared home with meta-ads-intel (one auth, one brand context)
├── config.json # account_id, currency, brand targets — shared, read-only here
├── brand-context.md # product/audience/hooks — shared, read for the brief
├── creative-signal.env # GEMINI_API_KEY=… (mode 0600)
├── creative-signal.db # SQLite: ad×day metrics + tag-once cache (mode 0600)
├── creatives/ # CLI video snapshot — REPLACED on every --keep-video pull; never rely on it
└── creative-signal/
└── runs/<until>/
├── signals.json ── agent reads ──
├── run-status.json ── agent reads ──
└── brief.md ── agent writes ──
The store is the durable artifact. Videos are transient; the asset_hash → tags row in creative_tags is what survives.
Process
0. Mode Gate
Check the four prerequisites first, one command each; do not guess. Then route — the first match wins.
Prerequisites:
<skill-dir>/.venv/bin/pythonexists~/.meta-ads-intel/config.jsonhas a validaccount_id~/.meta-ads-intel/creative-signal.envcontainsGEMINI_API_KEY(or the env var is set)- the store is non-empty:
<skill-dir>/.venv/bin/python <skill-dir>/scripts/store.py statusreportsmetric_rows > 0
- Any prerequisite missing → ONBOARDING MODE: read
references/onboarding.mdand follow it completely. Onboarding ends with the first analysis run and STOPS. Onboarding is idempotent and skips satisfied phases, so entering it with a partial setup is safe; areconfigureorbackfillargument is ignored until setup is complete (an empty store with everything else present is the one case wherebackfilland onboarding coincide — onboarding's Phase 6 handles it). $ARGUMENTScontainsreconfigure→ readreferences/onboarding.md→ "Reconfigure Mode". STOP when it prints "Config updated".$ARGUMENTScontainsbackfill→ readreferences/onboarding.md→ "Phase 6: Backfill" only (loads more history into an existing store), then STOP.- Otherwise → ANALYSIS MODE: continue to Step 1.
1. Load Context
Read ~/.meta-ads-intel/brand-context.md (product, audience, proven hooks). If missing, proceed and note "brand context unavailable — recommendations are attribute-level only" in the brief.
Read references/taxonomy.md — every attribute the scripts can emit, its source, and how to interpret it. Do not invent attributes that are not listed there.
2. Run the Pipeline
<skill-dir>/.venv/bin/python <skill-dir>/scripts/run.py --window $ARGUMENTS
Omit --window when $ARGUMENTS is empty (defaults to last_14d). The run is idempotent: sync fetches only the gap plus a trailing 7 days (Meta revises recent days), videos download only for ads that still need one, Gemini is called once per new asset_hash. A second run minutes later does nothing but recompute correlation.
Steps inside run.py: sync → assets → deterministic → gemini → correlate. A bad video or a missing Gemini key degrades to a warning; the run still produces signals.json.
Runtime: first run after backfill tags every video (≈3 s + 9k tokens per ad; 200 ads ≈ 10 min). Steady state ≈ 30 s.
Exit codes:
0— readout_dir,signals_file, andwarningsfrom the JSON on stdout.3— store empty. Tell the user, then runreferences/onboarding.md→ Phase 6 (backfill). Do not loop.1— read the single stderr line.Another pull instance is runningmeans/meta-ads-intelholds the lock; the script already retried for 5 minutes — ask the user to wait for it and rerun.API access blocked/Session has expiredare auth problems: report and stop; suggestmeta-ads setup. Anything else: report verbatim and stop.
Read run-status.json from out_dir. Surface these before analysis:
steps.sync.new_ads> 0 — new creatives entered the account this window; say how many.steps.assets.unavailablenon-empty — ads whose video could not be fetched (deleted, or over the CLI's 500-ad cap); they contribute metrics but no attributes.steps.gemini.skippedtrue — no attributes from Gemini this run; the brief covers deterministic features only. Say why (reason).steps.gemini.failed> 0 — creatives Gemini could not parse after retries; rerun with--retry-failedlater viascripts/tag_gemini.py.steps.correlate—n_eligibleofn_ads, and the strong/directional/anecdotal counts. These are the headline numbers of the brief.
3. Read signals.json
Structure (spec §10):
{ "run": { "window", "since", "until", "account_id", "n_ads", "n_eligible", "n_tests",
"min_impressions", "model", "shopify_enabled", "confidence_rules" },
"ads": [ { "ad_id", "ad_name", "campaign_name", "impressions", "video_view", "thruplay",
"hook_rate", "hold_rate", "attributes": {...}, "flags": [...], "eligible" } ],
"signals": [ { "attribute": "first3s_content=face", "metric": "hook_rate",
"n_group", "n_rest", "mean_with", "mean_without", "lift_pct",
"effect_size", "p_value", "q_value", "confidence" } ],
"warnings": [ "..." ] }
signals is pre-sorted: strong → directional → anecdotal, then by |effect_size|. Read it top-down.
Attribute labels: categorical key=value (tested with-vs-rest); numeric key>=<median> (median split); booleans key=true. Every signal exists twice at most — once per metric (hook_rate, hold_rate).
Per-ad flags explain exclusions: untagged (no cached tags), no_video_view (image ad — no hook/hold possible), partial_retention (hold_rate unavailable — retention fields missing for part of the window; happens for days pulled before CLI 0.19), below_min_impressions (under 1000). Only eligible: true ads entered the tests.
4. Interpret
Confidence is the load-bearing field. Read run.confidence_rules and apply:
| confidence | treat as | say |
|---|---|---|
strong |
a finding | "Ads that open on a face hook 37% better (n=31 vs 56, d=0.61, p=0.018)" |
directional |
a lead worth a test | "Directional: UGC holds longer (n=9 vs 14, p=0.11) — test before betting on it" |
anecdotal |
a hypothesis only | mention at most 3, grouped, explicitly labelled anecdotal |
Rules that keep the brief honest:
- If
n_eligible < 20, no signal can be strong — open the brief with that fact and frame everything as hypotheses. q_valueis the Benjamini-Hochberg-adjusted p across alln_tests. If a strong signal hasq_value > 0.10, say it may be a multiple-comparison artefact. Always staten_testsin Confidence & Caveats.lift_pctis relative (0.375 = +37.5%). Reportmean_withvsmean_withoutas percentages alongside it so the absolute gap is visible.- Hook and hold answer different questions. A hook signal is about the first 3 seconds (
first3s_content,hook_text,time_to_first_cut,energy_first3s,branding_first3s). A hold signal is about the body (cut_count,avg_shot_len,sound_mode,emotion,transcriptlength). Do not attribute a hold signal to an opening choice. tempo_bpmand other audio-lane features are only meaningful whensound_modeincludes music. Gate on it.- Numeric splits:
cut_count>=7means "ads at or above the median of 7 cuts". Say "more cuts than the median" rather than quoting the threshold as a target. - A signal on
format_style=staticorsubject=text_graphicwith a tinyn_groupusually means one outlier ad. Checkadsfor that group before reporting. - Mirror pairs: a two-valued attribute is tested once.
faces_present=truelifting hook by +20% impliesfaces_present=falseat −20%; do not list both as separate findings.
Read ads to ground every reported signal in concrete examples: for each strong or directional signal, name the top 2 ads by the metric inside the group and 1 outside it. Quote hook_text verbatim where present — it is the most actionable attribute and is never tested statistically.
5. Write brief.md
Write <out_dir>/brief.md. Structure, in order, headers verbatim:
- Window & coverage — window dates,
n_eligibleofn_adsads, why the rest were excluded (fromflagscounts inwarnings), tags source (run.model), and whether Gemini ran. One paragraph. - What works — strong signals first, then directional. One bullet per signal: attribute in plain words, metric, lift with means, n, confidence, two example ads with
hook_textwhere it exists. - What doesn't — signals with negative lift, same format. Include the mirror reading of a positive finding only if it names a different actionable choice.
- Why — 2–4 sentences connecting the signals to the brand context and the audience. This is the only interpretive section; label inference as inference.
- What to try next — up to 3 recombination briefs. Each: the attribute combination to produce (drawn only from strong/directional signals), a hook line modelled on the best-performing
hook_text, the format, and which metric it targets. Tie to products/audience from brand-context.md. - Confidence & caveats — always present, always these points: hook/hold are proxies with no revenue link claimed in v1;
n_teststests were run andq_valueis the adjusted p; small groups; ads excluded and why; anything inwarningsnot already covered.
Numbers in the brief come from signals.json and run-status.json only. Never compute a rate, mean, or lift yourself. If a number the brief needs is absent, say it is absent.
Length target: 400–800 words. Fewer signals, shorter brief — do not pad an anecdotal-only run.
6. Return
Print: window, n_eligible/n_ads, counts by confidence, the single highest-value finding in one line, and both paths (signals.json, brief.md).
If this is the first analysis after onboarding, offer the optional daily pre-warm scheduler once (references/onboarding.md → "Optional: daily pre-warm"). Never offer it again in later runs.
Rules
- Run every script through
<skill-dir>/.venv/bin/python. Systempython3lacksgoogle-genai. - Never read
~/.meta-ads-intel/creatives/directly — it is a snapshot the CLI replaces. Readads[].attributesfromsignals.jsoninstead. - Never read
~/.meta-ads-intel/data/— that belongs to meta-ads-intel. - Never call
meta-ads intel fetch-daily --keep-videoyourself.run.pydecides when a video pull is needed; a manual call re-downloads every video and wipes the snapshot. - Never re-run correlation, recompute means, or count
ads[]by hand. All counts are inrun-status.jsonandrun.*. - Never make a revenue, ROAS, purchase, or CPA claim from this skill's output. Redirect budget questions to
/meta-ads-intel. GEMINI_API_KEYis read from the env orcreative-signal.envby the scripts. Never echo it, never write it into a run dir or brief.- Spend data is sensitive:
run.pysets umask 077. Do not copy outputs outside~/.meta-ads-intel/unless the user asks.