---
name: create-campaign-v2-tail
description: Tail steps (13-16) + MANDATORY TOOL ORDER + Threshold Trips + Tail Hard Rules for create-campaign-v2. Loaded on-demand via get_subskill_prompt before any auto-execute or validate-sample step. Internal subskill.
visibility: internal
---

# create-campaign-v2 Tail (Steps 13-16 + Tool Ordering + Hard Rules)

This is the tail detail extracted from the main create-campaign-v2 SKILL.md to keep the entry prompt slim. The agent loads this on-demand BEFORE entering the auto-execute or validate-sample steps. Follow this verbatim.

CampaignOffer state and the watch link are canonical. Disk artifacts are
optional debug/UAT diagnostics; normal customer runs should not expose local
draft files. Resume, gating, and handoff read campaign state first.
selectedLeadListId remains the source list; workflowTableId is the campaign
table.

## MANDATORY TOOL ORDER (read this BEFORE any tail step)

Every tail run MUST call these tools in this exact order. The tail is
**initial-slice cascade-driven**: you kick off Enrich Prospect only for
the initial campaign-table execution slice, and the workflow engine chains DNC Check →
ICP Score → Passes Rubric → Generate Message automatically. Your job is
to START the bounded cascade, WAIT until filter results land, OBSERVE message
generation as soon as one row passes, and stop for review.
Do NOT manually run rubric-check, enrich, or message-generation
tools — the cascade already does them.

```text
Step 13 — Start Import + confirm initial campaign slice
  materialize/reuse the approved source with campaignOfferId
  import_leads(source-list target; SalesNav/Prospeo default ~1000, Signal Discovery default ~1500 engagers)
  wait_for_lead_list_ready
  confirm_lead_list(reviewBatchLimit=15)  # compact reviewBatch metadata is stored on the campaign table
  wait_for_campaign_table_ready    # campaign table exists
  update_campaign(currentStep=filter-choice)

Post-import main thread
  launch Message Drafting after the filter-choice answer
  keep filter/rubric work inline in the parent thread when the user chooses filters
  save_rubrics({ campaignOfferId, leadScoringRubrics }) after the campaign table exists
  keep the watched app on Filter Leads after rubrics are saved
  while on Filter Leads, show the message template recommendation from background Message Drafting
  after approve-message, update_campaign_brief writes `## Approved Message Template` with `{{...}}` tokens
  only then start the initial-slice cascade

Step 14 — kick initial-slice cascade + observe campaign rows
  queue_campaign_cells(columnRole=enrich, rowSelector=reviewBatch)  <-- starts bounded chain
  wait_for_campaign_processing(minPassedCount=1)
  if at least one row passes: update_campaign(currentStep=auto-execute-messaging)
  compute projectedPass for later reporting / revision decisions
  if zero rows pass: diagnose brief-vs-list; if brief: update_campaign_brief + re-queue + wait with changed args
  (check_rubric / bulk_enrich_with_prospeo are NOT called here —
   cascade already did them.)

Step 15 — observe messaging
  queue_campaign_cells(columnRole=generateMessage, rowSelector=needsGeneratedMessage)
  wait_for_campaign_processing({ minGeneratedMessages: 1, templateRevision: "current" })
  token-contract spot check via get_rows
  update_campaign(currentStep=auto-execute-messaging) with review-ready narration
  ask the user to approve the generated message before Settings
  only after approval: update_campaign(currentStep=awaiting-user-greenlight)
  (generate_messages is NOT an MCP tool; messages come from the cascade)

Step 16 — awaiting-user-greenlight
  get_campaign + list_senders
  update_campaign(currentStep=settings) + describe the visible Settings state
  if no connected sender exists: explain sender setup + Slack reply review, surface campaign settings link, and STOP
  ask the user which connected sender to attach; in explicit UAT safe mode only, use the safe mock sender
  update_campaign(senderIds=[selectedSenderId], currentStep=sequence)
  attach_recommended_sequence({ campaignId, currentStep: "send" })  # tier-aware: premium/SN -> If Open Profile->INMAIL_OPEN, else INVITE->accepted->DM
  if the attach response did not move the UI: update_campaign(currentStep=send)
  surface campaign setup orientation + final launch choices without repeating the watch URL
  STOP. DO NOT call start_campaign. DO NOT move to running without explicit launch greenlight.
