Build a person audience from a plain-English description — discover the signals behind the brief, score them, and compose, partition, or cross them into a measured result. The discovery-driven way into a build, behind /watt:audience; anchored on a description (no owned list). The objective picks how it lands — a size band, the widest credible reach, the highest-precision few, grouping into the best-concentrated cells, or crossing the employment graph to their employers (or employees). Produces a signal stack + measured reach, or a roster — never an export, never contact data. Not a user command. Use when a build starts from a description — "build me an audience of about 1–5M weekend hikers", "the highest-intent people in-market for solar", "where do these people cluster", "a lead list of in-market companies".
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-search is the build leaf anchored on a plain-English description — the user arrives with who they want to reach, not an owned list. It runs the parent's shared discovery and scoring, then composes — or partitions — the approved working set into a measured result.
The leaf is split from its sibling by input anchor (description here; an owned list is audience-generate-list), and inside it the objective picks the strategy. Two output families ship today:
roster_uri.This is a delta over audience-generate: the unique work here is the objective, the strategy that hits it, and — for the roster objectives — the partition (grouping) or the graph crossing (crossing) that the stack objectives don't have. Discovery, scoring, the working-set record, the pivot loop, and the landing are the parent's shared spine (audience-generate → The shared spine), composed with verbatim — not restated. The inputs are the audience description and the objective's target (a band for greedy; no target for broad; a must-have base + output mode for lift; the grouping dimension for group; the crossing direction + optional target filter for traverse); an /watt:explore signal pool — or a lookalike signal pool from audience-generate-list — carries in as a pre-seeded working set, discovery skipped for the angles it covers.
audience-generate router, when the build starts from a description (the default anchor today) — carrying an /watt:explore signal pool, or a lookalike signal pool from audience-generate-list, as the pre-seeded working set when one came through.cat "<Bundle root>/context/<file>", once this session — then run:
context/compose-greedy.md) — the working set (in scored order) + the band → the signal stack, its measured reach, the assembly trace, and an ID-only sample.context/compose-broad.md) — the working set (in scored order), no band → the wide-OR stack, its measured reach, the assembly trace, a credibility read (gated? what fraction of the universe? promotable anchor candidates), and an ID-only sample. Returns needs_anchor when an ungated union has swollen toward the whole universe — that's a finding to put to the user, not an off-ramp.context/compose-lift.md) — the working set (must-haves as the base, candidates scored) + the output mode → the lift-ranked candidates, the picked signals, the composed expression, and its measured reach. Lift over the base is computed from reach counts, not calculate_trait_lift.context/partition-group.md) — the base set's expression + the resolved axes (the named dimension turned into its trait-expression buckets — the discovery step's job, not the partition's) → the roster: the top-K disjoint cells with group label, cell_lift, cell_size, and rank, an ID-only roster_uri, and the pruned/coverage counts. Partition-type axes only; overlap-type (interest/intent) is declined. The grouping dimension is always the user's (named here, or surfaced by an audience-analyze read first); the partition never discovers it, and never resolves the traits behind it.context/cross-traverse.md) — a composed seed set (its expression or entity_ids_uri) + the relationship and source/target pair + an optional resolved target filter → the roster: the whole qualified set of crossed entities that pass the filter, each with its source provenance, an ID-only roster_uri, and coverage (seeds, joined fraction, qualified). Membership only — traverse never scores, ranks, or truncates (no score_spec, no top-K); it's a compose-seed → cross chain (compose the seed first, then cross). Ranking the set into a prioritized lead list or segmenting it is a separate Classify step over the roster (group segments; ranking is the roster re-entering audience-generate-list's overlay play against a target-side signal pool). The supported crossings are whatever entity_relations exposes (today employs, person↔business); read them from the tool, never an allow-list.context/discovery.md, scoring via context/scoring.md, adjacencies via context/adjacencies.md.)Inherits the parent's table (must-haves / defining signals / exclusions; place as a geo-boundary signal; signal ↔ trait). The terms this leaf owns:
audience-analyze shows what defines these people), not a guess. The result is the best-concentrated groups, each with how concentrated (lift) and how many (size). Say groups and the dimension to group by — never "cells", "partition", "cross-product", or "axis" at the surface.No AND/OR/NOT, no boolean "pools" (the OR/AND/AND_NOT groupings — signal pool, the kept-signals carrier, is the user's word and fine), no "output mode" at the surface.
Five objectives ship — three compose a signal stack, two produce a roster (one partitions, one crosses Signal Graph). The brief usually settles which; route silently when it does, and make it the turn's pick only when it's genuinely open.
1,000 ≤ low < high ≤ 200,000,000; a single number → ask for a range (the band is what makes the assembly honest about where it lands).audience-analyze (its -search flavor) to see what defines these people by lift; the dimensions that over-index point to how to group, and they come back here to group by the one the read surfaced. The grouping dimension is settled (named, or revealed by that read) before the compose.entity_relations exposes (today employs, both ways); a direction the tool can't honor is named honestly, not faked. The seed audience (the side you start from) is composed first with whichever compose strategy fits the brief — the seed and the optional target-side filter are both settled before the cross. Two settle-points carry to step 3: which way to cross, and (optionally) how to narrow the crossed side.When the objective itself is open, the pick is a target size, the widest credible audience, the highest-propensity people, where these people cluster, or the set reached by crossing the employment graph (their employers / their employees)? — band the default when a size was floated.
One read to route away, not build: if the user wants to understand a market — who one audience is, its overall traits — that's audience-analyze (its -search flavor), not grouping (grouping ranks sub-groups of a target audience; profiling describes one). A pure indifference to size, reach, and propensity with no grouping ask is also a read. Name audience-analyze and route there rather than inventing a band.
The audience description plus the objective's target (the band; nothing for max-reach; the base/output-mode for precision; the grouping dimension for grouping; the crossing direction + optional target filter for crossing) are the inputs — with them in hand, go to discovery.
Hand to the parent's shared spine (audience-generate → The shared spine, steps A–C): read the brief into angles, run discovery inline per angle (or once in full scope on the user's sweep-it-all ask), score the pool per role with the inline scoring procedure, render the scored slice, and run the working-set record + pivot loop. The objective rides in the working-set header — target: band <low>–<high> (band), target: max-reach (max-reach), target: precision (base …) (precision), target: grouping (<dimension>) (grouping), or target: crossing (people→employers, filter …) (crossing) — so it survives compaction.
The crossing objective discovers on two entity types: the seed side (the audience you start from — discovery with entity_type matching the seed) and, when the user wants to narrow the crossed side, the target-filter side (a second, smaller discovery pass on the other entity type — business firmographics when crossing to employers, person signals when crossing to employees). Each is its own working set with its own header (seed: / target-filter:); the seed is the primary, the filter optional. Everything else in discovery — angles, the scoring, the pivot loop — is the parent's, run once per side; don't restate it.
A pre-seeded pool arrives without compose scores — and a lookalike pool also arrives without roles. An /watt:explore pool carries its roles in session; honor them and skip the role beat (re-asking what the handoff already answers violates it). A lookalike signal pool does not — its halves (durable identity / intent layer) are not a role split — so settle its roles with the user in one beat: which signals are must-haves or exclusions; the rest land core (durable-identity signals usually map to core, a life-stage or geo gate to must-have). Either way, score the pool per role through the spine's inline scoring exactly as a discovered pool would be — lift-or-reach figures are profiling metrics; never carry them forward as a score.
The one thing this leaf supplies to the shared scoring is the core pool's breadth lean — passed as the breadth element of the core role's weights vector in the spine's scoring call. The parent leaves it to the objective leaf, and the objective sets it:
breadth weight, so distinctiveness wins); a broad target leans it toward broad signals (positive breadth); a middling target sits near zero.breadth, a firmer freshness positive): lift rewards candidates that are over-indexed in the base and fresh, so narrow, current signals are the ones worth scoring high.lift in-market seed leans toward distinctiveness + recency; a broad seed leans breadth-positive); the optional target-filter side leans toward distinctiveness (it narrows the crossed set, it doesn't size it). The crossing's value is a clean, on-brief qualified set, not seed size — a vast vague seed crosses to noise.It's a directional lean, not a precise formula — the strategy still measures its way to the result. Everything else in discovery and scoring is the parent's; don't restate it.
When every angle is covered or consciously dropped, offer the step — naming the strategy the settled objective selects, not asking which to use (the objective already answered that). The offer is a strategy-picker: the objective names the strategy, and when the objective is genuinely open the strategies are the turn's pick. Adding a strategy is a thin delta to this offer — register it, wire its edge, add its vocab row — never a rewrite.
Only on yes, run the objective's path.
Stack objectives (band / max-reach / precision). Run the selected compose procedure inline with the working set (in scored order, pivots applied) — greedy (context/compose-greedy.md) with the band, broad (context/compose-broad.md) with no band, or lift (context/compose-lift.md) with the must-haves as the base, the chosen output mode (tighten = slice of the base; reach more like them = structural anchor retained), and the default select (the few highest-lift). Narrate the run as it reports ("Adding 'In-market: Hiking Gear' — the union now reaches 1.4M…"; or "'Interested: Sustainability' lifts the base 4.7× — keeping it…"). Render the trace as the story of the build: each step, the reach after it, where it landed — and the leftovers it never needed (for broad, the core signals skipped at the marginal floor, named honestly as adding no real reach; for lift, the lift-ranked table with every candidate's lift over the base and the few picked). Lift's multiple is over each candidate's own base rate, so present it as an honest approximation, not an exact figure. Reach is measured at every step; never present arithmetic over per-signal sizes as a count. Handle the procedure's edge report as the turn's question — never quietly accept it:
below_floor / above_ceiling — show its note and put the leverage to the user: expand an angle, relax the band, drop a broad exclusion, or accept the off-band result as-is. Never change the band to fit what composed — the band is the user's.needs_anchor — the union is ungated and reaches a majority of the addressable universe (it's approaching everyone). Show the reach and the fraction, and put the stack move to the user, with broad's anchor_candidates as the signals driving the breadth: promote one to a must-have (anchor the audience on it), add a structural must-have the pool doesn't yet hold (an occupation, life-stage, or place — a fresh discovery pivot), exclude a signal, or accept the wide-open reach as intended (broad's whole job is reach — a confirmed big number is a valid landing). A credible status lands straight away.no_base / base_below_floor / no_lift_candidates — show its note and put the leverage to the user: add or broaden the must-have base, deepen an angle for stronger candidates, or switch to a band or max-reach. When nothing lifts the base, the base itself is the honest audience — say so rather than forcing a pick.Grouping objective. Two inline runs, narrated as one story:
context/compose-broad.md) with the working set, no band — the credible audience of everyone the brief describes; its measured reach is the base set's size. Unlike the max-reach objective, a wide or ungated base is accepted as intended here (it's the population to partition, not a targeted audience) — a needs_anchor base lands as the base set, not a problem to surface. It is still recorded honestly: the roster record's header carries the base as base 1.4M (ungated) when it landed needs_anchor, so the downstream read knows the population carries no anchor.age traits), "region" into state hashes, "income" into its banded cuts; each bucket is a label + a resolved trait expression, mutually exclusive within the axis. Then run the group partition inline (context/partition-group.md) with the base set's expression as source_expression and those resolved axes — the partition ANDs the buckets, it never looks a trait up. Narrate the partition ("Grouping the 1.4M base set by region × age — 138 groups, 31 above the size floor…"). The partition returns the roster: the top-K best-concentrated groups, each with its label, how concentrated (cell_lift), how many (cell_size), and rank. Grouping by a single dimension is measured exactly across the full base at any size. A cross of two or more dimensions on a base above 50K uses a bounded 50K sample only to pick the top groups worth measuring; each winning group's size and concentration are then measured exactly on the full base, and its people pulled from the full set — so no number shown ever rode on the sample. Either way the roster is complete; the sample only chooses which groups win. Show it — the groups ranked, the lift and size driving each rank both shown — and surface the honest limit every run: how fine a cross the base size credibly supports (a bigger base earns a finer cross, not a bigger sample), and the two ways a group was suppressed, kept distinct — too thin in the sample to trust (below_support, noise) versus a real population under the size floor (below_floor) — plus the coverage (assigned vs. unassigned). Surface any note plainly — a geo boundary type not supported, an overlap-type dimension declined (interest/intent grouping is not supported), or a dimension that proved multi-valued.Re-composing or re-grouping after any change is a fresh run of the same procedure.
Crossing objective. Two inline runs, narrated as one story — compose the seed, then cross it:
context/compose-lift.md) for an in-market seed (the canonical chain), greedy (context/compose-greedy.md) / broad (context/compose-broad.md) when the seed is a sized or wide audience — with the seed working set. Its stack (the expression, and the materialized entity_ids_uri) is the seed you cross from; render it as its own short trace, then move to the cross.context/cross-traverse.md) with the seed as source (its expression or entity_ids_uri), the relationship and the source_entity_type / target_entity_type pair the direction settled (today employs; people→employers, or companies→people), and the resolved target filter (the second working set, or empty). Narrate the cross as it reports ("Crossing 180K in-market people to their employers — 50K joined; keeping the mid-size tech firms → 9.1K qualify…"). The cross returns the roster: the whole qualified set (every crossed entity that passed the filter, unordered), each tagged with its source provenance. Show it — the qualified set, its size, the seed→target provenance, the coverage — and surface the measured counts every run — the observed join fraction, reported as measured, never compensated. Surface any note plainly — a direction the tool can't honor, an empty join, a thin post-filter result, or a seed that didn't materialize.The qualified set is the deliverable here; prioritizing it into a ranked shortlist or breaking it into segments is a further step, not part of the cross. If the user wants "the top N companies," say the set is in hand and the ranking is a separate pass — re-enter the roster through audience-generate-list's overlay play (score against a target-side signal pool, ranked), sort by provenance count (a deterministic read of the roster's own source_provenance — most in-market employees first), or segment it with the grouping objective; don't fake an order traverse didn't produce. Re-composing the seed or re-crossing after any change is a fresh run of the relevant procedure.
A signal stack (band / max-reach / precision). Hand back to the parent's shared landing (audience-generate → The shared spine, step E): write the audience record file, render the landed audience, and offer the downstream (audience-analyze / audience-activate). The record header carries the landing mode: reach 2.4M (band 1M–5M) in band mode, reach 84M (max-reach) in max-reach mode, reach 180K · precision (lift over base) in precision mode; a needs_anchor union the user accepted as wide-open is recorded as max-reach, ungated so the downstream read knows it carries no anchor.
A roster (grouping or crossing). The output is a roster, not a signal stack, so what's saved is the roster record — the shared serialization every roster-emitting strategy (group and traverse today; overlay / expand as they ship) writes, so audience-analyze and audience-activate consume any of them the same way. Its classification columns vary by strategy — group's are group_label, cell_lift, cell_size, rank (ranked groups); traverse's are source_provenance only (an unordered qualified set — no rank or score, since traverse is membership-only) — but the frame (a header naming the build, the rows, honest coverage, an ID-only roster_uri) is shared. Show the rows and write the roster record per the record contract (context/record.md).
Grouping:
# Watt record · kind: roster · play: group · audience: weekend hikers · grouped by region × age · base 1.4M
# dimensions: geo.state × demographic.age_band
# top groups picked on a 50K sample (base 1.4M); sizes & lift measured exact on the full base pruned: 18 below support (60K, noise), 107 below 1,000 (220K) coverage: 402K of 1.4M assigned to top 10
# roster_uri: workflow://…/roster.csv columns: entity_id, group_label, cell_lift, cell_size, rank entity_type: person
rank,group_label,cell_lift,cell_size,entity_ids_uri
1,CO · 25–34,3.1×,44K,workflow://…/cell_01.csv
2,WA · 25–34,2.6×,38K,workflow://…/cell_02.csvCrossing:
# Watt record · kind: roster · play: traverse · audience: in-market solar buyers → employers · filter: mid-size tech firms
# crossing: employs (person→business) seeds 180K · joined 50.4K · qualified 9.1K
# roster_uri: workflow://…/roster.csv entity_type: business
# next: rank into a shortlist or segment the set — a separate step, on request
entity_id,source_provenance
b_8f…,3 in-market employees
b_2a…,2 in-market employees
… (sample; full set behind roster_uri)The roster record is the roster's canonical serialization: its classification columns are first-class and must survive downstream, the roster_uri carries the full entity-ID set (IDs only — never contact data) as a CSV of those columns, and the # crossing: / coverage line stays honest about the measured join and what was filtered out. A grouped record's rows each carry that group's entity_ids_uri (the entity_ids_uri column — the cell artifact from the partition's return, the cell_01.csv values above) — that URI is what a per-group read (audience-analyze-list) or a per-group export (audience-activate) consumes; the flat roster_uri carries the per-entity classification columns, never the per-group URIs, so dropping the entity_ids_uri column from the record severs the per-group paths. Then offer the downstream in one line: read who these entities are over the roster (audience-analyze); export the set (audience-activate); or shape it further — segment it with the grouping objective, or rank it into a shortlist (the roster re-enters audience-generate-list's overlay play). Either of the first two runs on their say-so.
needs_anchor is a finding to surface with its anchor candidates, and "accept wide-open reach" is the user's to choose, not yours to assume. In precision mode, the base and the output mode (tighten / reach more like them) are the user's pick — never swap them silently. In grouping, the dimension is the user's — they name it (or an audience-analyze read surfaces it first); group never discovers or auto-picks it. In crossing, the direction and the target filter are the user's — never flip the crossing or narrow the crossed side on your own.calculate_trait_lift (intent signals have no world baseline there); group measures every cell's size and lift directly (the joint cell, never arithmetic over the marginals); traverse crosses real employs edges, reports the observed join — never an assumed rate — and filters to the qualified set (membership only — it never scores, ranks, or truncates; ordering the set is a separate strategy). Per-signal scores rank; the probe's total is the only truth about a combination — and broad's credibility fraction is measured against a geo-correct universe, never assumed.entity_relations exposes (today employs, both directions) — don't offer a crossing the tool can't honor.audience-activate's lane.audience-analyze (-search) — route there.)audience-analyze; this leaf composes or partitions toward a target.audience-generate-list anchor — route there (it resolves the list to a matched roster). To read a supplied list as aggregates, audience-analyze-list; to build from a description, give the brief.needs_anchor conservatively rather than waving an ungated wide union through — surface that as the over-broad finding, not as an error.note, not a fabricated grouping; relay it and offer a supported dimension.note, not a fabricated roster; relay it and put the next move to the user — broaden the seed, drop a target-filter must-have, or accept the smaller set.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.