---
name: linbox-campaigns
description: >-
  Runs multi-step LinkedIn outreach campaigns through the Linbox MCP server —
  list/create/update campaigns and their steps (message, scrap_profile,
  like_recent, wait_agent), activate or pause them, enroll personas, inspect
  enrollment state, set per-persona step texts and custom template vars, and
  complete or stop a single enrollment. Use when a request involves an outreach
  campaign, sequence, drip, cadence, or follow-up series, enrolling people into a
  campaign, personalizing a template placeholder like {problem}, handling leads
  waiting on the agent, pausing/resuming one, or when calling
  linbox_list_campaigns, linbox_upsert_campaign, linbox_set_campaign_status,
  linbox_enroll_campaign, linbox_list_campaign_personas, or
  linbox_update_campaign_persona.
contract_version: "3.0"
---

# Linbox Campaigns

Campaigns are server-executed step sequences. The campaign tools only read and
write campaign configuration and enrollments in the Linbox DB — **they never enqueue
LinkedIn work and never return a `task_id`**. A background tick picks up due
enrollments and queues `connection_request` / `send_message` / `scrap_profile` /
`like_post` tasks under the normal limit gate. Set it up, then read state; don't poll.

One step type is different: `wait_agent` pauses the sequence and hands the lead to
**you**. Those leads surface in `linbox_whats_new` (`campaign_waiting`) and are
released with `linbox_update_campaign_persona(complete_step_id=...)`.

Read the root [Linbox skill](../SKILL.md) first for auth, identifiers, and the
response contract. This skill covers only the campaign group.

## When to use

- setting up or editing a multi-step outreach sequence and its message texts
- activating, pausing, or finishing a campaign
- enrolling one or many personas into a campaign
- checking where enrollments stand (waiting on a connection, stopped, needs attention)
- personalizing a specific person's step text or template vars without touching the campaign
- working the leads a `wait_agent` step parked for you
- stopping, skipping, or reactivating one person's enrollment
- following up on campaign replies surfaced by `linbox_whats_new`

For a one-off message or invite, do not create a campaign — use
[Messaging](../messaging/SKILL.md) or [Connections](../connections/SKILL.md).

## Prerequisites

- A Linbox MCP token scoped to one LinkedIn `account_id` (see root skill).
- Targets do **not** have to exist in Linbox first: a valid `profile_url` with no
  persona is stubbed and enrolled as `created`. Enroll never scrapes, so that stub can
  have no name or headline — use `{var|default}` in texts, or put a `scrap_profile`
  step before the first `message` (or call `linbox_scrap_profile` yourself) when the
  texts need real values.
- An active LinkedIn session is required for the tick to actually send. Enrollment
  still succeeds without one, but `meta.reason="account_session_not_active"` means
  nothing will go out until the account is reconnected.

## Tools

| Tool | Purpose |
|---|---|
| `linbox_list_campaigns(status?, limit_campaigns=100)` | List campaigns (`id`, `name`, `status`, `steps_count`, `max_invites_per_day`, `invite_days`) |
| `linbox_upsert_campaign(name, steps?, campaign_id?, status?, max_invites_per_day?, skip_if_conversation_exists?, invite_days?)` | Create or update a campaign; returns full `campaign` with `steps` and `settings` |
| `linbox_set_campaign_status(campaign_id, status)` | Move between `draft` / `active` / `paused` / `done` |
| `linbox_enroll_campaign(campaign_id, profile_urls?, persona_ids?, texts_by_persona?, vars_by_persona?, vars_by_url?)` | Enroll targets; returns `created` / `updated` / `skipped` + `items[]` |
| `linbox_list_campaign_personas(campaign_id, status?, waiting_agent?, step_ids?, limit_personas=50, cursor?)` | Inspect enrollments and their state |
| `linbox_update_campaign_persona(campaign_persona_id, status?, stopped_reason?, texts?, vars?, complete_step_id?, note?)` | The only per-enrollment write: merge `texts` / `vars`, complete a `wait_agent` step, or stop / skip / reactivate |