```

**Prefer `attach_recommended_sequence` over `attach_sequence` in the
autonomous tail.** The recommended tool takes only `campaignId` and
delegates template selection to the backend's tier-aware selector
(`selectTemplateForTiers`). `attach_sequence` requires you to
hand-author a `version: 2` template with nodes, branches, and
entryNodeId — that's error-prone mid-long-context (galley-off UAT
`20260420T195732Z` failed because Claude hit "Invalid node type" and
tried to debug via forbidden Bash/Glob calls). Use `attach_sequence`
only when the caller explicitly needs a custom non-recommended cadence.

Hard gates — if you find yourself about to violate any of these, stop
first:

- Do NOT use `update_cell` to write message bodies. The `Generate
Message` column's http_request writes those cells via the cascade.
  `update_cell` remains reachable for legitimate operator overrides
  AFTER the tail hands off. For approval writes, resolve the actual visible
  `Approved` column cells with `select_campaign_cells({ columnRole:
  "approved", rowSelector: { type: "rowIds", rowIds } })`; do not trust
  `approveCellId` from `get_rows`, which may refer to a row-level scheduling
  helper instead of the visible table checkbox.
- Do NOT call `check_rubric`, `enrich_with_prospeo`, or
  `bulk_enrich_with_prospeo` in the tail. Those are direct-API
  mutation tools that fetch data to the caller without writing to
  the workflow table cells. Step 14's `queue_campaign_cells` call
  triggers the column-level enrichment that populates those cells.
  Running these tools in the tail produces duplicate cost with no
  cell side effect.
- `wait_for_campaign_processing` is the stats-only observation helper for the
  tail. In Step 14 pass `minPassedCount: 1`; in Step 15 pass
  `minGeneratedMessages: 1` and `templateRevision: "current"`. Do not repoll
  identical args after a timeout.
- `start_campaign` is FORBIDDEN in the autonomous tail. It belongs only
  in the Claude-greenlight path, AFTER the user signals "start". See
  `references/final-handoff-contract.md`.
- You MAY NOT call `start_campaign` without a prior successful
  `attach_recommended_sequence` (or `attach_sequence` if a custom
  cadence is explicitly required) in the same run.
- You MAY NOT call `start_campaign` while ANY passing row has an
  empty `Generate Message` cell. If you discover empty message cells in
  the greenlight turn, ABORT greenlight and return to Step 15 first.
- You MAY NOT call `attach_sequence` while ANY reviewed passing row has an
  empty or stale `Generate Message` cell. Before `attach_sequence`, call
  `select_campaign_cells({ tableId, columnRole: "generateMessage",
  rowSelector: { type: "needsGeneratedMessage" } })`. If any cells are
  selected, call `queue_campaign_cells` with the same selector and wait with
  `wait_for_campaign_processing({ minGeneratedMessages: 1,
  templateRevision: "current" })`. If rows truly won't message, ESCALATE.
- You MAY NOT advance past Step 14 without calling `queue_campaign_cells` for
  `{ columnRole: "enrich", rowSelector: { type: "reviewBatch" } }`. Without it,
  every downstream cell stays `pending` and the campaign ships empty.
- You MAY NOT queue enrichment for rows outside the configured internal
  execution slice before the user approves expansion. Full-list enrichment/message
  generation is a credit-spend decision and must happen after the user
  has reviewed the sample.

Load `core/auto-execute.yaml` exactly once at the start of Step 13 through
`get_subskill_asset({ subskillName: "create-campaign-v2", assetPath: "core/auto-execute.yaml" })`.
All subsequent steps read the already-parsed config. Do not re-load mid-run,
and do not read repo-local config files; packaged Claude Code and Codex runs
must use the MCP asset loader.
Load each subskill prompt (`create-campaign-v2`, `research-sender`,
`generate-messages`) at most once per run. If a tool result already told
you to load a prompt, remember it; do not restart the same prompt later.

After every `update_campaign({ currentStep: ... })` in the tail, narrate
what changed and orient the user to what the already-open app will show next —
reuse the v1 `create-campaign` watch-mode pattern verbatim.

Resume currentStep names covered by this tail: `"auto-execute-leads"`,
`"apply-icp-rubric"` (the visible Filter Leads home for the logical
`validate-sample` loop), `"validate-sample"` (legacy resumes only),
`"auto-execute-messaging"`,
`"awaiting-user-greenlight"`, `"settings"`, `"sequence"`, `"send"`, and
`"running"`. New mint-early runs normally
enter Step 13 from `"confirm-lead-list"`; the `"auto-execute-leads"` string is
kept for compatibility resumes.

## Step 13: auto-execute-leads

Entered from the lead-source confirmation path, with
`CampaignOffer.currentStep === "confirm-lead-list"` or an equivalent source
import milestone.

> Reminder: every provider search you run from this point forward — the
> campaign setup source rerun in Step 13, any expansion search after the
> initial slice, alternate-lane probes, account-based reruns, and operator
> follow-ups — MUST include `campaignOfferId`. This applies to
> `search_prospeo`, `search_sales_nav`, `search_apollo`,
> `search_signals`, and any other provider search tool added later.
> Campaignless searches after mint orphan results from the campaign UI.
>
> **Lead-list vs campaign-table identity rule (do not confuse them):**
> after the first `confirm_lead_list`, two workflow tables exist:
>
> - the **lead-list** table (the original `sourceLeadListId` returned by
>   `import_leads`), and
> - the **campaign workflow table** (`CampaignOffer.workflowTableId`,
>   also returned as `campaignTableId` from `confirm_lead_list`).
>
> `confirm_lead_list` must preserve the source lead-list id as
> `CampaignOffer.selectedLeadListId` and store the campaign workflow table as
> `CampaignOffer.workflowTableId`. The campaign-table id is NOT a valid
> `sourceLeadListId` for future `import_leads(mode: "add")` calls.
>
> Capture the original `leadListId` from the FIRST `import_leads` response and
> reuse it for every scale-up `import_leads(mode: "add")` call on the same
> campaign. If the original id is unknown, query for the lead-list table by
> name or skip the `mode: "add"` path; do not pass the campaign-table id as
> `sourceLeadListId`.

1. Load `core/auto-execute.yaml` through
   `get_subskill_asset({ subskillName: "create-campaign-v2", assetPath: "core/auto-execute.yaml" })`.
   Capture `import.importLimit`, `sample.sampleSize`, `sample.minProjectedPass`,
   `sample.maxRevisionRounds`, `messaging.tokenContract`,
   `messaging.critique.enabled`, `handoff.autoStart`,
   `handoff.orientation`, `retry.sameToolSameError`,
   `logging.logEveryThresholdTrip`.
2. Resolve and materialize the approved source after Start Import approval. Load
   `references/post-mint-source-materialization.md` when the source is normal
   discovery or when a legacy no-shell approval fixture must be replayed. If
   CampaignOffer state already attached searches/selections to this campaign,
   reuse them. If there is no `lead-source-intake.json`, replay the approved
   provider recipe from `lead-review.md` with `campaignOfferId` before
   `import_leads`.
   If a manifest exists, branch by `sourceType`:
   - `supplied-linkedin-profiles`: revalidate file metadata and confirmation
     token, confirm `load_csv_linkedin_leads` as source-list materialization to batch the
     supplied CSV into a Sellable lead list, persist the returned `leadListId`,
     then call `confirm_lead_list({ sourceLeadListId: leadListId,
reviewBatchLimit: 15 })`. Do not call `import_leads` for this
     branch; the lead list is the source, and `confirm_lead_list` copies the
     confirmed source rows into the campaign table.
   - `existing-lead-list`: revalidate that the lead list still exists in the
     same workspace, reuse it as `sourceLeadListId`, then call
     `confirm_lead_list({ sourceLeadListId, reviewBatchLimit: 15 })`.
   - `supplied-domains`: reuse or confirm `load_csv_domains`, preserving
     `domainFilterId`; run a campaign-associated Prospeo people search with
     `campaignOfferId`, provider prompt preflight, and that `domainFilterId`;
     then import with `targetLeadCount` set to the approved source-list target
     (default about 1,000). The first copied campaign rows are the internal
     execution slice; the full confirmed source list is copied into the campaign
     for later expansion.
     Persist or recover materialized IDs on resume: `leadListId`,
     `domainFilterId`, `searchId`, `selectedLeadListId`, `workflowTableId`, and
     imported row IDs. If the source file changed after preview, or an existing lead list
     is missing/wrong-workspace, escalate before materialization instead of
     silently applying stale preview data. Retrying Step 13 must not duplicate
     lead lists, searches, or campaign rows.
     Normal discovery provider order:
   - Signal Discovery: `get_provider_prompt({ provider: "signal-discovery",
campaignOfferId, confirmed: true })` -> `search_signals({ campaignOfferId,
...approved recipe })` -> `select_promising_posts({ campaignOfferId,
selectionMode: "replace", scrapePlanMode: "capacity-target", selections,
headlineICPCriteria })` ->
     `import_leads({ campaignOfferId, provider: "signal-discovery",
	targetEngagerCount: 1500 })`.
     For LinkedIn engagement (`signal-discovery` internally), the promotion/select step is load-bearing. Use
     post IDs from the current campaign-scoped `search_signals` response or
     posts the user has visibly promoted in the campaign UI. Never use post IDs
     copied only from a source-scout summary unless they have been re-resolved
     through the current campaign search state. After `select_promising_posts`,
     require `selectedCount > 0` before calling `import_leads`. If it returns
     `selectedCount: 0`, do not switch providers and do not retry import.
     Explain that the campaign has no promoted LinkedIn posts yet,
     re-run a narrow campaign-scoped `search_signals` call to recover current
     post rows, or ask the user to promote the desired posts in the UI and then
     retry `import_leads`.
     Start Import approval is the explicit confirmation for this selected-post
     scrape; do not ask for a second yes/no gate between
     `select_promising_posts` and `import_leads`.
   - Sales Nav: `get_provider_prompt({ provider: "sales-nav", campaignOfferId,
confirmed: true })` -> rebuild/verify filter IDs -> `search_sales_nav({
campaignOfferId, filters, confirmed: true })` -> `import_leads({
campaignOfferId, provider: "sales-nav", searchId, targetLeadCount:
1000 })`.
   - Prospeo: `get_provider_prompt({ provider: "prospeo", campaignOfferId,
confirmed: true })` -> reuse `domainFilterId` when present ->
     `search_prospeo({ campaignOfferId, filters, domainFilterId, confirmed:
true })` -> `import_leads({ campaignOfferId, provider: "prospeo",
searchId, targetLeadCount: 1000 })`.
3. `wait_for_lead_list_ready` when a provider import job exists, then
   `confirm_lead_list`. Persist both identifiers: `selectedLeadListId` remains
   the source list and `workflowTableId` is the campaign table.
4. `wait_for_campaign_table_ready` until the initial campaign-table execution slice rows are
   available in the campaign table.
5. Confirm the compact `reviewBatch` returned by `confirm_lead_list` has the
   expected 15-row internal execution slice metadata. Do not fetch row payloads
   or queue cells in Step 13.
6. If the import returns zero usable leads, ESCALATE per
   `references/escalation-ladder.md` (hard fail).
7. `update_campaign({ campaignId, currentStep: "filter-choice" })`.
8. Describe the filter-choice screen now visible in the already-open app. Do not
   repeat the watch URL.

**Do NOT call `check_rubric`, `wait_for_campaign_processing`,
`queue_campaign_cells`, `enrich_with_prospeo`, or `bulk_enrich_with_prospeo` in Step 13.**
Those are direct-API tools that fetch enrichment/scoring data to the
caller or start the workflow-table cascade too early. The cascade starts in
Step 14 only after `save_rubrics` and `update_campaign_brief` have both
succeeded.

## Step 14: validate-sample (logical loop on Filter Leads)

Entered after rubrics are saved while the watched campaign is already on
`CampaignOffer.currentStep === "apply-icp-rubric"` / Filter Leads.
Do not route to a visible `validate-sample` step. Full decision tree lives in
`references/sample-validation-loop.md`.

**Step 14 starts the bounded fit cascade, then observes it.** Step 13 imported
the internal execution slice only. After `save_rubrics`, Step 14 queues the initial-slice
Enrich Prospect cells, waits until filter results start landing, then moves to
message observation as soon as one row passes and approved-template message
generation is ready. Step 15 opens review as soon as one passing generated message
exists. Do not wait for a larger or stronger sample once that first passing
message is ready. It does NOT call `check_rubric`,
`bulk_enrich_with_prospeo`, or any other direct enrichment/scoring tool.

Shape:

```text
queue_campaign_cells({ tableId: workflowTableId, columnRole: "enrich", rowSelector: { type: "reviewBatch" } })
wait_for_campaign_processing({ tableId: workflowTableId, minPassedCount: 1 })
passInSample = count of first sampleSize review/process sample rows with passesRubric === true
projectedPass = round(passInSample / sampleSize * importLimit)

