---
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, automatic withdrawal of stale pending invites
  (withdraw_after_days, invite_withdrawn), 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: "5.2"
---

# 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 queue
  `linbox_create_task(type="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`, `withdraw_after_days`, `max_withdraws_per_day`) |
| `linbox_upsert_campaign(name, steps?, campaign_id?, status?, max_invites_per_day?, skip_if_conversation_exists?, invite_days?, withdraw_after_days?, max_withdraws_per_day?)` | 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`, `short` (first profile page only — topcard + up to 3 experiences + visible education/skills; ignored when `full` is set), `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) |

- A `scrap_profile` step with `full` spends the account's scarce full-scrape budget
  (50/day by default vs 150/day for short), so on a large enrollment prefer `short` unless
  the texts really need activities or the employer page — see
  [Enrichment](../enrichment/SKILL.md).
- `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.
- `withdraw_after_days` is the **pending-invite TTL** (default 30, min 1). Every campaign
  withdraws stale invites — there is no off switch and `0` is rejected. Once an invite has
  been pending that long, the tick queues a `withdraw` itself and the enrollment ends up
  `invite_withdrawn`. `max_withdraws_per_day` (default 10, min 1) caps how many withdraws
  one campaign queues per campaign day; the rest wait for the next day.
- `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`, `last_withdraw_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` |
| `invite_withdrawn` | The stale pending invite was withdrawn after `withdraw_after_days`; outreach stopped and the enrollment is inert — the tick never writes or sends again until you set another status |

- `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`. That happens once the
  reply is in the cache — on message persist (an inbox/thread read, or a send on that
  thread) or on the next tick (~10 min) — **not** when the next delayed step falls due,
  so a lead mid-sequence with `next_step_at` days out still stops right away. An inbox
  preview line is not a reply on its own: open the thread (`linbox_get_thread`) so the
  message gets cached.

### 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 **any** enrollment status — `active`, `completed`, `stopped`,
  `skipped`, `needs_attention`, `invite_withdrawn`. Every non-`active` value clears the
  waiting flags and `next_step_at`; `stopped` defaults `stopped_reason` to `manual` and
  `invite_withdrawn` defaults it to `invite_withdrawn`.
- `active` reactivates from any other status and schedules the next step immediately, so
  `invite_withdrawn` → `active` puts the lead back in the sequence (the tick may invite
  or message again) exactly like reactivating a `skipped` one.
- `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.
- **The campaign withdraws its own stale invites.** Do not queue
  `type="withdraw_connection"` on a `waiting_connection` enrollment unless you
  deliberately want to withdraw before `withdraw_after_days` — otherwise leave it to the
  tick. Withdraws run on their own `connection_withdraw` budget (not the invite cap), so a
  deferred or capped withdraw is expected; never poll-loop it. Treat `invite_withdrawn` as
  a finished lead, not one still waiting on an accept.
- 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_get_profile` and the `scrap_profile` type
  to fill template variables on a stub persona.
- [Tags](../tags/SKILL.md) — build and label target lists before enrolling.