`limit_campaigns` clamps to 1–500. `linbox_list_campaign_personas` is **cursor
paginated** (`limit_personas` clamp 1–100, default 50): loop while top-level `has_more`
is true, passing `cursor=next_cursor` — those fields sit on the response itself, not
inside a `page` block. `waiting_agent=true` returns only leads parked by a `wait_agent`
step; `step_ids` matches the current pending step id or `waiting_step_id`.

### Finding the people in a campaign

Two different questions, two tools:

- **Who is in it (as people)** — `linbox_search_persona(campaign_id=...)` returns
  persona results (name, profile URL, tags) limited to that campaign's enrollments,
  any status. `campaign_id` alone is a valid filter and ANDs with `tags`, `name`,
  `linkedin_url`, `is_friend`, `post_url`. Unknown campaign → `Error: Campaign not
  found for this account`.
- **Where each enrollment stands** — `linbox_list_campaign_personas(campaign_id)` for
  `status`, `stopped_reason`, `next_step_at`, `texts`, `waiting_connection`.

### Steps

`steps` is an ordered list. Every step needs a unique `id` and a `type`; max 50 steps.
`delay_days` (integer ≥ 0, default 0) is the wait **before that step runs**, counted
from the previous step finishing. On a `message` step for someone who is not connected
yet, the invite goes out without waiting and `delay_days` is counted **from their
accept** — so `delay_days: 1` there means "invite now, message a day after they
accept", never a delayed invite.

```json
[
  {"id": "step-1", "type": "scrap_profile", "full": true, "delay_days": 0},
  {"id": "step-2", "type": "like_recent", "lookback_days": 14, "delay_days": 1},
  {"id": "step-3", "type": "message", "text": "Hi {first_name|there}, ...", "delay_days": 3},
  {"id": "step-4", "type": "wait_agent", "timeout_days": 14, "delay_days": 0}
]
```

| `type` | Does | Own fields |
|---|---|---|
| `message` | Sends the step text; invites first if not connected, then waits `delay_days` after the accept | `text` required, max 8000 chars |
| `scrap_profile` | Queues profile enrichment, waits for the task | `full`, `include_*` flags, `activities_since_days` (1–365), `activities_limit` (1–200) |
| `like_recent` | Likes the person's most recent post; skips when there is none | `lookback_days` (1–365, default 14) |
| `wait_agent` | Pauses the enrollment for you to act, then resumes | `timeout_days` (≥ 1, default 14) |

- `text` placeholders are `{var}` or `{var|default}` — see Template variables below.
- `steps` is required when creating; omit it on update to keep the existing steps.
- `max_invites_per_day` is the campaign's **daily live-start cap** (default 10, min 1):
  new invites and new conversation probes (`skip_if_conversation_exists`) share one
  quota of *people started* per campaign day. Enrolling 300 people does not start 300 —
  only `max_invites_per_day` of them start today, the rest stay `active` with
  `next_step_at` on a later campaign day. Someone already started today keeps moving
  (probe then invite on the same day is one start).
- `invite_days` is the campaign's **working days** in the account timezone (weekday
  names, default `["mon","tue","wed","thu","fri"]`). It is intersected with the account's
  own work days, so adding a day here has no effect unless the account also works that
  day. Together with the account's work hours it is the only window in which the tick
  does campaign LinkedIn work — messages, invites, `scrap_profile`, `like_recent`, the
  conversation probe, `wait_agent`. See the working window rule below.
- `skip_if_conversation_exists=true` makes enrollment skip anyone you already have a
  LinkedIn conversation with (`skipped` / `has_existing_conversation`). All settings
  round-trip in `campaign.settings`; omit a setting on update to keep its value.
- The step id `__conversation_probe__` is reserved.

### Template variables

**Any** `{var}` / `{var|default}` name works in a step text — you are not limited to a
fixed list. Values resolve from two sources:

