CtrlK
BlogDocsLog inGet started
Tessl Logo

audience-analyze

Read who a built audience reaches, as aggregates over a deterministic sample — and, on request, a shareable signal membership report. The read step behind /watt:audience; routes to the way in that fits what the user has — a brief, signals they already hold, or a list of people. Aggregates only — never individual records, never contact data, never an ad-platform export. Not a user command — /watt:audience is the front door. Use when a read-shaped ask arrives — "who's actually in this audience", "what do these people look like", "an audience profile for my client" — or to sanity-check a signal stack before exporting.

67

Quality

84%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

Analyze an audience

Purpose

audience-analyze — the read step behind /watt:audience — answers the question generate can't: generate guaranteed an audience's size (or, for a profile, measured its headcount); this read shows what it means. The user walks away with a dashboard in two halves: your signals — the stack's own signals by share and how many each person hits — and discovered — the net-new traits that define these people against the world by lift, plus segmentation, skews, freshness. For a market profile, this read is the deliverable: on request it writes the two halves to a self-contained shareable report file.

There are three ways into that read, by what the user brings — and this skill routes to the one that fits:

  • a brief — they describe the audience in business terms and want the signals discovered for them → audience-analyze-search.
  • signals they already hold — a signal stack from generate, an explore pool, or a list of signals they name → audience-analyze-signal.
  • a list of people — identifiers to resolve and profile → audience-analyze-list (discovered half only — no signals were specified).

Route; don't run. Your job at this level is the routing question and the shared canon below — the language, the aggregates-only lane, and the read-and-report procedure every leaf composes with. The leaf does the discovery, the inline profiling run, and the render.

Works with

  • Called by: the /watt:audience router, or a sibling leaf's offer (audience-generate at its landing — a build to sanity-check, or a profile whose report is the deliverable; audience-activate after delivery) — with a built audience in session, a re-supplied audience record, or a fresh read-shaped ask.

  • Hands off to: the three leaves —

    • audience-analyze-search — brief → discover signals → organize into pools → operator pivot → materialize → read.
    • audience-analyze-signal — a supplied stack/signal list → materialize → read (skips discovery).
    • audience-analyze-list — a supplied list of people, as identifiers (resolve to entities) or as already-resolved entity IDs (a roster from grouping — skip the resolve) → discovered-only read.

    (After the read, the user continues into audience-generate to re-pivot or audience-activate to export — offered at the closing question, step 3.)

Language

Shared across every leaf — the surface the user reads is always business language; the boolean operators never reach them.

The user saysWhat it means
signala trait
must-havean AND gate every person satisfies
exclusionan AND_NOT — people to leave out
audience, market, the peoplethe person set the signal stack reaches
reach, market size, headcountthe count the build/resolve measured

New to this surface: lift is explained once, in plain English — "how much more common a trait is in your audience than in the population — 5.6× means these people are 5.6 times likelier than average to have it" — then used freely. Sample is named honestly: for the search and signal flavors the read is computed over a fixed, reproducible slice of the audience, and the dashboard says how big; the list flavor reads the supplied set whole — no sampling — and the dashboard says that instead.

Entry — pick the way in

  • A built audience is in session (fresh from generate, or just composed in a leaf) → audience-analyze-signal, with that stack. Confirm which one in a word if there's any doubt, then go.
  • A re-supplied audience record — the user pastes a previous session's record (signals with names and hashes by role group, plus reach/headcount) → audience-analyze-signal; take the role groups as the expression exactly as built, and a past session's figures are "measured then", not re-measured silently.
  • A brief, no signals yet — "who's in the market for X", "an audience profile for a Nashville roofer" → audience-analyze-search, with everything they've said.
  • A list of people → audience-analyze-list — the one input the read can profile without composed signals; discovered half only. The list comes either as identifiers (a CSV / pasted emails — -list resolves them) or as already-resolved entity IDs — a roster from the grouping objective, or any entity-ID set the user holds — where -list skips the resolve and reads them directly. A roster carries group_label, so the whole set (its roster_uri) or a single group (its entity_ids_uri) can be read; either way there's no specified-signals half — these are people, not a signal stack the user named.
  • Nothing to read and no brief → there's nothing here yet; route to audience-generate (build) or /watt:explore (just curious) honestly.