if wait_for_campaign_processing.ready === true and passRate.passed >= 1:
    advance to Step 15 to observe or queue Generate Message for currently passing rows
    do not wait for every sample row to finish before message generation starts
    stop for review as soon as one passing generated message is ready
else if wait_for_campaign_processing.ready === false and reason === "timeout":
    use the partial passRate/stats as the sample diagnostic
    if passRate.passed >= 1:
        advance to Step 15 to observe/queue Generate Message for passing rows
    else if active processing is visible:
        wait one more time at most
    else:
        stop before Settings with Status: sample-needs-revision
        show completed, passed, pending, pass percent, and message count when available
        ask whether to revise source, revise filter/rubric, or wait once only if still processing
else:
    diagnose brief-vs-list per sample-validation-loop.md
    - brief: autonomous update_campaign_brief, then re-kick cascade with
             queue_campaign_cells({ columnRole: "enrich", rowSelector:
             { type: "reviewBatch" }, forceRerun: true }) and wait for the
             new results
    - list:  ESCALATE (no auto-revision of leads)
    - unknown: ESCALATE
    revisionRound += 1
    if revisionRound > maxRevisionRounds:
        ESCALATE
```

Persisted counter rule: `revisionRound` persists across skill resume.
Do NOT reset to 0 on resume — a stale resume must still respect the
`maxRevisionRounds` cap.

A "list problem" diagnosis NEVER triggers autonomous
`update_campaign_brief`. Escalate instead.

A timeout or underfloor partial sample NEVER advances to Settings,
awaiting-user-greenlight, or start-campaign. It must hand off as
`sample_revision_required` with the observed counts so the user can decide
whether the source or filter should change.

If the same source lane has already failed after a revision, do not present
another same-source revision as the recommended/default next action. Recommend
the strongest fallback lane first (for example, Sales Nav after repeated
Signals underfloor results), then offer revise-filter only if the imported rows
look close to ICP but the rubric is too strict. This prevents customer-visible
loops where the agent keeps asking to retry the same weak source.

When the internal slice passes the projected-pass floor, call
`update_campaign({ campaignId, currentStep: "auto-execute-messaging" })`
and orient the user that messaging will complete for the initial campaign rows only.

## Step 15: auto-execute-messaging

Entered on `CampaignOffer.currentStep === "auto-execute-messaging"`.

**Messages are produced by the `Generate Message` column cascade, not
by a separate tool.** Do NOT call a `generate_messages` MCP tool —
that tool does not exist; the full `generate-messages` subskill prompt is the
Message Drafting contract for the approved template. In the autonomous tail, messages
flow through the column pipeline: `Enrich Prospect` →
`DNC Check` → `ICP Score` → `Passes Rubric` → `Generate Message`.
Each column's http_request auto-fires when its upstream dependency
completes AND passes gate (e.g. `Passes Rubric === true` before
`Generate Message`). Your job in Step 15 is to WAIT for the initial-slice
cascade to reach `Generate Message` for rows that passed ICP, and verify
the output — not to generate messages manually.

**What Step 15 does:**

1. Before queueing or waiting on Generate Message cells, confirm the minted
   campaign brief still contains `{{...}}` in `## Approved Message Template`.
   If it does not, fail before the cascade runs. Do not repair after mint; the
   template must be present during mint so Step 15 never starts in freeform
   generation mode.