- **persona built-ins** — `first_name`, `last_name`, `headline`, `company`, `position`,
  filled from the scraped profile.
- **enrollment `vars`** — anything you set per person; they also override built-ins.

So `"Hi {first_name|there}, saw you're dealing with {problem}"` works as long as each
enrollment carries a `problem` var. Set them:

- at enroll time — `vars_by_persona` (key = `persona_id`) or `vars_by_url` (key = the
  LinkedIn URL, matched against both the URL you passed and the resolved profile URL),
  so you can enroll straight from URLs and personalize in the same call;
- later — `linbox_update_campaign_persona(campaign_persona_id, vars={...})`, which
  **merges**: omitted keys keep their value.

Var keys match `^[A-Za-z_][A-Za-z0-9_]*$`, max 50 keys, values max 2000 chars; an empty
value counts as no value.

If the tick hits a placeholder with no value and no default, it **does not send a blank
message** — the enrollment goes to `needs_attention` with
`stopped_reason="missing_vars"` and `missing_vars=["problem"]`. Fix it by supplying the
var (or enriching the persona, or overriding the text), then **reactivate explicitly**
with `linbox_update_campaign_persona(status="active")` — saving vars or texts never
changes status on its own.

### Enrollment state

`linbox_list_campaign_personas` returns per enrollment: `id` (the
`campaign_persona_id`), `persona_id`, `status`, `stopped_reason`, `done_steps`,
`texts`, `vars`, `missing_vars`, `next_step_at`, `was_friend`, `waiting_connection`, `waiting_agent`,
`waiting_step_id`, `waiting_agent_at`, `current_step_id`, `current_step_type`,
`last_invite_task_id`, `replied_at`, `agent_seen_at`.

| `status` | Meaning |
|---|---|
| `active` | In the sequence; `next_step_at` is when the tick may act |
| `completed` | All steps done |
| `stopped` | Halted — read `stopped_reason` (`replied`, `manual`, `account_inactive`, `unresolved_target`) |
| `skipped` | Deliberately passed over, e.g. `stopped_reason="has_existing_conversation"` |
| `needs_attention` | Blocked on input — `missing_vars`, `agent_timeout`, `task_failed` |

- `waiting_connection=true` — the enrollment is parked on an invite and resumes once the
  target accepts. It is **not** proof a live invite exists: if the last invite task
  (`last_invite_task_id`) ended `not_sent` or unverified, the tick clears the flag and
  retries the invite. Before telling the user an invite is out, check
  `linbox_get_task(last_invite_task_id)` for `output.verified_pending=true`.
- `waiting_agent=true` — a `wait_agent` step is holding this lead for you.
  `waiting_step_id` is the id to pass to `complete_step_id`, `waiting_agent_at` is when
  it started, and `next_step_at` is the timeout deadline. Miss it and the enrollment
  falls to `needs_attention` / `agent_timeout`.
- A reply from the target stops the enrollment automatically (`stopped` / `replied`)
  and surfaces in the `campaigns` section of `linbox_whats_new`.

### Working the wait_agent queue

`linbox_whats_new` has a `campaign_waiting` section with one aggregate item per
campaign (`campaign_id`, `campaign_name`, `count`). It is **live** — `ack=true` does
not clear it; the count drops only when the enrollments are actually resolved.

```
linbox_whats_new()
linbox_list_campaign_personas({"campaign_id": "<uuid>", "waiting_agent": true})
linbox_update_campaign_persona({"campaign_persona_id": "<uuid>",
                                "complete_step_id": "<waiting_step_id>",
                                "note": "what you did"})
```

`linbox_update_campaign_persona` is the control surface for one enrollment:

- `complete_step_id` must equal the current `waiting_step_id` (or an already-completed
  step — repeats are idempotent). Anything else is
  `Error: Cannot complete step that is not the current waiting_agent step`. Completing
  resumes the sequence at the next step.
