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.
67
84%
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-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.
/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.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.audience-analyze — offered after delivery if the user hasn't had the read; another run for a different slice stays here.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.
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.
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.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.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.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./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.audience-generate step honestly.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.
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.
/watt:configure to get the connection fixed (it owns the connect path and recovery docs), then have them re-run once it's connected.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:
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.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.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.
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:
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.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.
Render the result:
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.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.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.
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):
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.python3 "…/<platform>.py" --phase merge --run-dir <R> --lane <lane>, repeated until it reports 1 run(s) remaining.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).
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:
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.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."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./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.)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.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.--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.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.