Build a person audience from an owned list — customers, leads, accounts — resolving it to Watt entity IDs and emitting a roster, or learning what defines it and handing back a tunable signal pool. The list-anchored way into a build, behind /watt:audience. Four plays — resolve-only, expand (households via addresses), lookalike (the signals that define the list, to tune), overlay (score and rank against signals); group and traverse aren't built. Produces a roster of entity IDs or a signal pool — never an export, never contact data. Not a user command — /watt:audience is the front door. Use when a list arrives to build from — "match my customer list", "find more like my customers", "rank my list by intent", "score the people from my last run". A read intent is audience-analyze-list.
71
89%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
audience-generate-list is the build leaf anchored on an owned list — the user arrives with people they already hold (customers, leads, accounts, engagement data), not a plain-English description of who to reach. It resolves that list to Watt entity IDs and emits a roster: the matched set, ready for the audience-activate and audience-analyze steps.
The "list" can already be resolved: a roster from a prior pass (a workflow:// entity-IDs URI, or a pasted roster record) is the same anchor — people the user holds — just past the resolve step. It enters here to be re-transformed: overlay scores it, lookalike profiles it for its defining signals; the resolve is skipped, and the roster's classification columns ride along into the play (the play's output roster carries its own columns, per the procedure contract). (Re-expanding a roster needs raw identifiers, which entity IDs alone don't carry — that's the export-addresses-then-expand round-trip below; grouping/crossing a roster aren't available here.)
The leaf is split from its sibling audience-generate-search by input anchor — an owned list here, a description there. The structural consequence is that elicitation starts from what the list is, not from "describe who you want to reach"; there is no discovery, no scoring, no composing.
This leaf ships four plays. Two match the list — resolve-only and expand, both resolution-driven, differing only in how wide the match is. One learns from the list — lookalike. One scores the list — overlay:
context/resolution.md); the roster classification is minimal (source_provenance).0. For maximum addressable reach — the wide roster feeds identifier maximalism at audience-activate. Runs the expand-roster procedure inline (context/expand-roster.md); the roster carries each entity's match confidence and corroboration count.overlay_score = Σ weightᵢ × matchᵢ; weights default to 1, so the default score is a plain count). Returns the whole list ranked — lead-scoring, "who's hot", prioritize-the-pipeline — and can optionally be cut to the matched slice (keep people expressing ≥ N signals — the intersection, "my list who also do X"). Unlike the other plays, overlay needs a brief for the signal pool — the brief defines the layer to score by, never the population (the list stays the population). Runs the resolution procedure inline, then the parent's discovery + scoring inline to build the scored pool, then the overlay-score procedure inline (context/overlay-score.md) to score and rank.Households fall out of expand, they aren't a separate play. Because an address resolves 1:many to everyone at it, an expand run whose match columns include addresses returns the co-residents — so household expansion is reachable today on an address-bearing list. From an email/phone-only list, the supported route is the round-trip: build the roster, export it with addresses via audience-activate, then feed those addresses back through expand. What's not built is a one-shot button that does the person→address enrichment inside this leaf (enrichment is the activator's lane), nor seed-vs-co-resident provenance tagging on the result.
The shared group / traverse strategies are not available in this leaf: name them honestly when asked, never build or run them.
This is a thin delta over audience-generate: the unique work is getting the owned list to something the operator can build from — a chainable entity-ID set (resolve-only via the inline resolution procedure, expand via the inline expand-roster procedure), the signals that define the list (lookalike via the inline profiling procedure), or the scored-and-ranked list (overlay via the inline overlay-score procedure). The parent's lane (build, not export; entity IDs only), language, and landing plumbing are composed with verbatim. The parent's full discover→score→compose spine still does not run here — a list isn't a brief, and no play composes a new set: lookalike profiles the resolved seed (the profiler's read of who's already on the list) and stops at the signal pool; overlay is the one play that runs inline discovery + scoring — but over a brief that defines the scoring layer, never the population, and it scores the resolved list rather than composing a set.
audience-generate router, when the build starts from an owned list (the list anchor) rather than a description.context/resolution.md) — run inline with the supplied identifiers (inline) or a csv_resource_uri, plus the entity_type and a workflow_id → a workflow:// entity-IDs URI and the counts (input_identifier_count, resolved_count, below_floor_count). The resolve is a format=csv export the script reduces to an entity-ID set, so the leaf handles only the URI and the counts — never a resolved contact record.context/expand-roster.md) — run inline with the supplied identifiers (inline) or a csv_resource_uri plus the lookup_columns mapping the leaf elicited (which columns to match on; required on the CSV path), the entity_type, an overridable quality_floor (default 0), and a workflow_id → a workflow:// roster URI (columns entity_id, match_confidence, match_criteria_count), a small ID-only sample, and the coverage counts (input_identifiers, resolved, below_floor, quality_floor). The procedure owns the wide entity_resolve 1:many pass (a format=csv export) and the dedupe/floor script, keeping the result IDs-only — so the leaf handles only the roster URI, the sample, and the counts, never a resolved contact record. The leaf chooses the columns and (when a transform is needed) builds the artifact; the procedure resolves against exactly the lookup_columns it's handed.context/profiling.md) — run inline on the seed's entity_ids_uri (from the resolution procedure), the entity_type, and a workflow_id → a structured two-half read of the seed: the durable identity by lift (discovered) and the top intents by reach within the seed (the breakdown intent panel, tagged by size). It runs in mode B (entity_set) — an entity set, no signal stack — exactly as audience-analyze-list does; it groups the full seed (never a sample — sparse intents need the whole set) and returns aggregates only. This leaf renders them as the tunable signal pool and never sees a record.context/discovery.md); scoring runs inline (context/scoring.md) by trait_hash, the brief as the grounding frame. The pool defines what to score the resolved list against — never who's on the list.context/overlay-score.md) — run inline with the resolved list (an entity_ids_uri from the resolution procedure), the scored signal pool (expressions over trait_hashes), an optional per-signal weights map (the operator's; default every signal 1), the entity_type, and a workflow_id → a workflow:// roster URI (columns entity_id, overlay_score, signals_matched, rank), a small ID-only sample, and coverage (source_size, scored, scored_zero). The procedure only scores and ranks — it returns the whole list, never cut; slicing to the intersection (keep signals_matched ≥ N) is this leaf's post-step. Weights flow operator → the procedure; never elicited by it.audience-activate (export the matched set — Meta, Google, Reddit, or TikTok) and audience-analyze (read who the matched set is) — offered at the landing, on the user's say-so. For the lookalike play the deliverable is the tuned signal pool, not a matched set: hand it to audience-generate-search to compose into an audience (the working set pre-seeded — discovery skipped for what's already there, a landing mode picked there), or to audience-analyze to read it.Inherits the parent's table; neither shipped play uses its boolean vocabulary. The terms this leaf owns:
total: "N identifiers in, M people matched".Both unavailable -list strategies (the shared group / traverse) stay internal — not surfaced as a live choice; name them honestly only if asked by name.
workflow:// entity-IDs URI or a pasted roster record with a transform intent). Same anchor, already resolved — skip the resolve: pick the play (overlay or lookalike run directly; expand needs raw identifiers — offer the round-trip; group/traverse aren't available here) and the URI is the population. The classification columns ride through untouched. A roster with a read intent is audience-analyze-list; with an export intent, audience-activate.audience-generate with the list anchor but no list in hand yet. Ask for it: "Share the list — paste the identifiers, or upload a CSV. What does it contain (emails, phones, names, addresses)?"audience-analyze-list, not this leaf — both run the same resolve, but analyze reads aggregates and this leaf builds toward export. Route there.audience-generate-search; don't resolve a list that wasn't given.context/expand-roster.md, floor 0).audience-activate, then expand them). Only a one-shot in-leaf household grow is unbuilt.audience-activate's call (target-vs-exclude polarity), not a generate play — say so and route the export there.Four plays ship. Two match the list (resolve-only and expand), one learns from it (lookalike), one scores it (overlay). All share step 1 — settle the list and take it — then the play decides the rest: the matching plays pick how wide and emit a roster (steps 3–6); lookalike profiles the seed and hands back a signal pool (steps L1–L3); overlay resolves the list, scores it against a pool, and ranks (steps O1–O4). Each beat ends at the user's decision; nothing resolves, profiles, scores, or lands without a go-ahead.
Start from the list, not a brief. Confirm what the list is (customers / lapsed / accounts / leads — it frames the matched set and the downstream read) and how it arrives. For lookalike this list is the seed — proven people the operator wants more of — settled the same way (what it is, its identifier shape, addresses called out):
workflow:// entity-IDs URI, or a pasted roster record. Already resolved: confirm what the set is and which play they want, then skip the resolve entirely — the URI is the population, there are no lookup_columns to settle, and the roster's classification columns ride along untouched.workflow://…/uploads/…csv resource), or one the user will upload now.Name the identifier types present — email, phone, name, address. Addresses need explicit handling — call them out, because they resolve on a different call shape (the resolution procedure names their types explicitly; a default email-only resolve 500s on addresses). This input-shape handling mirrors audience-analyze-list's step 1 exactly; the only difference is the downstream intent (build/export here vs. read there).
For a CSV, settle which columns to match on — the resolution procedure resolves against exactly the columns you map (lookup_columns) and ignores the rest, so "match on these two of the twenty" needs no file surgery: just map the two. Two cases do need a prepared artifact, which the leaf builds in the sandbox before the resolve (the resolution procedure never reshapes a file):
When neither applies, pass the user's csv_resource_uri straight through with the chosen lookup_columns. When one does, write the cleaned CSV in the sandbox, upload it (generate_upload_url), and carry that artifact's URI plus the lookup_columns for it into the resolution step — the column choice and any reshape are the leaf's, never the resolution procedure's. End the turn confirming the list, its shape, and the match columns before resolving.
The user's words usually answer this — route silently when so ("customer match" → resolve-only, "the widest reach" → expand, "more like them" → lookalike); otherwise land the choice as the decision — native clickable options, with the tradeoff plain:
0). More entities, lower-confidence tail — chosen to maximize addressable reach, because the wide roster feeds identifier maximalism at export. The match confidence rides with every row, so the tail is never hidden.The matching plays (matched / expanded) continue through steps 3–6; lookalike branches to L1–L3 and overlay branches to O1–O4 below. A floor other than the default is the user's to ask for on the matching plays (a tighter expand, a looser match) — otherwise resolve-only takes the resolver default and expand takes 0.
For resolve-only and expand: on the user's go, run the play's resolve with the identifiers (inline) or the csv_resource_uri (the user's, or the sandbox artifact from step 1) plus the chosen lookup_columns, the entity_type, and a workflow_id — resolve-only runs the resolution procedure inline (context/resolution.md), expand runs the expand-roster procedure inline (context/expand-roster.md). Either way the resolve stays IDs-only — the leaf handles only the URI(s), the sample, and the counts, never a resolved contact record.
context/resolution.md) — returns the entity_ids_uri and the counts (input_identifier_count, resolved_count, below_floor_count). Narrate plainly ("5,000 identifiers in, 4,200 people matched").context/expand-roster.md; pass quality_floor: 0 unless the user set one, and the lookup_columns for the CSV) — runs the wide entity_resolve 1:many pass and returns the roster URI, an ID-only sample, and coverage (input_identifiers, resolved, below_floor, quality_floor). Narrate plainly ("5,000 identifiers in, 6,300 entities matched wide at floor 0"). Because the match is 1:many, resolved can exceed the identifiers in — say so; it's a union, not a per-row rate. When the match columns include addresses, that union includes the co-residents at them.Put the counts on screen — submitted vs. matched — and say plainly that the matched set is who carries forward. For resolve-only, the mapping is many-to-many: report the two counts as given, never a per-row matched/unmatched rate. For expand, name the floor and what it bought (floor 0 = widest net, every match kept; corroboration is global pooling — one entity matched two values — not two inputs confirmed as one person). Either way: if few or none matched (bad identifiers, wrong column mapping, an address list on the wrong call shape), surface the counts and the likely cause as the turn's finding — don't land an empty roster as a result.
Emit the matched set in the shared roster record frame — the same serialization the crossing roster from audience-generate-search uses, so audience-activate and audience-analyze consume it identically. The classification columns differ by play; show the matched set — the count, the classification, the coverage — and write the roster record per the record contract (context/record.md):
entity_id, source_provenance (value seed); no rank, no groups, no score.# Watt record · kind: roster · play: resolve-only · audience: matched from your customer list
# matched: 4,200 of 5,000 identifiers submitted (below floor: 350)
# roster_uri: workflow://…/roster.csv entity_type: person
entity_id,source_provenance
e_8821,seed
e_4410,seed
… (sample; full set behind roster_uri)entity_id, match_confidence, match_criteria_count (the expand procedure's roster, verbatim); no rank, no groups. The confidence + corroboration columns are the audit trail for why each entity is in the set, and must survive downstream.# Watt record · kind: roster · play: expand · audience: expanded from your list · widest match · floor 0
# match_confidence — Noisy-OR per entity (1.0 strong … low = weak single match)
# match_criteria_count — how many of your identifiers corroborated it
# matched: 6,300 entities from 5,000 identifiers submitted (below floor: 0)
# roster_uri: workflow://…/roster.csv entity_type: person
entity_id,match_confidence,match_criteria_count
e_8821,0.95,3
e_4410,0.88,2
… (sample; full set behind roster_uri)The roster_uri carries the full entity-ID set (IDs only — never contact data) as a CSV of these same columns; the rows in the record are a sample, and the # matched: line stays honest about how the count relates to what was submitted. The roster record file is the canonical serialization — its column header row must survive downstream, the shape audience-activate and audience-analyze consume.
Offer the downstream in one line, in plain words: export the matched set (audience-activate — Meta, Google, Reddit, or TikTok) or read who they are (audience-analyze). Either runs on their say-so. For an expanded roster, the export is where the wide set pays off — each entity is enriched back out to all its identifier types so the platform can reach more of them on its side (the activator's lane, behind its confirmation).
On the user's go, run the resolution procedure inline (context/resolution.md) with the seed identifiers (inline) or the csv_resource_uri plus the chosen lookup_columns, the entity_type, and a workflow_id — the same resolve as resolve-only, here producing the seed to profile, not a deliverable roster. A roster input skips this step — its entity_ids_uri is the seed as-is (no namesake risk: the entities were already resolved upstream); go straight to L2. Confirm coverage honestly (submitted vs. matched; the matched seed is what gets profiled). Watch for name-match inflation — a name-heavy seed resolves wide to namesakes (a 10-person list can match thousands of same-name strangers), and because lookalike builds the whole profile on the seed, that noise washes out the real signature: far more damaging here than the count is for resolve-only. When the match is dominated by fuzzy name matching, flag it and offer to tighten to email/phone-confirmed identifiers for a sharper seed before profiling. End the turn at the go-ahead to profile: "4,200 of your 5,000 matched — profile these to find what defines them?" Never profile an empty or near-empty seed as if it were the list (surface the counts and the likely cause instead).
On the go, run the profiling procedure inline (context/profiling.md) in mode B (entity_set) with the seed's entity_ids_uri, entity_type, and workflow_id. It groups the full seed (never a sample — intent traits are sparse, and sampling thins them into noise) and returns two halves:
Narrate the read plainly ("Your 4,200 customers over-index hardest on X and Y; their most common in-market intents are Z…"). Render the result as the tunable signal pool — each signal with its lift or reach, size, freshness, and which half it came from — and write the signal-pool record per the record contract (context/record.md); the keep/drop tuning is the decision the render lands. The user tunes: keep the portable signals, drop the self-referential ones (owning the product, the brand's own app) and the generic high-reach intents that describe everyone. This is the decision beat — the turn ends at the tuned pool.
# Watt record · kind: pool · play: lookalike · audience: lookalike from your customer seed · 4,200 people profiled
# tuned: kept N, dropped M next: carry into a compose, or read · seed not suppressed from a later compose
# half — identity = by lift vs. the world · intent = by reach within the seed
# pool_uri: workflow://…
half,name,lift_or_reach,size,freshness,domain,trait_hash
identity,<signal name>,lift 7.2×,~410K,fresh,interest,…
intent,<intent name>,31% of seed,~1.2M,fresh,intent,…The tuned signal pool is the deliverable; lookalike builds no audience itself. Offer the next move in one line: compose it into an audience — carry the pool into audience-generate-search with the working set pre-seeded (discovery skipped for what's already there; that leaf settles the roles with the user and re-scores the pool per role before any compose — the pool's lift-or-reach figures are profiling metrics, never compose scores) — or read it via audience-analyze. Lookalike does not suppress the seed from a later compose: a composed lookalike audience may re-include people already on the seed — out of scope here; say so if asked, don't fake an exclusion.
On the user's go, resolve the list to the matched set by running the resolution procedure inline (context/resolution.md) — the same resolve as resolve-only (overlay scores who's genuinely on the list, the tight match), with the identifiers/csv_resource_uri and chosen lookup_columns, the entity_type, and a workflow_id. Confirm coverage honestly (step 4). The resolved entity_ids_uri is the population to score. A roster input skips this step — its entity_ids_uri is already the population; go straight to O2.
Overlay needs a brief — but it defines what to score by, never who's on the list. Ask: "What should I score your list by — in-market for what, engaged with what, interested in what?" Read it as angles and, one angle per beat, run discovery inline (narrow scope, the population's entity type — person for a list; a crossed business roster re-entering discovers business-side signals, entity_type: "business") for the candidate signals, then the inline scoring procedure to score them (by trait_hash, the brief as the grounding frame) — the parent's discover→score procedure, composed with verbatim. Show the scored pool — name · what it means · ~size · relevance · freshness · score — and write the pool record per the record contract (context/record.md). End the turn on the pick: which signals make up the pool, and — if the operator wants — a per-signal weight (default every signal 1, so the default score is a plain count of signals expressed). Nothing is scored until the user approves the pool.
On the user's go, run the overlay-score procedure inline (context/overlay-score.md) with the resolved entity_ids_uri, the approved pool (expressions by trait_hash), the weights map (the operator's, or omitted for uniform), the entity_type, and the workflow_id. Narrate plainly ("Scoring your 18,400 matched customers against the 6-signal pool…"). It returns the roster ranked by overlay_score, an ID-only sample, and coverage (source_size, scored, scored_zero). The procedure scores the whole list — nobody is cut. Land the closing decision as native clickable options:
Show the ranked set — the top rows by overlay_score + signals_matched, scored_zero called out — and write the roster record per the record contract (context/record.md). Be honest: the scored_zero people express none of the pool — a real result, not a drop; with uniform weights overlay_score is just a count, not a precision verdict.
Emit the scored set in the shared roster record frame — columns entity_id, overlay_score, signals_matched, rank. When the intersection cut was applied, the emitted roster is the kept rows and the record says so: a # set: header line reads intersection (signals_matched ≥ N) — 4,100 of 18,400 kept, and a # full_set_uri: line retains the overlay procedure's full ranked roster_uri — so a downstream consumer is never handed the slice as if it were the whole list, and the full ranking stays reachable:
# Watt record · kind: roster · play: overlay · audience: your customer list, scored · pool: 6 signals (uniform weights)
# overlay_score — Σ (weight × match) over the pool; default weight 1
# signals_matched — how many of the 6 pool signals the person expresses
# rank — 1 = expresses the most
# matched: 18,400 of 20,000 identifiers submitted scored_zero: 1,210
# (for a roster re-entry this reads `population: N people from your prior pass` — no identifiers were submitted)
# roster_uri: workflow://…/roster.csv entity_type: person
entity_id,overlay_score,signals_matched,rank
e_2231,5,5,1
e_8890,4,4,2
… (sample; full set behind roster_uri)Then offer the downstream in one line — export the top of the list (audience-activate — top-N by rank) or read who the high scorers are (audience-analyze).
context/resolution.md); expand runs its resolve inline via the expand-roster procedure (context/expand-roster.md) — a format=csv entity_resolve export the script reduces to an IDs-only roster. Either way the leaf handles only entity-ID URIs, samples, and counts — never a resolved contact record, and never resolve_and_enrich_rows (enrichment to PII is the activator's lane).context/expand-roster.md) and overlay runs the overlay-score procedure inline (context/overlay-score.md); lookalike runs the profiling procedure inline (context/profiling.md, mode B); overlay also runs the parent's discovery + scoring inline to build its pool; and resolve-only, lookalike's seed-resolve, and overlay's list-resolve all run the resolution procedure inline (context/resolution.md). Households are not a separate strategy: they fall out of expand on an address-bearing list, or via the export-addresses-then-expand round-trip; only a one-shot household-from-a-non-address-list (which would need enrichment, the activator's lane) is unbuilt.audience-generate-search path. Profile the full seed, never a sample: intent traits are sparse, and sampling thins them into noise.context/overlay-score.md) scores the resolved list against it and ranks the whole set — nobody cut. The intersection cut (keep signals_matched ≥ N) is this leaf's filter on the returned roster, applied only if the user asks. Suppression is not an overlay thing — target-vs-exclude is audience-activate's call.cat "<Bundle root>/context/record.md" (the path published at session start). It isn't injected at session start; read it once, then honor it for every record this session.audience-activate's lane, behind its own scale-and-identifiers confirmation. (The record file the leaf writes carries entity IDs and signals only, never PII.)audience-analyze-list. Same resolve step, different intent: it reads aggregates, this leaf builds toward export. Route there.audience-activate's call (target-vs-exclude polarity). Build the set here, route the exclude decision to activate.audience-activate, then expand those addresses. Only a one-shot in-leaf household grow (which needs enrichment, the activator's lane) is unbuilt; don't promise that, offer the round-trip.audience-generate-search. Route there; don't resolve a list that wasn't given.audience-activate's lane, behind its confirmation. This leaf emits a roster of entity IDs only.note plainly — which batch, how many unresolved — and stop; never hand back a partial matched set as if it were whole, and never fill the gap with an estimate.note plainly (which step, what was returned) and stop — never silently profile a truncated slice as if it were the whole seed, and never pad the pool with guessed signals.scored_zero ≈ the whole set), that's a real finding, not a failure — surface it with the counts; don't re-score at a looser pool to inflate it, and don't present an all-zero ranking as a meaningful order.880e260
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.