- `status` accepts `active` / `stopped` / `skipped` / `needs_attention` (never
  `completed`). `stopped` / `skipped` clear the waiting flags and `next_step_at`;
  `stopped` defaults `stopped_reason` to `manual`.
- `active` reactivates from `stopped` / `skipped` / `needs_attention` and schedules the
  next step immediately; reactivating anything else is an error.
- `texts` merges by `step_id`, `vars` merges by var name — pass only what changes.
  Neither changes `status`, so a reactivation needs `status="active"` (a second call, or
  the same call carrying both).

### Enroll outcomes (per item)

| `status` | `reason` | Meaning |
|---|---|---|
| `created` | — | New enrollment, starts immediately; an unknown but valid `profile_url` gets a persona stub first |
| `updated` | — | Existing enrollment in this campaign: `texts` merged, a stopped one reactivated |
| `skipped` | `persona_already_in_active_campaign` | Persona has an active enrollment in another campaign |
| `skipped` | `has_existing_conversation` | Campaign has `skip_if_conversation_exists=true` and a thread already exists |
| `skipped` | `invalid_target` / resolve error | `profile_url` is not a usable LinkedIn profile URL, or `persona_id` does not resolve |

Max 200 targets per call. `texts_by_persona` is keyed by `persona_id`, each value a
`{step_id: text}` map; `vars_by_persona` / `vars_by_url` carry `{var: value}` maps (see
Template variables). On `updated`, texts and vars are merged into the existing enrollment.

## Workflow

1. **Check what exists** — `linbox_list_campaigns()` before creating a near-duplicate.
2. **Create or edit** — `linbox_upsert_campaign(name, steps=[...])`. Campaigns start
   as `draft`; nothing runs yet.
3. **Pick targets** — `persona_ids` for people already in Linbox
   (`linbox_search_persona`), or `profile_urls` straight from LinkedIn. Enrich first
   only when the texts need real `{first_name}` / `company` values.
4. **Enroll** — `linbox_enroll_campaign(campaign_id, persona_ids=[...])`, optionally
   with `texts_by_persona` and `vars_by_persona` / `vars_by_url` for custom
   placeholders. Read `items[]` and handle every `skipped`.
5. **Activate** — `linbox_set_campaign_status(campaign_id, "active")`. Only `active`
   campaigns are ticked.
6. **Monitor** — `linbox_list_campaign_personas(campaign_id)`; fix `needs_attention`
   rows (read `missing_vars`) with `linbox_update_campaign_persona(vars=...)` or
   `texts=...`, then reactivate them with `status="active"`.
7. **Work the waiting queue** — `campaign_waiting` in `linbox_whats_new` → list with
   `waiting_agent=true` → `linbox_update_campaign_persona` to complete or stop each.
8. **Handle replies** — `linbox_whats_new` `campaigns` section lists unseen replies;
   take the conversation over in [Messaging](../messaging/SKILL.md), then
   `ack=true` once reviewed.

## Rules

- Campaign tools are **config-only**: no inline scraping, no enqueue, no `task_id`.
  Never tell the user a message was sent because enrollment succeeded.
- A campaign only runs while `status="active"`. `draft`, `paused`, and `done` are
  inert — activate explicitly after setup.
- **One active enrollment per persona per account**, across all campaigns. Expect
  `persona_already_in_active_campaign` and don't retry it.
- Re-enrolling into the *same* campaign is the safe update path: it merges `texts`
  and reactivates a stopped enrollment rather than duplicating it.
- `linbox_update_campaign_persona` is the **only** per-enrollment write tool: `texts`
  **merges** by `step_id` (omitted steps keep their current text) and `vars` merges the
  same way by var name.
- **Saving vars or texts never reactivates an enrollment.** After filling
  `missing_vars` on a `needs_attention` row you must call
  `linbox_update_campaign_persona(campaign_persona_id, status="active")`, or it stays
  parked forever.