Route silently when the shape is already answered — skip the question, never the handoff. Carry everything the user already said into the leaf; it must not re-ask it.

The read & report — shared canon every leaf composes with

Each leaf reaches the same place — a built audience (a signal stack, or a resolved entity set) — and from there the read is identical. This is the procedure the leaves inherit; they don't restate it.

Aggregates only. Every number on screen — and in the report file — is computed over the audience as a whole; no individual record, identifier, or profile is ever pulled, shown, or written. A user who wants the people themselves is asking for audience-activate, behind its own confirmation. The report file is aggregates, not contact data — a different artifact from activate's PII export.

1 — Run the read inline

On the user's go (arriving at a leaf with a built audience is the go), run the profiling procedure inline — read it once from the bundle (cat "<Bundle root>/context/profiling.md", the path published at session start) and follow it here on the main thread:

  • with a signal stack (search / signal) — mode A: the expression_string, the stack's signals (hashes, names, roles), the location, the measured reach, and a shared workflow_id. Both halves come back.
  • with a resolved entity set (list, or a roster) — mode B: the entity_ids_uri and the resolved count. The discovered half only — no signals were specified. For a roster, the entity_ids_uri is the whole roster_uri (the audience across its groups) or a single group's set; a per-group read is mode B re-run once per group's entity_ids_uri, the group_label naming each.

Narrate it plainly ("Reading who your 2.4M actually are — drawing a fixed sample and comparing its traits against the world…"). Track the read as a session task; complete it when the aggregates are in hand.

2 — Render the dashboard

The read carries section headings throughout — each part below sits under its own labeled heading (Summary, Your signals, Discovered, Skews, Freshness & band, Basis), in both the rendered dashboard and the shareable report file, so the read scans as a structured document and a reader can jump to the part they want.