2. Use `select_campaign_cells({ tableId, columnRole: "generateMessage",
   rowSelector: { type: "needsGeneratedMessage" } })` only if you need a dry-run
   count. If any passing row has a pending, empty, or stale Generate Message
   cell, queue it explicitly with `queue_campaign_cells({ tableId, columnRole:
   "generateMessage", rowSelector: { type: "needsGeneratedMessage" } })`.
3. `wait_for_campaign_processing({ tableId, minGeneratedMessages: 1,
templateRevision: "current" })` until the first current-revision generated
   message is ready. Do not wait for every passing row's
   Generate Message cell, and do not add "one more wait" for a stronger sample.
   Remaining review/process sample rows can continue processing in the background.
4. Read the first ready generated message back via `get_rows` (full) and
   sanity-check that sample against the offline-validation token contract: no unresolved
   `{{tokens}}`, no invented proof, one sentence per line, etc.
5. If the sample fails the token contract, diagnose brief-vs-list
   (same revision loop as Step 14) and escalate if over
   `maxRevisionRounds`.
6. On success, keep `currentStep: "auto-execute-messaging"` and ask the user to
   approve the generated message before continuing to Settings. Only that
   approval may move the campaign to Settings / `awaiting-user-greenlight`.
7. When approval is given — or auto-approved in YOLO when the Recommendation is
   `approve-message` — you MUST actually mark the reviewed passing rows approved.
   Collect the reviewed generated-message row IDs, then resolve the real visible
   `Approved` cells with
   `select_campaign_cells({ tableId, columnRole: "approved", rowSelector: { type:
   "rowIds", rowIds: reviewedRowIds } })`. Call
   `update_cell({ cellId: <returned Approved cell id>, value: true })` for at
   least one generated-message row, preferably two when available. Do not use
   `approveCellId` from `get_rows` unless it matches the semantic selector
   result; it may point at a row-level scheduling helper and leave the visible
   checkbox unchecked. The campaign UI gates "Next: Settings" on
   at least one row whose `Approved` cell is true:
   moving `currentStep` to `settings`/`awaiting-user-greenlight` with zero
   approved rows desyncs the watch UI, which stays stuck on Generate Messages
   showing "Approve at least one draft to continue." Setting the `Approved`
   flag via `update_cell` is explicitly allowed; the "do not use `update_cell`"
   rule below applies ONLY to message bodies, never to the `Approved` flag.
   Advance `currentStep` only after the approve writes succeed and a follow-up
   selector/stats read confirms `approved >= 1`.
   If any Settings/sequence/send/start step call returns
   `error: "approved_message_required"` with
   `requiredAction: "approve_generated_message"`, recover with the same semantic
   `select_campaign_cells({ columnRole: "approved", rowSelector: { type:
   "rowIds", rowIds } })` lookup, update the returned visible `Approved` cell,
   confirm the approved count is at least 1, then retry the blocked action. Do
   not ignore or route around that error with a different currentStep write.