- A `wait_agent` step blocks that lead until you complete it — never leave
  `campaign_waiting` unattended, and do the actual work (message, research, whatever
  the campaign intends) before calling `complete_step_id`.
- `campaign_waiting` is live and unaffected by `ack=true`; resolve the enrollments.
- To halt everything, pause the campaign; to halt one person, use
  `linbox_update_campaign_persona(status="stopped")`. There is still no MCP tool to
  delete an enrollment.
- Deferred steps are normal: the limit gate, `max_invites_per_day`, and the working
  window push `next_step_at` forward instead of sending. Don't treat a future
  `next_step_at` as a failure. A big enroll therefore rolls out over many campaign
  days — never re-enroll or re-call tools to force it through, and tell the user the
  realistic pace (`max_invites_per_day` people started per campaign day).
- **Nothing goes out outside the working window.** Off the effective days
  (`invite_days` ∩ account work days) or outside account work hours, the tick enqueues no
  campaign work at all and just moves `next_step_at` to the next working day's start. With
  the default Mon–Fri, expect **no campaign messages on Saturday or Sunday** — never
  promise a weekend follow-up, and widen both `invite_days` and the account's work days if
  the user actually wants one. Only reply-stop keeps running outside the window (it is
  cache-only).
- Replies stop the sequence by design — never re-enroll someone who was stopped with
  `replied` unless the user explicitly asks.
- A validation error is a plain string `"Error: ..."`, not JSON.

## Examples

**Create a two-step sequence and start it** — first message as soon as they accept,
follow-up 3 days later. The wait lives on the step that should pause, never on the
message that just went out.

```
linbox_upsert_campaign({"name": "Q3 founders", "steps": [
  {"id": "step-1", "type": "message", "text": "Hi {first_name|there}, ...", "delay_days": 0},
  {"id": "step-2", "type": "message", "text": "Circling back ...", "delay_days": 3}
]})
linbox_enroll_campaign({"campaign_id": "<uuid>", "persona_ids": ["<uuid-a>", "<uuid-b>"]})
  # read items[]: created / updated / skipped(+reason)
linbox_set_campaign_status({"campaign_id": "<uuid>", "status": "active"})
```

**Enroll from URLs with a custom placeholder** — the step text uses `{problem}`, and
each person's value ships with the enroll call.

```
linbox_enroll_campaign({"campaign_id": "<uuid>",
  "profile_urls": ["https://www.linkedin.com/in/alex/", "https://www.linkedin.com/in/mia/"],
  "vars_by_url": {
    "https://www.linkedin.com/in/alex/": {"problem": "manual CRM data entry"},
    "https://www.linkedin.com/in/mia/": {"problem": "slow onboarding"}
  }})
```

**Fix an enrollment stuck on a missing variable** — supply the value, then reactivate.

```
linbox_list_campaign_personas({"campaign_id": "<uuid>", "status": "needs_attention"})
  # stopped_reason=missing_vars, missing_vars=["problem"] -> take its id
linbox_update_campaign_persona({"campaign_persona_id": "<uuid>", "vars": {"problem": "manual CRM data entry"}})
linbox_update_campaign_persona({"campaign_persona_id": "<uuid>", "status": "active"})
  # saving vars never reactivates; one call with both vars and status="active" also works
```

**Work campaign replies**

```
linbox_whats_new()                  # campaigns section -> unseen replies
linbox_get_thread({"profile_url": "<items[].profile_url>"})
linbox_whats_new({"ack": true})     # only after reviewing
```

## Related skills

- [Linbox root](../SKILL.md) — auth, identifiers, `meta`, whats_new digest.
- [Messaging](../messaging/SKILL.md) — take over a conversation after a reply stops
  an enrollment.
- [Connections](../connections/SKILL.md) — how the invite step behaves, and
  `is_friend` checks before enrolling.
- [Enrichment](../enrichment/SKILL.md) — `linbox_scrap_profile` to fill template
  variables on a stub persona.
- [Tags](../tags/SKILL.md) — build and label target lists before enrolling.