Open with Summary — the 3–5 sentence plain-English read seeded from the procedure's findings — facts about who these people are, no recommendation. Then the dashboard — the surface the user reads is a rendered inline visual; the full read is captured in the shareable report file (written on request, step 3). The dashboard and the report both hold the procedure's halves, each under its heading:

  • Your signals (specified — search/signal flavors only) — each stack signal by its share of the audience, and the coverage distribution: how many of your signals each person hits (most hit just one or two — that's a finding about how the signals overlap). Absent for the list flavor, which specified no signals.
  • Discovered (net-new — every flavor) — defining traits: name · prevalence in the audience vs the world · lift, strongest first, under-represented included and labeled (what the audience lacks is a finding); stack-own signals are excluded here, so this is genuinely net-new. Plus the per-domain breakdown, with the intent panel surfaced by reach (top ~10) — intent over-indexes weakly, so it earns a by-size read, not a lift framing.
  • Skews — one line each, only the real ones.
  • Freshness & band — the intent share, and where measured reach sits in the band (or the headcount, for a profile), stated once.
  • Basis — the basis line, always present: "aggregates over a fixed sample of 5,000 of the 2.4M" for the search/signal flavors (an aggregate without its basis reads as a census); "aggregates over all 4,200 people in your list — the whole set, no sampling" for the list flavor (there it is a census of the set — say so).

Author the dashboard as a data-only visual: the sections under their labeled headings — Your signals and Discovered (the two halves), Skews, Freshness & band, and the Basis line in the frame, with Summary leading — a prevalence-vs-world bar and the lift figure beside each defining trait, your-signal shares and the coverage spread, the freshness mix. Show how the read was produced, one line: sample → your-signals membership + trait aggregation (intent split out, geo excluded when fenced) → lift against the world.

3 — End at the next decision, and the report on request

One question — landed as native clickable options — does this audience read right? The real options, when they fit: re-pivot (back into the audience-generate step, or back into the leaf's own pivot — the read is the evidence: drop a signal dragging in the wrong people, add an angle that's missing), save the shareable report (write the read to a self-contained file — the deliverable for a profile, and offered for any read), export it (the audience-activate step), read deeper (a domain the dashboard didn't cover — a fresh inline run), or done.

The signal membership report (the default). On the user's yes, assemble the procedure's read into a profile JSON — specified, coverage, discovered, breakdown (the procedure returns these field names already) — author the 3–5 sentence executive summary, and run the shared report script (build_report_membership.py — the default two-section signal membership report; future report types are build_report_<name>.py siblings). It lives in this audience-analyze skill's directory (not the leaf's), so any leaf runs it at ${CLAUDE_PLUGIN_ROOT}/skills/audience-analyze/scripts/build_report_membership.py; locate it if the runtime relocated the files. For a discovered-only read (the list flavor), pass --no-specified so the report drops the your-signals section:

python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-analyze/scripts/build_report_membership.py" --profile /tmp/profile.json \
  --title "<client> — in-market buyers, <geo short>" \
  --location-label "<human geo>" --expression "<human-readable expression>" \
  --headcount <full reach or resolved count> --sample <sample size> \
  --workflow-id <W> --gates '{"must_have":[…],"exclusion":[…]}' \
  --summary "<exec summary>" \
  [--no-specified] \
  --out-html ./<client>_membership_report_<YYYYMMDD>.html \
  --out-json ./<client>_membership_report_<YYYYMMDD>.json

It writes a self-contained HTML report (no JavaScript, no CDN — opens offline, aggregates only, no PII) plus a JSON audit. Write the deliverable into the working directory — a durable, discoverable home — not a temp path the OS can sweep; only the throwaway --profile input lives in /tmp. Surface the absolute path. For the standard report the math, filters, and layout are the script's — don't hand-rebuild what it already renders.

A custom report, on request. When the user wants something the standard report doesn't give — a different cut, extra or fewer stats, their own framing or layout — author a bespoke one on the fly: a self-contained HTML file (no JavaScript, no CDN, opens offline) crafted for the ask, written to the working directory. Two rules keep it honest: every figure traces to the profiling procedure's aggregates — the specified/coverage/discovered/breakdown you already hold, or a fresh inline run of the profiling procedure (context/profiling.md) for a cut it didn't cover, never a number you invented — and it stays aggregates-only — no row, no PII, like the standard one. The script is the consistent default; this is the escape hatch when the user asks for more.

How to behave

  • One question, then route. If the way in is clear from the ask, route without the question. Never start the work here — no trait searches, no inline runs, no render; a router that "helps a little first" becomes a second copy of the leaf.
  • Name what v1 doesn't do, before the leaf has to. Audiences are US-only, adults-only, person-only; employer/job-title as the defining criterion redirects to interest/demographic/location framing. Catching these at the door beats a dead end two steps in.
  • Narrate the read and report its result in one plain line each — never the structured payload. (Binds the leaves, which run the read inline.)
  • Facts, not verdicts. The dashboard says who the audience reaches; whether that's right is the user's call. Never grade the audience.
  • Aggregates in, aggregates out — including the file. No record, identifier, or PII ever — that's distinct from activate's lane. The sample stays server-side — chained by URI and processed by script — never surfaced to the workspace.
  • The record contract loads on demand. When a leaf writes or refreshes a record file — the working-set pool in -search, a refreshed stack in -signal — read the record contract first from the bundle: 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.

Refuse cleanly

  • "Show me some of the actual people." "Analyze reads the audience as aggregates — individual people only exist downstream, in the export, and the activate step will confirm scale and identifiers with you first."
  • "Just tell me if it's good." Give the facts and where they're strong or thin; the call is theirs.
  • An export-shaped ask ("push it to Meta") → that's audience-activate, named honestly; this step reads, it doesn't export.

Failure modes

  • A tool error mid-read. The inline read halts and surfaces it; render what completed, name what didn't — never fill a dashboard band with plausible numbers.
  • python3 missing or build_report_membership.py fails. Say so and offer the in-chat dashboard as the fallback — never hand-assemble the HTML to stand in for the script.
  • A leaf can't reach a built audience (the user expected one in session and there isn't) → route to audience-generate honestly rather than reading an empty set.
  • A Signal Graph call fails on connection or authentication — including a call your inline read made coming back reporting the connector isn't authenticated. Your first Signal Graph call is your liveness gate: read its shape per the connector-readiness rule in your session overview ("Getting connected"). A genuine auth shape isn't yours to work around — stop, send the user to /watt:configure (it owns the connect path and recovery docs), and never loop the connect / authenticate tools, diagnose the MCP registry, or press on. (A pre-call notice isn't a failure; your first call settles it — never announce one.)
Repository
wattdata/plugin
Last updated
First committed

Is this your skill?

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.