**Do NOT hand-write message bodies via `update_cell`.** `update_cell`
is reachable for legitimate operator overrides AFTER the tail hands
off, but during autonomous tail the cascade owns the writes. If you
find yourself drafting a message body and about to push it through
`update_cell`, you have lost state — stop, read the cells, and let
the pipeline finish.

**What `attach_sequence` does (Step 16, not here):** binds the
DM/InMail cadence template to the workflow table. Completely different
job from messaging. Does NOT generate or write message text. A
sequence with no `Generate Message` outputs would try to send empty
strings, which is why Step 16 requires Step 15 to be complete.

1. Observe the review-sample messages on the same sample that passed
   validation.
2. If `messaging.critique.enabled` is true, run the
   bounded critique pass on the sample output per
   `references/parallel-critique-protocol.md`. The pass runs on at
   most `messaging.critique.sampleSize` rows, sends each row through
   three parallel critics (targeting / copy / voice) that return
   structured JSON, and merges the voices in a synthesis step that
   enforces the offline-validation token contract verbatim and re-runs the
   finalizer pass. Any rewrite that invents proof or uses an
   unsupported token falls back to the plain generated message. A
   trip of `messaging.critique.budgetUsdCap` HALTS critique for the
   remaining sample and continues the plain tail. The plain campaign tail reads
   the flag but never flips it; critique stays off until the flag is
   deliberately enabled.
