> ## Documentation Index
> Fetch the complete documentation index at: https://www.plungeai.com/docs/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, core/core-base/campaign-setup.ts, core/core-base/kind-lint.ts, orchestration/api-gateway/routes/saved.ts, apps/ocean-cli/src/commands/session/campaigns.ts | last-synced: 2026-10-05 (One API 2.3.0: kind campaign over REST/MCP, campaign_config JSON, save-time lint; earlier 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."

**The surface today.** A campaign is a saved workflow of `kind: campaign`. Create it over the One API
(`POST /v1/workflows` with `kind: "campaign"` and a `campaign_config` object), over MCP
(`plungeai_workflow {action: "create", kind: "campaign", campaign_config}`) or visually in Ocean Studio via
the **`plungeai-campaign-agent`** kind (CNL YAML + a `campaign-config` fenced block): all three write the
same row and arm the same ledger (`campaignBegin`) and cron job. Listing, reading, changing and deleting are
the ordinary workflow routes (`GET /v1/workflows?kind=campaign`, `PATCH`, `DELETE`). Ledger status, pause,
resume, stop and completing a human row are Studio-session routes (`ocean-cli campaigns status | pause |
resume | stop | complete | setup`); over an `ozk_` key you pause the campaign's *schedule* instead
(`POST /v1/schedules/{id}/pause`). The ledger operations in this page are the engine's own: you write them
into the campaign YAML, you do not call them directly.

## 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` is the exception: an
internal engine agent with no registry card (a lookup answers "no such card"); it appears only in the
claim task of the campaign YAML and in the ledger operations below.

## 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. Over REST and MCP the same fields are the
`campaign_config` JSON object of `POST /v1/workflows` / `plungeai_workflow` (a `list` object instead of
the block's `list:` line; `task_types` and `learn` are Studio-only and dropped there).

| 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` | no | `refill` (add new list rows, keep prior) or `reset` (fresh cycle from the list). Default over REST/MCP: `reset` for `csv` / `inline`, else `refill`. |
| `schedule` | no | 5-field cron for the run cadence, e.g. `"*/5 9 * * 1"` — the scheduler re-fires until the cycle drains (`plungeai-scheduling`). Without it no scheduler job is created and the pipeline schedules its own next run (`runAgain`, below). |
| `max_attempts` | yes | Per-item retry cap before an item is marked `failed` (integer ≥ 1; the ledger column defaults to 2). |
| `then` | no | Campaign or workflow id (`A-Za-z0-9_-`, ≤ 64) to hand the baton to when this one's cycle completes. |
| `deliver` | no | `[{channel, to?}]` for the cycle-complete notice (below); `channel` is `inapp` (default), `email`, `slack`, `telegram`, `whatsapp`, `discord` or `teams`. |
| `local` | no | `true` runs items on the user's own computer. |
| `max_items` | no | Hard cap on total items claimed across the cycle (required when an inner task can reach `data-table-agent`). |
| `batch_size`, `pace` | no | Positive integer claim slice per run (default 40); seconds between runs (`pace` is inert today). |
| `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.

## Saving one over the API — validation

`POST /v1/workflows` lints a `kind: campaign` YAML before anything is written (`422 invalid_workflow`, `errors`
listed): exactly one claim task (`agent: data-table-agent`, `operation: campaignClaim`) and exactly one `type: batch`
task with `items_from` (the claim task id) and `ledger: campaign`; `concurrency` ≤ 5 (open-connection cap); no
`ask_user` / `platform_action` inside the batch (unattended); an inner `type: harness` task must list
`task_complete` in `allowed_tools`; `max_items` is required when an inner task can reach `data-table-agent`. A
missing `{item.key}` in the per-item task is a warning (the ledger is at-least-once: write idempotently). Setup then
stamps the campaign id into the claim and batch tasks, opens the ledger and, when `schedule` is set, creates the
cron job named `<name> - Campaign`. Failures after the row is saved come back as `warnings` on the `201`.

## 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.
