> ## Documentation Index
> Fetch the complete documentation index at: https://docs.plungeai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Campaigns — running a list to completion (the campaign ledger)

> A campaign claims a list of items (a data-table query, an agent's lister output, an uploaded CSV, or an inline list), runs each item through a CNL pipeline with per-item retries, and repeats on a cadence until the list ("cycle") is drained — then either re-cycles (refill/reset) or hands off to…


<!-- sources-of-truth: agents/agents/campaign-agent/campaign-agent-design-5.0.md §8, agents/agents/campaign-agent/EXAMPLES.md, core/core-data-table-agent/README.md, core/core-data-table-agent/campaign-ledger.ts, core/core-data-table-agent/chain-step.ts, core/core-data-table-agent/counter-step.ts, orchestration/scheduler/README.md, orchestration/scheduler/CLAUDE.md | last-synced: 2026-09-23 -->
A **campaign** claims a list of items (a data-table query, an agent's lister
output, an uploaded CSV, or an inline list), runs each item through a CNL pipeline
with per-item retries, and repeats on a cadence until the list ("cycle") is
drained — then either re-cycles (`refill`/`reset`) or hands off to another
campaign (`then`). It is PlungeAI's answer to "process these 280 rows every
Monday" or "watch these 50 URLs forever."

**Plain fact about the surface:** there is no dedicated public One API route or MCP
tool for managing a campaign today (`https://api.plungeai.com` and
`https://mcp.plungeai.com/v1` expose neither) — a campaign is authored visually in
Ocean Studio via the **`plungeai-campaign-agent`** kind (CNL YAML + a
`campaign-config` fenced block), and its runtime primitives are plain CNL
`agent: data-table-agent` operations you can also call directly from a workflow you
build yourself. This skill documents what exists; for the authoring UI itself use
`plungeai-campaign-agent`.

## Discover the pieces first

A campaign's pipeline is ordinary CNL, so discover its agents the normal way —
never guess an id: `plungeai_list_agents {search: "<what the item-processing step needs>"}`
(REST: `GET /v1/discovery/search?kind=agents&q=…`). `data-table-agent` itself is a
registry agent id — confirm it live the same way.

## The `campaign-config` block

A fenced ```` ```campaign-config ```` block sits after the CNL YAML; Studio parses
it to open the ledger and create the scheduler job.

| Field | Required | Meaning |
|---|---|---|
| `list` | yes | Where items come from: `table:` (a data-table project+table), `agent:` (a lister task whose JSON array feeds `items_from`), `csv: true` (uploaded rows), or `inline: [key, …]`. |
| `cycle` | yes | Calendar bucket: `once` \| `hourly` \| `daily` \| `weekly` \| `monthly` \| `continuous`. |
| `cycle_start` | when re-listing | `refill` (add new list rows, keep prior) or `reset` (fresh cycle from the list). |
| `schedule` | yes | Cron for the run cadence, e.g. `"*/5 9 * * 1"` — the scheduler re-fires until the cycle drains (`plungeai-scheduling`). |
| `max_attempts` | yes | Per-item retry cap before an item is marked `failed` (integer ≥ 1; the ledger column defaults to 2). |
| `then` | no | Campaign id to hand the baton to when this one's cycle completes. |
| `deliver` | no | Channels for the cycle-complete notice (below). |
| `local` | no | `true` runs items on the user's own computer. |
| `max_items` | no | Hard cap on total items claimed across the cycle. |
| `task_types` | no | Per-row task/worker overrides. |
| `learn` | no | Enables the ledger's recall/knowledge lane. |

## The CNL shape — claim, batch, complete

```yaml
workflow:
  name: "RH price check"
  tasks:
    - type: task
      id: claim
      agent: data-table-agent
      operation: campaignClaim
      campaign_id: "<written by Studio at save>"
      batch_size: 40
    - type: batch
      id: work
      items_from: claim
      concurrency: 5
      ledger: campaign
      tasks:
        - type: task
          id: one
          agent: price-collector-v2-agent
          operation: collect_single
          product_name: "{item.key}"
          week_date: "{item.cycle}"
```

`type: batch` with `ledger: campaign` is what wires the batch's per-item outcome
back into the campaign ledger automatically (complete/fail bookkeeping) — see
`plungeai-workflows` for the general `batch` task type. An item's work can be a
plain `task` (as above) or an open-ended `type: harness` mission
(`plungeai-missions`) when the per-item work needs judgment, not a fixed call.

## The ledger primitives (`agent: data-table-agent` operations)

Every operation is owner-scoped (`owner_uuid` — resolved from your account, not a
field you pass by hand):

| Operation | Purpose |
|---|---|
| `campaignBegin` | Create/upsert the campaign header at save time (list source, cycle kind, batch/concurrency/max_attempts, optional seed items). |
| `campaignClaim` | Lease up to `batch_size` pending items for this run (`run_id`); opens a new cycle if the prior one drained and `cycle_start` allows it. Refuses to lease from a non-`active` campaign. |
| `campaignComplete` | Mark one leased item done with its result — only a currently-leased row transitions (a stale duplicate is a safe no-op). |
| `campaignCompleteHuman` | Complete a `worker_kind: "human"` row directly — human rows are never leased, so this is the only way one closes out. |
| `campaignFail` | Record a failed attempt; the item returns to `pending` until `max_attempts`, then flips to `failed`. |
| `campaignRelease` | Un-lease claimed-but-unfinished rows (e.g. a crashed run) back to `pending`. |
| `campaignStatus` | Read the header + per-status item counts. |
| `campaignAddItems` | Add items to the current (or a named) cycle; refuses past `max_items`. |
| `campaignRetryFailed` | Re-queue `failed` items (optionally a specific key list) back to `pending`. |
| `campaignSetStatus` | Pause/resume/mark done; pausing stamps `paused_by` (default `user:<owner>`; the scheduler's own zero-progress breaker stamps `auto:zero-progress`). |
| `campaignDelete` | Soft-delete (`deleted_at` + `status='done'`); releases held leases; history stays queryable. |
| `campaignTasks` / `campaignUpsertTasks` / `campaignSetTaskActive` | Read/edit the campaign's editable task-row list (the Task-app grid). |
| `campaignResults` / `campaignPivot` | Recent per-item results (optionally rolled up per cycle) / a tasks-×-runs reporting grid. |
| `chainStep` | The zero-AI "forever campaign" unit of work: bump a lane counter once per cycle (idempotent under at-least-once re-claims) and log a per-run row. |
| `counterStep` | A pure per-cycle counter (`prev + step`), for the simplest possible repeating tally. |

Full parameter shapes live in `core/core-data-table-agent/campaign-ledger.ts`,
`chain-step.ts`, `counter-step.ts` — read those before hand-building a pipeline
around one of these operations; nothing here is invented beyond what that code
implements.

## Cadence — two ways a campaign keeps going

1. **Cron `schedule`** — an ordinary recurring scheduler job re-fires the pipeline
   until the cycle drains (the Forever Campaign ran on `*/5 * * * *`). This is the
   normal path when `campaign-config.schedule` is set.
2. **`runAgain` self-scheduling** — a campaign saved *without* a cron schedule ends
   its pipeline with `agent: scheduler, operation: runAgain, in: "1h"` (or another
   duration). This creates exactly ONE `@once` job for the workflow and **never
   stacks** — an existing pending `@once` job for the same workflow is re-timed,
   not duplicated. See `plungeai-scheduling` for `@once`/`runAgain` mechanics and
   cron/date-token reference in full.

## Sizing

Keep `batch_size × seconds-per-item ÷ concurrency ≤ 600s` — comfortably inside the
scheduler's execution window. The defaults (`batch_size: 40`, `concurrency: 5` at
~30s/item ≈ 4 minutes per run) are safe up to roughly 100 items per scheduled run;
`batch_size`/`concurrency` are hidden authoring knobs, not something you expose to
an end operator.

## Delivery on cycle-complete (`deliver`)

- Fire-and-forget: a delivery failure never fails the run.
- Length caps per channel: telegram 3800, whatsapp 3800, discord 1900, slack 3800,
  email 100000, inapp 2000 characters — keep output well under the smallest cap you
  use.
- telegram / whatsapp targets must be paired to the owner first (a pairing code
  from Studio, `/pair <code>` from that chat); discord requires any active pairing.
  Unpaired targets are skipped.
- email sends from the platform address; a bare target means the owner's address —
  any other address needs that holder's prior confirmation, else it's skipped
  silently.
- slack: a bare target DMs the owner; a specific `chat_id`/`to` needs the owner to
  be a connected workspace member.

## Gotchas

- **`upsertRow` is insert-only, not keyed** — it does not match `matchFields` or
  update in place, and a JSON-string `data` is not parsed. For an idempotent
  per-item write use `chainStep` or explicit top-level `col_<name>` columns
  (`insertRow`/`upsertRow` substitute `{item.*}` only into top-level string
  fields); to update an existing row use `updateRows`.
- A partial cycle should never trigger a *dependent* report via `then` — chain only
  full completions to full completions (a data-collection campaign feeding a
  time-triggered analysis job is the safer pattern than chaining the analysis
  itself).
- The zero-progress breaker auto-pauses a campaign after several consecutive
  completed-runs-that-completed-nothing (an all-failing list) — check
  `campaignStatus`/the Task app before assuming a stalled campaign needs a manual
  nudge.
- Every ledger operation is scoped by the caller's own `owner_uuid` — there is no
  cross-tenant campaign management surface.
