| name | audience-activate |
| description | Export a built audience as platform-ready files — confirms the platforms, the scale, and the identifier types with the user, then materializes the audience once on this thread, batch by batch, and runs each platform's deterministic writer script, returning the finished files and honest row counts. Contact identifiers only by default; device IDs (mobile-ad IDs) ride an after-export pass added only when the user asks for them. Meta, Google, Reddit, and TikTok are the supported platforms, one or several at once. Never runs unconfirmed. Not a user command — /watt:audience is the front door. Use when an export-shaped ask arrives — "export it", "push it to Meta", "push it to Google and Reddit", "push it to TikTok", the audience as a file. |
| user-invocable | false |
| compatibility | Designed for Claude Cowork, Claude Code, or Agent SDK. Requires Signal Graph. |
Activate an audience
Purpose
audience-activate — the export step behind /watt:audience — turns a built audience into the deliverable: a file per platform, each shaped to that platform's spec by its bundled writer script — one script per platform under scripts/writers/. The writer scripts present are the platforms that ship — Meta, Google, Reddit, and TikTok, each its own self-contained writer script. The user picks one platform or several, confirms exactly what will happen, and walks away with the files, their row counts, and the reproducibility handle. The audience is materialized once, however many platforms are picked — the pull off Signal Graph is the expensive part, so it happens a single time and each platform's file is shaped from that one pull.
Nothing exports unconfirmed. Before the run, state plainly: the platforms, roughly how many people, which identifier types ride along, what each platform's spec does to them, and the exact row ceiling. Each platform's writer owns that transform, and they differ — confirm each one the user picked: a Meta file hashes emails, phones, names, cities, states, zips, and country, with mobile-ad IDs riding raw (the one identifier Meta keeps in the clear); a Google file hashes emails, phones, and names but keeps country and zip in the clear, with mobile device IDs written to a separate unhashed list; a Reddit file hashes emails and takes mobile-ad IDs raw, both in one combined list; a TikTok file hashes emails and phones and takes mobile-ad IDs raw, all in one row per person (with a 1,000-person upload floor). Device IDs are off by default — every export is contact-only unless the user asks for mobile-ad IDs (or "device IDs" / "MAIDs"); when they do, the confirmation says so and the after-export pass runs once the contact files are delivered. The user's explicit yes — including that number — is the gate. Anything truncated or pruned is named before the run, never discovered after.
The run happens on this thread — foreground, nothing detached. The pull off Signal Graph is the expensive part, so this skill runs it directly, in bounded concurrent waves — never dispatched to a worker that goes quiet, and never as a background or "detached" job you return from early and poll. A steady pulse is the point: the user sees progress accrue toward the finish, not a multi-minute silence. But the pulse is the milestone, not the machinery — "Pulling your audience — 1.2M of 2.2M," "Building your Meta and Reddit files" — never the batches, waves, offsets, URLs, or retries underneath it. How you speak while it runs is its own contract: How you speak while it runs.
Works with
- Called by: the
/watt:audience router, or a sibling leaf's offer (audience-generate at its landing, audience-analyze at its closing question) — with a built signal stack or a roster in session, or a re-supplied audience record.
- Runs, on this thread: the export itself — the union of the confirmed platforms' contact identifiers pulled once (a signal stack's
expression_string materialized with entity_find, or a roster's entity_ids_uri enriched), in fixed-size batches fired in bounded concurrent waves that page through the whole audience, each platform's transform run over that one pull per batch, straight to per-platform files with honest row counts. Only when the user asks for device IDs, a second pass after delivery pulls the mobile-ad-ID lane once and merges it into each platform's file. Each platform's writer script (scripts/writers/<platform>.py) owns hashing and layout; the shared scripts/writers/_common.py owns the batch-window sizing, the concurrency bound, the disk preflight, and the device-lane spill/merge/join.
- Hands off to:
audience-analyze — offered after delivery if the user hasn't had the read; another run for a different slice stays here.
Language
This surface says export, hashed — and explains the hashing once, plainly: "Export files carry hashed identifiers — your file holds digests, not raw emails, except the fields the platform reads in the clear." (Meta hashes everything but the mobile-ad ID; Google leaves country and zip in the clear and lists device IDs unhashed; Reddit hashes emails and takes mobile-ad IDs raw; TikTok hashes emails and phones and takes mobile-ad IDs raw — device IDs land on an after-export pass that runs only when the user asks for them.) It also names the expected audience size the honest way — the real-people range the platform will likely reach from this file, shown as a count, never a percentage or a guarantee (the full posture is below). Watt's word is audience size, never match rate — if the user arrives with "match rate," recognize it and answer in audience-size terms, never adopt it. The stack vocabulary (signals, must-haves, exclusions) carries over from generate; AND/OR/AND_NOT still never reach the user.
How you speak while it runs
The pull and the transforms take minutes, so a steady pulse matters — but every line is a milestone the user cares about, never the machinery that produces it. The user is watching their audience get built; they are not watching a shell script run. This is the single source for the run's voice; the flow steps below defer to it.
- Pulse on progress the user owns — people pulled against the total, the platform files taking shape, the device pass moving. "Pulling your audience — 1.2M of 2.2M." · "Your audience is down — building the Meta, Google, and Reddit files now." · "Adding device IDs — about halfway." One calm line every so often, enough that there's never a multi-minute silence.
- The machinery never surfaces. Batches, waves, offsets, download URLs, byte ranges, checkpoints, cursors, slices, windows, spill/merge/join, the per-call time ceiling, disk/GB,
has_more, workflow_id — these are how the run works, not what the user asked for. They never appear in a user-facing line. Counts and platform names do; the plumbing that moves them doesn't.
- Recovery is silent. A timeout you back off from, a URL you re-mint, a window you resume from a checkpoint, a transform you re-run to
done, a teardown lock you wait out — these are the reliability discipline doing its job, invisibly. Never narrate a retry, a re-fire, or a switch of approach. The user hears about a stumble only when it becomes a decision they must make — a genuine halt: a batch that keeps failing with no smaller scale offered, a disk that won't hold the run, a connector that needs reauthorizing. Then you stop and ask; short of that, the recovery is yours and stays quiet.
- Speak to the audience, not the plumbing. Every user-facing line is about their audience and where it stands — people pulled, files taking shape, the device pass moving. How the run does its work — how it chunks, paces, resumes, or steadies itself through a hiccup — is the model's to manage, not the user's to read. A calm line about real progress builds trust; a play-by-play of the internals working through a stumble reads as thrash, even when the run is going fine.
Entry
- A built signal stack is in session (fresh from generate or analyze). Preflight network access (flow step 1), then go to the confirmation (flow step 2).
- A re-supplied audience record — read from the saved record file in the working directory, or pasted in: signals with names and hashes by role group, plus reach, from a past session. Take it as given: its role groups reconstruct the expression exactly as built — a deterministic reading, not a modification — its
location: line carries the fence the run re-applies (none (national) means no filter — dropping the line would silently widen the export), its entity_type rides into the pull, and its reach is "measured then" until the run re-measures. A refresh-shaped re-supply ("re-run this export") is exactly this path — the materialization is the refresh — and after delivery, re-write the audience record per the record contract with today's measured total against the header's original target and the · refreshed suffix (reach 26.1M (band 1M–5M) · refreshed), so the user walks away with the updated recipe alongside the export file. The contract loads on demand, not at session start: read it from the bundle first — cat "<Bundle root>/context/record.md" (the path published at session start) — then write the record to it.
- A roster — from any roster-emitting strategy (grouping or crossing from
audience-generate-search; expand or overlay from audience-generate-list): an ID-only entity set already chosen, with classification columns that vary by strategy. Grouping gives ranked groups (group_label, cell_lift, cell_size, rank; roster_uri for the whole, an entity_ids_uri per group). Overlay gives a ranked list (overlay_score, signals_matched, rank — rows, not groups). Crossing and expand give an unordered membership set — no rank, no groups (crossing: entity_id + source_provenance; expand: entity_id + match confidence columns). Whichever it is, the entities are already chosen — there's no expression to materialize; the export shape is the choice (the confirmation, flow step 2). Take the record as given; never re-rank, re-group, or re-score it.
- A signal pool (an
/watt:explore session's kept signals, or a lookalike pool) with an export intent. A pool has no expression yet — auto-compose it to the default stack first: pool-marked must-haves all-of, exclusions none-of, the rest any-of; if the pool carries no role markers, ask once ("any must-haves or must-have-nots, or export them all as one group?") before composing. A deterministic reading of the user's picks (the same operation as reconstructing a record's role groups), never a strategy compose — note plainly that a tuned build is audience-generate's lane if they want it. Measure the headcount once (count-only), then go to the confirmation with that real number.
- No audience anywhere. Nothing to export — route to the
audience-generate step honestly.
- A platform with no writer script ("push to Snapchat"). Say what ships today — Meta, Google, Reddit, and TikTok — before anything else; offer a shipped-platform run if it serves.
The flow
The preflight (step 1) precedes every path in — a built stack, a re-supplied record, a roster, or a pool — before any confirmation or number reaches the user.
1 — Preflight: confirm the connection is live
The export runs through Signal Graph's download/upload tools (generate_download_url, generate_upload_url), and an export that dies because the connector was never live wastes the user's confirmation. So before naming the platform menu or quoting a number, fire your liveness gate: one live probe — a single trait_search with a real query (e.g. "data"), never a placeholder-argument call to a download tool (a missing-argument or validation error isn't a connection failure, and acting on it would push a connected user into the connect flow wrongly). Checking only that the tools are registered isn't enough — presence isn't authentication; a real call is the only thing that proves the grant is live. This is cheap, and it catches a missing or unauthenticated connector up front, here.
What the probe cannot catch is the code-execution network-egress block: the download/upload tools return a presigned URL successfully even when egress is denied, so that failure only surfaces later, when you fetch the artifact — and it's handled at pull time by its own entry in Failure modes, not here.
- The probe returns → the connection's live. Note it in a line and move to the platform pick.
- The probe fails → read its shape per the connector-readiness rule in your session overview ("Getting connected"): a still-connecting shape gets a few silent retries; a genuine auth shape means you don't have a live connection to run the export. Stop here, before any confirmation — don't retry blindly, don't loop the auth tools, don't improvise a local file. Send the user to
/watt:configure to get the connection fixed (it owns the connect path and recovery docs), then have them re-run once it's connected.
2 — Pick the platforms, confirm with the real numbers
Resolve the platforms from what's actually shipped. The writer scripts bundled at ${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/writers/<platform>.py are the platform menu — list them with ls "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/writers/" (a name beginning with _, like _common.py, is shared plumbing, not a platform): today that's Meta, Google, Reddit, and TikTok, so the platforms are the user's first pick — name the menu and let them choose one or several before anything else. Then python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/writers/<platform>.py" --list-identifiers gives the contact identifiers you confirm for each chosen platform — Meta and Google: email, phone, name, address; Reddit: email; TikTok: email, phone — and --uses-maid says whether the platform also takes mobile-ad IDs. The script is the source of truth, not memory.
Because the audience is materialized once for the whole run, the pull carries the union of the chosen platforms' contact identifiers, and each platform's file is shaped from that one pull. Size the batch window on that union — python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/writers/_common.py" --contact-window <union-domain-count> (today 200,000 people a batch; the script is the source of truth, not memory). A single writer's --export-cap reports only its own domain count, so use the union count here when more than one platform is picked.
Mobile-ad IDs are off by default — every export is contact-only unless the user asks for device IDs (or "mobile-ad IDs" / "MAIDs"). When they do, the device IDs ride a second pass — confirmed at this gate, run after the contact files are delivered. They're the heavy domain to pull — folding them into the main export is what makes a large pull fail — so the contact identifiers always go first, pulled once in batches and delivered as the files; only then, and only on request, are device IDs pulled once in smaller windows and merged onto the people already exported, where each platform's writer decides where they land (inline, a separate file, or their own rows). Never add the device pass on your own initiative — it's opt-in, and the contact export is the whole run unless the user said otherwise.
Work out the scale honestly — and large is fine. The export covers the smaller of the audience's measured reach and the user's own number, the whole of it however large, pulled in fixed-size batches so size is never a reason it can't finish. The union batch window above is the size of each batch, not a ceiling on the run: a 2.4M audience exports as 2.4M, paged in batches of 200K. Name the real number in the confirmation; when the user's number is below reach, that's their chosen slice of it ("600K of your 2.4M"), said plainly.
A roster adds one choice — the export shape, and the roster's classification bounds what's offered:
- The whole set — one file over the entire
roster_uri, paged through in batches like any export. Always available; for a membership-only roster (crossing's qualified set, expand's widest match — no rank, no groups) it's the shape — there's nothing to take a "top-N" of, and no groups to split.
- Top-N by rank — the best-ranked slice, one file per platform. Only when the roster carries a
rank (grouping and overlay today) — never offered for an unranked membership set. For a grouped roster the slice is the top groups (e.g. "the top 5 groups, ~190K people" — the combined entity-ID set of those groups); for an overlay roster it's the top rows (e.g. "the top 500 by score"). One pass over the chosen set, shaping every picked platform from it.
- One file per group — a file per group per platform, the
group_label naming each (e.g. five metros × two platforms → ten files). One pass per group's entity_ids_uri, each paged through its group in batches and shaping every picked platform. Only when the roster has groups (the grouping objective).
Name the shape choice in the confirmation alongside platforms, scale, and identifiers — for one-file-per-group, the scale is per file and the file count (groups × platforms) is part of what the user authorizes.
Then the gate — landed as native clickable options — one decision, all the facts in it. State each chosen platform's actual transform; they differ, so confirm the right one for every platform picked. The default run is contact-only; if the user asked to add device IDs, confirm that the after-export pass will run too — otherwise the gate is contact-only, and device IDs are offered as an option at the closing decision (flow step 4), not baked in here. Each platform line also carries its expected audience size — of the people in this run, roughly how many that platform will likely reach on its side — computed from the run's people ceiling by audience_size_range.py, this skill's own range script (python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/audience_size_range.py" <people_ceiling> → the rounded low–high count to quote). It's a platform-side estimate set in real people, never a percentage and never a guarantee — see expected audience size. A single-platform gate is one line; a multi-platform gate lists each picked platform, then the one scale and the device-ID default:
Exporting weekend hikers, all 2.4M, pulled once in batches of up to 200K, into two files:
— Meta (email, phone, name, address; PII hashed). People with no email or phone are dropped. Expected audience size ~1.2M–1.9M.
— Reddit (emails hashed). People with no email are dropped. Expected audience size ~1.2M–1.9M.
Contact identifiers only — device IDs aren't included unless you want them. Each number is a platform-side estimate, not a guarantee. Go — both, contact-only? (Or add device IDs.)
When the user did ask for device IDs, the gate confirms that too — swap the closing line for: "Device IDs included — a second pass after the contact files land, folding mobile-ad IDs into each file. Go — both, with device IDs?"
The single-platform lines, for reference — the same facts, one platform:
Meta — Exporting weekend hikers, all 2.4M with email, phone, name, and address into a Meta file (PII hashed), pulled in batches of up to 200K, contact identifiers only. People with no email or phone are dropped — Meta has no way to reach them. Of these, your expected audience size on Meta is ~1.2M–1.9M people — a platform-side estimate, not a guarantee. Go — contact-only, or add device IDs?
Google — Exporting weekend hikers, all 2.4M into a Google file (emails, phones, and names hashed; country and zip in the clear), pulled in batches of up to 200K, contact identifiers only. People Google can't match — no email, phone, or mailing address — are dropped. Of these, your expected audience size on Google is ~1.2M–1.9M people — a platform-side estimate, not a guarantee. Go — contact-only, or add device IDs?
Reddit — Exporting weekend hikers, all 2.4M into a Reddit file (emails hashed), pulled in batches of up to 200K, contact identifiers only. People with no email are dropped. Of these, your expected audience size on Reddit is ~1.2M–1.9M people — a platform-side estimate, not a guarantee. Go — contact-only, or add device IDs?
TikTok — Exporting weekend hikers, all 2.4M into a TikTok file (emails and phones hashed), one row per person, pulled in batches of up to 200K, contact identifiers only. People with no email or phone are dropped. Of these, your expected audience size on TikTok is ~1.2M–1.9M people — a platform-side estimate, not a guarantee. (TikTok won't accept a file under 1,000 people.) Go — contact-only, or add device IDs?
An explicit yes including the number is the only thing that opens the gate — for every platform in it, and for the device-ID pass if the user asked to add it. "Just do it" without the scale on screen is not a confirmation — put the numbers up and ask once.
3 — Run the export, on this thread (contact lane)
This loop runs the pull — foreground, never a worker and never detached. Every entity_find / entity_enrich, every download, and every writer-script run happens here, on this thread, and the user sees progress accrue toward the finish, never a multi-minute silence — pulsed on real coverage in their terms, not the batch mechanics (How you speak while it runs). There is no dispatch to a subagent and, above all, no background or "detached" job you kick off and return from to poll — that hides the run and drops its tail. The reliability discipline lives right here: bounded concurrent waves, a fresh download URL per batch, byte-range downloads under the shell's per-call ceiling, entity-cursor reconciliation, and timeout back-off. The deterministic math — batch-window sizing, the concurrency bound, the disk preflight, the resumable batched transform, the device-lane sort — lives in the shared _common.py and the writer scripts; you orchestrate the calls, you never re-implement their math.
Resolve where files go. Two directories: dest_dir, the deliverable destination the host surfaces finished files from (in Cowork the outputs mount, e.g. $HOME/mnt/outputs; otherwise the working directory) — it may be slow and may forbid deletion, so write finished files there in bounded appended slices and never rm from it — and scratch_dir, fast local temp for intermediates (the host temp, e.g. $TMPDIR), which you reclaim as you go.
Preflight both disks before pulling anything. A large all-platform export is the dominant way a run runs out of room, so estimate demand and check it up front: python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/writers/_common.py" --preflight --people <confirmed ceiling> --delivery-bpp <sum of every target's --est-bytes-per-person> --scratch-dir <scratch_dir> --dest-dir <dest_dir>. If it comes back short, halt before the pull and surface which disk and by how much (the disk failure mode) — never pull into a disk that won't hold the run.
Size the batch plan. The pull carries the union contact domains across the confirmed platforms (step 2's --list-identifiers per target — email, phone, name, address whenever Meta or Google is in the set). Size the batch window on that union count — python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/writers/_common.py" --contact-window <union-domain-count> (today 200,000 people a batch) — and read the concurrency bound — python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/writers/_common.py" --export-fanout (today 3, sized to the export worker's slots). Plan one format: csv slice per window: offset 0, one window, two windows, … up to min(confirmed ceiling, total).
Set the expectation in one line, then pull — and keep the pulse going. Name the scale and the platforms to come, in the user's terms — "Pulling your 2.4M now, then building the Meta and Reddit files — I'll keep you posted as it moves." — then start. Keep a calm pulse on real progress as it runs — the people pulled against the total, the files taking shape — never the batch-and-offset mechanics underneath (How you speak while it runs).
Pull in bounded concurrent waves. Fire the plan in waves of at most the fan-out bound (3), and fire a wave's calls concurrently — as several tool calls in one turn — so they truly overlap. (Firing one call per turn and waiting for each is the serial anti-pattern this design exists to fix: it turns a 3-wide pull into a single-file crawl.) Each wave completes before the next fires, walking the plan in ascending offset. Each call, by input form:
- A signal stack —
entity_find with the expression_string exactly as built, the location, the entity type, the union contact domains, max_identifiers: 3, format: csv, audience_limit = the batch window, the given workflow_id, its own offset.
- A roster —
entity_enrich the IDs at the entity_ids_uri (never inline-batch them — the forbidden slow shape) for the union domains, max_identifiers: 3, format: csv, the batch window, the workflow_id, its offset.
Keep the pulse on people, not batches — "1.4M of 2.4M pulled" as coverage climbs — never a wave-or-offset readout, and never a silent multi-minute run of calls (How you speak while it runs).
Mint each download URL fresh, then hand it to the download driver. Mint each batch's URL right before its download — generate_download_url on that batch's workflow:// resource, fresh per batch — then hand that URL, once, to the shared download driver, which owns the byte-range fetch so the ~2.5 KB URL crosses into your context a single time per batch instead of once per segment: python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/writers/_common.py" --download --url <minted-url> --out <scratch_dir>/batch_<offset>.csv --checkpoint <scratch_dir>/batch_<offset>.download.json. The driver owns the segment boundary — it pulls the artifact in ranged segments, self-caps inside the shell's 45s ceiling, and prints a JSON line ending …, "done": <bool>, "expired": <bool>. Re-run the identical command until done is true — each call resumes from the checkpoint's committed byte and truncates any partial a stopped call left, so a re-run never doubles or drops a byte. On "expired": true (a presigned URL that lapsed mid-pull), re-mint the URL with generate_download_url and re-run with the fresh --url — the workflow_id pins the same artifact, so the resume from the committed byte is byte-identical. One self-capped round per call; never wrap the re-run loop inside a single shell call (the same ceiling discipline as the transform below). Download a wave's batches concurrently — background one driver per batch in a single shell call (… & … & wait), each to its own --out / --checkpoint, then re-issue whichever still report done:false. Then concatenate the raw batches in ascending offset order into one retained union contact_file in scratch_dir (first batch's header, the rest headerless) — the file every platform's transform and any device pass reuse.
Reconcile on the entity cursor before you trust coverage. offset is an entity cursor and the server returns the next as next_offset (not the row count — rows run higher than entities, since one person emits several identifier rows). An interior batch can come back short of its window without being the last, so the precomputed offsets are a first pass only. Map each batch to the span it covered — [offset, next_offset) — and fire a fill batch at any gap (same fan-out bound) until the spans are contiguous from 0 and the farthest next_offset reaches min(ceiling, total). Gate on that entity coverage, never a raw-row sum. Signal Graph recomputes daily, so total can shift between the confirmation and the pull — trust the run's own enumerated coverage, not the figure quoted at the gate, and name the real number plainly if it moved. A residual gap you can't close is a loud halt, never a shipped shortfall.
Transform each platform over that one pull — bounded, resumable, straight to dest_dir. For every confirmed platform, drive its writer's --phase transform over the concatenated contact_file: python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/writers/<platform>.py" --phase transform --input <scratch_dir>/contact_file --out-dir <dest_dir> --checkpoint <scratch_dir>/<platform>.transform.json. Feed it the whole file — the phase owns the window boundary: it transforms one bounded person-window at a time (byte-identical to one whole-file run), self-caps inside the shell's 45s ceiling, and prints a JSON line ending …, "done": <bool>. Re-run the identical command until done is true — each call resumes at the checkpoint's cursor and truncates any partial a stopped call left, so a re-run never doubles or drops a row. Don't hand-split the file, don't size the chunk yourself, and don't loop a bare --input … --append — the window is the unit, and the checkpoint owns idempotency. Run the platforms concurrently — background one --phase transform per platform in a single shell call (… & … & wait); each self-caps at the deadline so the whole call returns inside the ceiling, each writes its own file(s) under its own checkpoint so they never collide, and each JSON line leads with its platform so the lines stay distinguishable. Re-issue that shell call each round for whichever platforms still report done:false, until all are done — one self-capped round per call; never wrap the re-run loop inside a single shell call (a loop of self-capped stints re-crosses the 45s ceiling). Read each platform's grand total / written off its final (done) call — no summing across calls. If the user asked for device IDs, spill each raw batch into the device run-dir before reclaiming it (flow step 5 details the spill). Then reclaim each raw batch and each platform's checkpoint once every platform's transform reports done (and each batch is spilled, when a device pass will run); the retained contact_file — the transform's own input — persists until every transform is done and coverage is reconciled.
Roster shapes. A signal stack is one pass over the expression. A roster's confirmed shape sets the loop: the whole set — one pass over the full roster_uri (the shape for an unranked crossing set); top-N by rank (ranked rosters only) — one pass over the chosen top-N's entity-ID set (the combined set of the chosen groups, or the top rows), its name carried as a filename label (top_5_groups, top_500_by_score); one file per group (grouped rosters only) — loop the whole export once per group's entity_ids_uri, prefixing each group's group_label onto its files, confirming the file count (groups × platforms) is reasonable first and narrating each group. (Classification columns don't ride through enrichment — the writer reads identifier columns only; group identity travels as the filename label, never a column in the platform file.)
Handle a stumble inline — silently, and never by shrinking silently (Failure modes): recovering is the discipline doing its job, not a status update (How you speak while it runs). A timeout means the server job is still running — back off (serialize the lane, re-issue the same pinned offset alone a bounded few times), never re-fire into a live wave; an expired URL re-mints in place; a too-large rejection comes back with a smaller audience_limit to re-confirm — never lower it yourself; a connector auth failure stops the run and sends the user to /watt:configure. The first three are yours to absorb quietly; only a stumble that becomes the user's decision surfaces. Track a session task with a moving label — "Exporting — pulling your audience," then "…building the platform files" — as a heartbeat, and hold dest_dir, scratch_dir, and the retained contact_file: they're what a requested device pass (flow step 5) reuses without re-pulling.
4 — Deliver, with the gaps named
Render the result:
- The file(s), per platform — for each platform, path and row count of each file its writer produced. Some platforms emit more than one, and row counts differ by file: Meta writes one
meta_audience.csv whose rows can exceed persons (one row per identifier pair); Google writes two — google_audience.csv (one row per person) and google_audience_maid.csv (the device-ID list, a separate match path, so its count differs from the person file); Reddit writes one reddit_audience.csv, one row per identifier (each email and each device ID on its own row), so its count exceeds persons; TikTok writes one tiktok_audience.csv, one row per person (its count tracks persons written). Report every file, not just the first, and not just one platform. The contact lane carries no device IDs, so the device-fed outputs are empty here — Google's device list is header-only, Meta carries no madid, Reddit has no device rows, TikTok's maid columns are empty, and each platform's maid_matched is 0. If the user asked for device IDs, say so plainly and note the device pass (flow step 5) fills them next; if they didn't, this is the final, contact-only shape — state that it's contact-only, not a gap, so an empty device column never reads as a shortfall.
- The reconciliation — total resolved vs exported, with any unmatchable persons the writer skipped named. A shortfall the user didn't hear about before the run is a failure; repeat it here regardless.
- The expected audience size, per platform — of the people exported, roughly how many each platform will likely reach on its side, from
audience_size_range.py on that platform's actual exported people count — the people actually written, not file rows (Meta's rows can exceed persons) — python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/audience_size_range.py" <exported_people>. State it in real people, never a percentage: "Of the 600,000 people in your Meta file, your expected audience size on Meta is ~300K–480K." Then the one-line posture, plainly: that linked share is the platform's own measure of who it already has on its side, not a measure of these people's realness or this audience's accuracy; every exported row is a real person, and one the platform doesn't reach just isn't on its side. If the user wants a larger reached audience, name the levers honestly: more identifier types per person, fresher identifiers, and meeting the platform's own list-size minimum. See expected audience size for the full posture and the lines we won't cross.
- The verification line, per platform — each platform's shape-check conclusion: its output files' headers match that platform's spec, with identifier fields digested where the spec hashes them and plaintext where it doesn't. Check each file after its writer runs — one header row, no stray repeat from the per-batch concatenation.
- The handles —
workflow_id (the same export, reproducible) and the source URL (traceability while it's live; it carries the unhashed identifier values if the user needs those — the platform file is the deliverable).
Then, if the user asked for device IDs, say the pass is starting now — one plain line ("The contact files are down. Adding device IDs now — a second pass over the mobile-ad-ID lane, folded into each file: Meta inline, Google as a separate device list, Reddit on their own rows, TikTok in the maid columns; device-only people get picked up too.") — and go straight to flow step 5. Otherwise the export is done at the contact files — no device pass runs unasked. Either way, the closing decision lands as native clickable options: the natural next steps — add device IDs if they weren't included and the user now wants them (that runs flow step 5), another run for a different slice, an audience-analyze read if they haven't had one, or done.
5 — Add device IDs (the after-export pass — only when asked)
Runs only when the user asked for device IDs — at the gate, or by taking the offer at the closing decision (flow step 4). Never on your own initiative. Like the contact lane, it runs here, on this thread, window by window — no worker, no detached job. Device IDs are the heavy domain, pulled apart from the contacts so they never sink the export; the contact lane is already on disk, so this pass never re-pulls it.
Guard the scale first. The device merge reconciles the whole audience, so it carries a ceiling the contact export doesn't. If total exceeds --maid-merge-cap (python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/writers/<platform>.py" --maid-merge-cap, the smallest across the maid-using platforms), halt and surface it — the contact files from step 3 already reached everyone, so deliver those and bring the device pass back at a smaller scale. Never merge past the cap.
Pull the mobile-ad-ID lane once, in the same bounded concurrent waves. The windows are smaller — python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/writers/<platform>.py" --maid-cap (the smallest across the maid-using platforms; today 100,000). The first window mints the snapshot: offset: 0, no workflow_id (let the server issue one), fired alone; then the rest against that returned workflow_id, each at its advancing offset, through the confirmed ceiling — in the same waves of at most --export-fanout, fired concurrently as in step 3. The call shape matches step 3 but with domains: ["maid"]: entity_find with the same expression_string, or entity_enrich the same entity_ids_uri (so the lane covers the same people). Download each window to scratch_dir with the same fresh-URL download driver as step 3 (_common.py --download, re-run until done, re-mint on expired), keeping the pulse on the device pass's progress — "Adding device IDs — about halfway" — never the windows underneath (How you speak while it runs).
Spill, merge once, then join per platform — the deterministic sort-join is _common.py's phased plumbing, driven one bounded call at a time into a device run-dir <R> under scratch_dir (spill/merge are shared plumbing, identical across writers — use any maid-using platform's script):
- Spill only the maid windows —
python3 "${CLAUDE_PLUGIN_ROOT}/skills/audience-activate/scripts/writers/<platform>.py" --phase spill --input <window> --run-dir <R> --lane maid per window. The contact lane was already spilled per batch during step 3; only if that left no contact runs waiting (a device pass driven in isolation) spill contact_file there too with --lane contact.
- Merge each lane to one run —
python3 "…/<platform>.py" --phase merge --run-dir <R> --lane <lane>, repeated until it reports 1 run(s) remaining.
- Join and transform, per platform — bounded, resumable, driven exactly like the contact transform —
python3 "…/<platform>.py" --phase join --run-dir <R> --out-dir <dest_dir> --checkpoint <scratch_dir>/<platform>.join.json. The phase owns the slice boundary: it self-caps inside the per-call ceiling, checkpoints its cursor and each output file's committed length, and prints a JSON line ending …, "done": <bool>. Re-run the identical command until done is true — each call resumes at the checkpoint and truncates any partial a stopped call left, so a re-run never doubles or drops a row. One call per round; never wrap the re-run loop inside a single shell call (a loop of self-capped stints re-crosses the 45s ceiling — the join's failure mode is the transform's, exactly). Read total / written / maid_matched off the final (done) call — never parse the JSON line with grep/sed; read the fields with python3 -c. The first call writes the header and every later one appends, overwriting that platform's contact-only file(s) in place at the same paths (never a new file — dest_dir may forbid rm). The join reads the merged runs without consuming them, so every platform joins the same <R>. Run the platforms concurrently — background one --phase join per platform in a single shell call (… & … & wait), each under its own checkpoint, then re-issue that call each round for whichever still report done:false.
Then reclaim <R> and the maid windows from scratch_dir; never rm from dest_dir.
For a one-file-per-group export, loop this per group — one device pass per group, over that group's held contact_file / dest_dir / scratch_dir / group_label, mirroring the step-3 per-group loop.
Deliver the updated file(s) the same way step 4 does — every platform's files with their counts, the reconciliation, the expected audience size on each platform's now-larger reachable set, the verification line, and maid_matched per platform (how many exported people carried a device ID). A device-lane failure that persists is surfaced, never worked around (see Failure modes).
Expected audience size: the posture we hold
When the file is uploaded, the platform reaches only the share of the people in it that it already has on its own side. It's always below the count we handed over, and what drives it sits largely outside Watt: the platform's own identity graph, the customer's ad account, how fresh the identifiers are, the channel. Watt's language for that reached share is the expected audience size — a real-people range, always. (The platform's dashboard calls the same thing a match rate and shows it as a percentage; if the user arrives with that word, answer in audience-size terms — recognize it, don't adopt it.) Hold one honest posture, every run:
- What it is, and isn't. The reached share is the platform's measure — who that platform already has on its side — not a measure of Watt's data quality or this audience's accuracy. Two platforms will reach different amounts of the identical file; that's about them, not the people.
- Set it in real people, never a percentage. We don't quote a percentage — a number we don't control, and one that anchors expectations on the platform's side. We show the expected audience size for this export's size (
scripts/audience_size_range.py, the single source of the band), so the read is "of these N people, your expected audience size is X–Y" — concrete, and clearly an estimate.
- A non-reached row is not a wasted record. The person is real and correctly in the audience; the platform simply doesn't have them on its side. An unreached row is the platform's coverage gap, not a wrong or junk record.
- The levers a customer actually has. More identifier types per person (an email and a phone and a mobile ID reach more often than any one alone), fresher identifiers, and meeting the platform's list-size minimum. Hashing format is not a customer lever — the writer script produces exactly the digest each platform matches on, so a "wrong hash" can't cost reach here.
- The line we will not cross. Never guarantee an audience size or a reached count. Never imply Watt's counts equal deliverable reach on a platform — the exported count is real people; how many it reaches is the platform's to report. Promise the audience is real and correctly built; never promise how much of it a given platform will reach.
How to behave
- The gate is the product. Every fact in the confirmation — the platforms, count, identifiers, what each spec does to them, who gets dropped, whether device IDs are included — is there because the user is authorizing a real export at real scale. Never soften, batch, or skip it; never treat an earlier session's yes as this run's yes.
- Counts, paths, conclusions in chat; rows in the file. The deliverable carries the data — quoting records into the transcript adds nothing.
- Name every gap before and after. Unmatchable persons skipped, or a slice smaller than full reach when the user chose one — said before the run, reconciled after it. A silent shortfall shipped as complete is this surface's worst failure.
- The script is the spec. Hashing and layout live in the platform's writer script — you never re-implement them, hand-compute a digest, or patch the layout, and you don't walk the hashing internals into chat (the gate already states each platform's transform in plain terms). Pulse the progress — the counts moving, the files taking shape — in the user's terms; never the batch/offset/checkpoint machinery, the digest math, or a raw row (How you speak while it runs). A transform change is a plugin change, not a runtime favor.
- The pull runs here, in bounded concurrent waves — never detached. The materialization — the
entity_find/entity_enrich, the downloads, the writer scripts — runs on this thread, pulsed on real progress as it moves (How you speak while it runs). Fire each wave's calls concurrently (up to the fan-out bound) so they overlap; never fire one per turn and wait (the serial crawl), and never kick off a background or detached job you return from to poll — it hides the run and drops its tail. The reliability discipline — bounded waves, fresh download URLs, byte-range downloads, entity-cursor reconciliation, timeout back-off — is flow step 3's, held right here, and runs invisibly.
- Pulse the pull on real progress — a calm line as coverage climbs and the files take shape; counts and platform names, never batches, offsets, retries, row contents, or a structured payload (How you speak while it runs).
Refuse cleanly
- "Skip the hashing / give me the raw emails." The Meta file is hashed because that's the form Meta requires — a raw file would be rejected. The source URL from the run carries the unhashed values for an hour; point there.
- A platform with no writer script. "Meta, Google, Reddit, and TikTok are the supported platforms." Don't improvise a CSV "close enough" for another platform — its layout and hashing rules differ, and a wrong file shrinks the audience the platform can reach.
- "Guarantee me an X% match" / "promise these will all match." No — how much a platform reaches is the platform's to report, driven by factors outside Watt (see expected audience size). Recognize the "match" framing, answer in audience-size terms: give the honest expected audience size and the real levers; never a guaranteed rate or count, and never imply our counts equal what the platform will reach.
- An export with no built audience. Generate first — there's nothing confirmed to pull.
Failure modes
- An export batch fails mid-run. Halt and surface it; no file comes from a partial pull. The contact lane is pulled in batches by design — what you never do is quietly shrink it below the confirmed scale, or page it past that scale, to dodge a failure. An expired download URL isn't one of these failures — the download driver reports
"expired": true when a presigned URL lapses mid-pull, and you re-mint it with generate_download_url and re-run from the committed byte (the workflow_id pins the same artifact), so a long export that outlives a presigned URL resumes in place rather than halting. A genuine one-off blip re-runs with the same workflow_id. A contact-lane failure that recurs with no smaller limit offered is the user's call, not an auto-retry: offer the same run again or a re-confirmed smaller scale (a smaller pull completes more reliably), never a number you lowered silently. The heavy mobile-ad-ID domain is already pulled apart into its own small-window lane for exactly this reason — its windows are the design, not a crawl — so a device-lane failure that persists even there is a server-side limit to surface, not a shape to shrink further here.
- A Signal Graph call fails on connection or authentication — including a batch call reporting the connector isn't authenticated. This is the connector itself, not the egress block below. The preflight probe (step 1) is your liveness gate; read its shape — and any later auth failure's — 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; the preflight probe settles it — never announce one.)
- Network egress blocks the download (Cowork, a Team or Enterprise org). In Cowork the transform runs in the code-execution sandbox, which enforces the org's network-egress policy; when that policy blocks the Watt artifact storage, you can't pull the file and the run halts. Don't retry or improvise a local file — the artifact URL is presigned and rotates, so allowlisting one domain won't hold; egress has to be opened fully, an org-admin setting and the only fix. Hand the user the path and a line to forward to their admin, then have them re-run here once it's open:
- The admin opens egress in Organization settings → Capabilities → Code execution → Allow network egress (https://claude.ai/admin-settings/capabilities): set the Domain allowlist to All domains, then save. A line to forward: "In our Claude organization settings, set Code execution → Allow network egress → Domain allowlist to All domains."
- It takes effect on a new session. After saving, the user closes and reopens Cowork — the policy applies only to a fresh session — then re-runs. If it still fails, fully log out of Claude and back in, then re-run.
- The export won't fit on disk. Before pulling, you preflight both disks (flow step 3) — the fast scratch you stage intermediates on, and the delivery destination the finished files land on — against the run's estimated size (a large all-platform export is the dominant way an export runs out of room). If either is short, halt before the pull and report which disk and by how much. Bring that back as a fresh confirmation: the same run needs more room, or a smaller scale, fewer platforms, or contact-only (device IDs add a second lane) will fit. Never proceed into a disk the preflight says won't hold the run — a mid-run disk-full can strand undeletable partial files on a delivery mount. It's the same re-confirm-smaller shape as a rejected batch, just caught up front.
- A batch is rejected as too large. The batch window is already sized to sit well inside the export cost envelope, so this should be rare — but if it happens, bring the server's suggested smaller
audience_limit back to the user as a fresh confirmation. Never lower it silently, never shrink the batch window below what the script sets, and never change the expression to chase a smaller count.
- A batch times out mid-stream. A client timeout doesn't stop the server — the export job runs to its own completion deadline, and the
workflow_id + offset pins one deterministic artifact — so treat it as still running, not failed. Back off: narrow to one export in flight, wait for the queue to drain, then re-issue that same pinned batch alone, a bounded few times — rather than re-firing the identical window and piling a duplicate job onto the same worker slots. If a batch still times out once the lane is serial, that surfaces as a halt for the user, never an endless retry.
- A transform or join call is stopped at the shell ceiling. The contact transform (
--phase transform) and the device-ID join (--phase join) run on the identical contract: each processes one bounded person-window/slice at a time, self-caps inside the per-call ceiling, and checkpoints its cursor and each output file's committed length — so even if a call is killed anyway, the fix is to re-run the identical command: it truncates any partial the kill left back to the last committed point, then resumes at the checkpoint's cursor, so no row is doubled or dropped. Transform feeds the whole concatenated contact_file as one --input; join reads the merged runs in --run-dir <R>; both take a --checkpoint <file> and own their own slicing — the window, not the file, is the unit, so a large audience self-slices and you never hand-split it or size the chunk yourself. Drive each across separate shell calls — re-issue the command each round until done is true; never wrap the re-run loop, or several platforms' full runs, inside one shell call, which re-crosses the 45s ceiling the self-cap exists to respect (background one self-capped round per platform with … & … & wait, then re-issue the not-yet-done ones as a fresh call). Don't repair a half-written file by hand, don't loop a bare --input … --append, and don't parse the JSON status line with grep/sed (read done / next_start / counts with python3 -c) — the checkpoint owns idempotency, and a hand-rolled loop or parser is exactly what corrupts the run. A process … already running right after a kill is a transient teardown lock on the workspace, not a failure — it clears in a few seconds; wait, then re-issue.
- The device-ID pass exceeds the merge ceiling. The mobile-ad-ID pass reconciles the whole audience in one merge, so it carries a ceiling the contact export doesn't. When the audience is over that ceiling, halt the device pass and surface it: the contact files from the first pass still reach everyone, so deliver those, and bring the device-ID pass back as a fresh confirmation at a smaller scale. Never merge past the ceiling, never ship a partial device-ID file.
- The script errors. Surface its message and stop; the transform is never hand-finished.
- The confirmation gate must be answered. However it lands, nothing runs until the user answers it.