3. If `messaging.critique.opus.enabled` is also true, route the
   highest-value subset through the Opus refinement path per
   `references/thomas-variant-selection.md`. Opus is capped by
   `messaging.critique.opus.maxMessagesPerPass` and
   `messaging.critique.opus.budgetUsdCap`; tripping either cap keeps
   the remaining rows on the non-Opus synthesizer. Opus is bound by
   the same token contract as every other rewrite path.
4. Check `messaging.tokenContract`. When `strict`, reject any sample
   message that contains unresolved or unsupported tokens (including
   any critique rewrite that tried to introduce one).
5. If the first passing generated message passes the token contract (and
   critique when enabled), stop at the review handoff. Do NOT wait for the
   remaining review/process sample rows and do NOT scale to the full source list
   before explicit user expansion approval.
6. If the sample fails the token contract or critique, diagnose +
   loop the same way Step 14 does (brief-vs-list), subject to the same
   `maxRevisionRounds` cap.
7. On success, keep `currentStep: "auto-execute-messaging"`, show that the
   first passing generated message is ready in Messages, and ask for approval
   before Settings. When the user approves (or YOLO auto-approves on an
   `approve-message` recommendation), first resolve the actual visible
   `Approved` column cells with `select_campaign_cells({ columnRole: "approved",
   rowSelector: { type: "rowIds", rowIds: reviewedRowIds } })`, then write
   `update_cell({ cellId: <returned Approved cell id>, value: true })` for at
   least one reviewed passing row, preferably two when available. Never use
   `get_rows.approveCellId` as the approval source of truth unless it matches
   the selector result. Confirm the table now reports `approved >= 1`, and only
   then run
   `update_campaign({ campaignId, currentStep: "awaiting-user-greenlight" })`.
   Never advance to Settings while the table still reports `approved: 0` — the
   builder UI will refuse to leave Generate Messages and the watch step will
   desync from the headless step.

Critique failure modes NEVER escalate. A critic timeout, a total
timeout, a budget trip, a fake-proof rejection, or an unsupported-
token rejection all fall back to the plain generated message for that
row. Step 15 does not stall on critique.

## Step 16: awaiting-user-greenlight

Entered on `CampaignOffer.currentStep === "awaiting-user-greenlight"` or a
final-handoff UI step (`"settings"`, `"sequence"`, or `"send"`).
Full contract lives in `references/final-handoff-contract.md`.

Shape:

1. Call `get_campaign({ campaignId })` and inspect sender/sequence state.
2. Call `list_senders()` so you can surface connected sender options and each
   sender's LinkedIn profile URL or verified account identity proof if the
   campaign has no attached sender.
3. Call `update_campaign({ campaignId, currentStep: "settings" })`, then use
   `get_campaign_navigation_state` when available to confirm the watched app is
   visibly on Settings.
4. Explain why the sender matters: Sellable needs a connected LinkedIn sender
   for launch. Explain Slack reply review before launch: replies
   and approvals need a place the team will actually monitor, so Slack should be
   connected or intentionally skipped before launch.
5. If no connected sender exists, surface a direct Settings link using the same
   watch mode as the active campaign URL:
   `/campaign-builder/{campaignId}/settings?mode={claude|codex}`. Tell the user
   to connect a sender there, then return here. STOP before sequence attach/start.
6. If connected senders exist but none is attached, auto-select only when
   exactly one connected sender has an exact identity match to the researched
   sender LinkedIn profile URL or verified LinkedIn provider identity. If
   identity proof is missing, multiple senders match, no sender matches exactly,
   or only display name/company appears to match, ask the user which connected
   sender to attach. Attach only after the user chooses or the exact match is
   proven. In explicit UAT safe mode, use only the safe mock sender; never pick
   a real sender.
7. Call `update_campaign({ campaignId, senderIds: [selectedSenderId],
   currentStep: "sequence" })` to attach the sender through the v3 senders
   route and describe the Sequence view.
8. Call `attach_recommended_sequence({ campaignId, currentStep: "send" })`
   (bind the tier-recommended sequence to the campaign). Explain the sequence
   choice plainly: Sales Nav/Recruiter senders get the Sales Nav Open Profile
   strategy, Basic/Premium/mixed senders get the Premium invite-to-DM strategy,
   and Paid InMail Campaign is available only as an explicit paid-InMail
   opt-in because it can spend InMail credits. If the tool response
   does not persist `currentStep: "send"`, call
   `update_campaign({ campaignId, currentStep: "send" })`.
9. Surface the `handoff.orientation` string from `auto-execute.yaml` without
   repeating the watch URL.
10. Ask the final launch greenlight with the structured question function:
    Start campaign, Review campaign first, or Pause here.
11. STOP unless the user explicitly chooses Start campaign. Do NOT call
    `start_campaign` from Step 16 itself; that belongs only to the Claude
    greenlight path. The autonomous tail ends here.
12. Make the credit boundary explicit in the handoff: only the first review
    batch has been enriched/messaged; expanding to more leads requires a
    separate user instruction.

Two equally valid greenlight channels take Step 16 into a running
campaign:

- **UI path** — user clicks "Approve all" and "Start Campaign" in the
  watch-link UI. The skill stays idle. On the next resume, it observes
  the running state and shifts to a "campaign is live" confirmation.
- **Claude greenlight path** — user replies with an affirmative ("yeah
  start" / "looks good, start" / "ship it"). The skill then performs,
  in order: (a) `get_campaign` and verify a sender is attached, (b) if no
  sequence is attached, `attach_recommended_sequence({ campaignId })`, (c)
  approve generated messages through the bounded prepared cohort when the user
  requested an exact send count, or bulk-approve queued messages via the
  EXISTING endpoint `POST /api/v3/workflow-tables/cells/approve-batch` only
  when unbounded approve-all/start intent is explicit, (d)
  `start_campaign({ campaignId })`, which persists
  `currentStep: "running"`, (e) surface a "campaign is live" confirmation
  without repeating the watch URL unless the user asks for it.

A Claude greenlight on an already-running campaign is a no-op
confirmation, NOT a duplicate start. Detect via `get_campaign` before
calling any mutating tool; if the campaign is already running, skip
approve-batch / start_campaign / update_campaign and just confirm.

Workspace/sender mismatch at greenlight time aborts the start with the
same invariant offline validation enforces.

## Threshold Trips and Logging

When `logging.logEveryThresholdTrip === true` (default), log every
threshold trip: `projectedPass` computation, retry cap, revision cap,
hard-fail. These logs drive `auto-execute.yaml` tuning after 3-5 real
runs.

## Tail Hard Rules

- Source-list materialization and initial-slice confirmation happen in Step 13,
  not during atomic mint.
- Step 13 materializes/reuses the approved source with `campaignOfferId` after Start Import approval and before
  import. Do not import a legacy campaignless Signal source until selected posts
  exist on the campaign.
- Full-list expansion happens only after the initial campaign setup proves out.
  The first enrichment/scoring pass must stay capped to the internal
  campaign-table execution slice.
- The tail NEVER calls `start_campaign` on its own.
- The tail NEVER auto-revises leads. Brief revision is autonomous; lead
  revision is always operator-gated.
- `revisionRound` persists across resume — no silent reset.
- The Claude greenlight path uses bounded prepared-cohort approval for exact
  scheduled-send counts. It uses the existing
  `POST /api/v3/workflow-tables/cells/approve-batch` endpoint only for
  explicit unbounded approve-all/start intent.
- Threshold trips are logged for calibration.

</autonomous_tail>

<references_index>

| File                                             | Load when                                                                 |
| ------------------------------------------------ | ------------------------------------------------------------------------- |
| `references/approval-gate-framing.md`            | Approval gate, before showing the approval packet                         |
| `references/watch-link-handoff.md`               | First brief handoff + explicit link recovery only                         |
| `references/post-mint-source-materialization.md` | Step 13, before importing normal-discovery or legacy campaignless sources |
| `references/sample-validation-loop.md`           | Step 14, before enriching + scoring the sample                            |
| `references/escalation-ladder.md`                | Any tail step that needs to decide retry / revise / escalate              |
| `references/final-handoff-contract.md`           | Step 16, and every Claude greenlight turn                                 |
| `references/parallel-critique-protocol.md`       | Step 15 when `messaging.critique.enabled` is true                         |
| `references/thomas-variant-selection.md`         | Step 15 when `messaging.critique.opus.enabled` is true                    |
| `references/sellable-cleanup-rules.md`           | Any critique rewrite, before persisting                                   |
| `core/auto-execute.yaml`                         | Start of Step 13, load once; all tail steps read parsed values            |
| `core/auto-execute.README.md`                    | When tuning `auto-execute.yaml` knobs or reviewing threshold-trip logs    |

Load every file in this table with:

```text
get_subskill_asset({ subskillName: "create-campaign-v2", assetPath: "<file>" })
```

Do not use local filesystem reads for these files in customer runs.

</references_index>
