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

# Campaigns (Batch Execution)

> Schedule recurring batch campaigns against target segments with frequency caps, file output configuration, and full execution history.

<Note>
  **See also**: [Runs REST API reference](/api-reference/runs) for request/response shapes, status codes, and error semantics.
</Note>

## Overview

A **campaign** defines a recurring batch execution configuration. Each campaign targets a [Decision Flow](/decisioning/decision-flows) and a customer [Segment](/data/overview), with schedule settings, frequency caps, and file output configuration. When a campaign runs, it creates a **campaign run** -- an individual execution instance that processes every customer in the segment through the Decision Flow.

```
Campaign (config + schedule) → Campaign Run #1 → Campaign Run #2 → ...
```

## Campaign vs Campaign Run

| Concept          | What It Is                                                                   | Lifecycle                                         |
| ---------------- | ---------------------------------------------------------------------------- | ------------------------------------------------- |
| **Campaign**     | Schedule config, frequency caps, file output settings, target flow + segment | Persistent -- draft, active, paused, archived     |
| **Campaign Run** | Individual execution of a campaign                                           | Transient -- pending → running → completed/failed |

## How Campaigns Work

<Steps>
  <Step title="Create campaign">
    Select a Decision Flow, target segment, schedule, frequency caps, and file output config.
  </Step>

  <Step title="Configure schedule">
    Choose manual, daily, weekly (pick day), or monthly (pick day or last working day). Set time and timezone.
  </Step>

  <Step title="Set frequency caps">
    Optionally limit max recommendations per run, per offer, or per channel.
  </Step>

  <Step title="Configure file output">
    In the campaign's **File Output** section, choose a destination (download-only, or an S3 connector), the format (CSV/TSV/JSON/JSONL), and build the columns — each column reads a built-in decision field or a segment-schema column, with an optional transform chain. A live preview shows one sample row.
  </Step>

  <Step title="Run">
    Click "Run Now" for manual execution, or activate the campaign for scheduled runs.
  </Step>

  <Step title="Review run history">
    Each execution appears as a numbered run with full results, offer breakdown, and file outputs.
  </Step>
</Steps>

## Schedule Configuration

| Schedule Type | Description                                    | Config Fields                                                                         |
| ------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------- |
| `manual`      | Triggered manually via "Run Now" button or API | None                                                                                  |
| `daily`       | Runs every day at a specified time             | `scheduleTime`, `scheduleTimezone`                                                    |
| `weekly`      | Runs once per week on a specific day           | `scheduleDayOfWeek` (0=Sun..6=Sat), `scheduleTime`, `scheduleTimezone`                |
| `monthly`     | Runs once per month on a specific day          | `scheduleDayOfMonth` (1-28 or `last_working_day`), `scheduleTime`, `scheduleTimezone` |
| `custom`      | Runs on a raw cron expression                  | `scheduleCron` (e.g. `0 9 * * 1`), evaluated in `scheduleTimezone`                    |

<Info>
  **Scheduled campaigns actually fire.** A `GET /api/v1/cron/campaign-scheduler` route (bearer-authed with `CRON_SECRET`, same as the rest of the cron tier) scans every `status: "active"` campaign whose `scheduleType` is not `manual`, evaluates `daily`/`weekly`/`monthly` as an RRULE and `custom` via `scheduleCron`, and triggers a campaign run the same way `POST /api/v1/runs/:id/campaign-runs` does when a schedule is due. It runs on a 5-minute cadence in the in-process [maintenance scheduler](/self-host/configure/cron-tier#in-process-maintenance-scheduler) (or your own external scheduler, on self-hosted deployments) and is self-healing: if a tick is missed (deploy, restart, scheduler downtime), the next tick still fires as long as the schedule's most recent occurrence is after the campaign's `lastRunAt` — no exact-minute alignment is required. `draft`, `paused`, and `archived` campaigns are never auto-fired.
</Info>

## Frequency Caps

<Warning>
  The Run record's `frequencyCaps` JSON column (`maxTotalPerRun`, `maxPerOffer`, `maxPerChannel`) is accepted on create/update but **not currently read by the batch execution engine** (`executeBatchRun`) — setting it has no effect on a run today. Per-customer frequency capping for batch campaigns works through [Contact Policies](/decisioning/contact-policies) instead (a `frequency_cap`-type policy applied during the same qualification/ranking pipeline batch runs share with `/recommend`) — see the caveat under [Run Results](#summary-breakdown) about `InteractionSummary` materialization.
</Warning>

| Cap              | Type   | Description                                                                     |
| ---------------- | ------ | ------------------------------------------------------------------------------- |
| `maxTotalPerRun` | number | Maximum total recommendations across all offers *(reserved — not yet enforced)* |
| `maxPerOffer`    | number | Maximum recommendations per individual offer *(reserved — not yet enforced)*    |
| `maxPerChannel`  | number | Maximum recommendations per delivery channel *(reserved — not yet enforced)*    |

## File Output Configuration

File output is configured **on the campaign** (`Run.fileConfig`), not the channel. Every file-mode (and manual) channel the campaign targets writes a file using this one shape — so two campaigns that share a channel can still produce different files. Edit it in the **File Output** section of the full-window campaign editor (`/runs/new` or `/runs/[id]/edit`).

<Note>
  File output moved off the Channel and onto the campaign in the `Run.fileConfig` v2 redesign. A Channel's `deliveryMode: "file"` now just means "this channel produces a batch file"; its columns, format, and destination come from the campaign. Any `fileConfig` still stored on a Channel row is ignored by the batch executor.
</Note>

`Run.fileConfig` v2 fields:

| Field                    | Type                            | Default                                  | Description                                                                                                                                                                                                     |
| ------------------------ | ------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `format`                 | enum                            | `csv`                                    | Output format: `csv`, `tsv`, `json`, or `jsonl`                                                                                                                                                                 |
| `delimiter`              | string                          | `,`                                      | Field separator (`csv` only)                                                                                                                                                                                    |
| `includeHeader`          | boolean                         | `true`                                   | Whether to write a header row (`csv`/`tsv` only)                                                                                                                                                                |
| `namingPattern`          | string                          | `{{channel}}_{{date}}_{{batch}}.{{ext}}` | File name template — `{{channel}}`, `{{date}}` (YYYYMMDD), `{{batch}}` (first 8 chars of the run id), `{{ext}}`                                                                                                 |
| `columns`                | `{name, source, transforms?}[]` | the 5 legacy fields below                | Output columns with an optional per-column transform chain (see [Column Sources](#column-sources) and [Transforms](#transforms)); omit to get `customerId`, `offerName`, `creativeName`, `channelName`, `score` |
| `destinationConnectorId` | string                          | *(download only)*                        | Id of an `aws_s3`-type [Connector](/data/connectors) to upload the file to. Omit for a download-only local artifact                                                                                             |
| `destinationPath`        | string                          | --                                       | Key prefix within the bucket, e.g. `campaigns/{{date}}` (supports `{{date}}` tokens)                                                                                                                            |

The engine derives whether to upload: when `destinationConnectorId` is set **and** resolves to a connector for the tenant, the file is uploaded; otherwise it degrades to a local downloadable artifact. `connectorId` is still accepted as a legacy alias for `destinationConnectorId`.

`Run.fileConfig` is validated on campaign create/update (`validateRunFileConfig` → `RunFileConfigSchema`): `format` must be one of the values above, each `columns[]` entry needs both `name` and `source`, and each transform `op` must be a known op. The check is permissive/additive — an omitted, empty, or legacy `fileConfig` is always accepted; only a genuinely malformed one is rejected.

<Warning>
  **Only Amazon S3 uploads today.** `aws_s3` is the only wired batch uploader (`UPLOAD_SUPPORTED_TYPES` in `src/lib/delivery/file-delivery.ts`), so the destination picker in the editor lists S3 connectors only. A campaign pointed at any other connector type honestly degrades to a local artifact rather than reporting a delivery it didn't make — other object stores are a tracked follow-up.
</Warning>

### Column Sources

Each entry in `columns[]` maps an output column `name` to a `source` — a namespaced string resolved per row at file-generation time (`src/lib/delivery/file-format.ts`). A source that doesn't resolve for a given customer (no such attribute, no contact address found, etc.) outputs an empty string — it never fails the row. In the editor, a column is either a **built-in field** (a fixed decision field, chosen from a dropdown) or a **schema column** (a column of the campaign segment's entity schema, which maps to `attribute.<column>`).

| Source                                                                                                                                    | Resolves to                                                                                                                                                                                                                                                                           |
| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `customerId`                                                                                                                              | Customer identifier                                                                                                                                                                                                                                                                   |
| `offer.id`, `offer.name`                                                                                                                  | The recommended offer's id / name                                                                                                                                                                                                                                                     |
| `creative.id`, `creative.name`                                                                                                            | The selected creative's id / name                                                                                                                                                                                                                                                     |
| `creative.subject`, `creative.headline`, `creative.body`, `creative.imageUrl`, `creative.ctaText`, `creative.ctaUrl`, `creative.deepLink` | Fields from the Creative's `content` JSON — see [Creatives](/studio/creatives)                                                                                                                                                                                                        |
| `channel.id`, `channel.name`                                                                                                              | The delivering channel's id / name                                                                                                                                                                                                                                                    |
| `score`                                                                                                                                   | Propensity score for this offer/customer pair                                                                                                                                                                                                                                         |
| `contact.email`, `contact.phone`, `contact.deviceToken`                                                                                   | The customer's resolved contact address, matched from the segment row by column-name pattern (e.g. any column matching `email`/`mail`, `phone`/`mobile`/`msisdn`, or `device_token`/`fcm_token`) — this is what lets an email or SMS vendor file actually carry a deliverable address |
| `attribute.<column>`                                                                                                                      | A column of the campaign segment's base schema — the per-row customer attributes. In the editor this is the **schema column** picker; pick a segment on the campaign first so its columns are available                                                                               |

<Info>
  `offerName`, `creativeName`, and `channelName` (no dot) are still accepted as column sources for backward compatibility, but new campaigns should use the namespaced `offer.name` / `creative.name` / `channel.name` forms above. `personalization.<key>` is intentionally not offered — batch runs don't populate it (they run their own qualification/scoring pipeline, not the Recommend API's Enrich/Compute stages).
</Info>

### Transforms

Each column can carry an optional `transforms` chain — operations applied left-to-right to the resolved source value before it's written. Semantics live in `applyTransforms` (`src/lib/delivery/file-format.ts`), shared verbatim with the editor's live preview.

| Op              | Effect                            |
| --------------- | --------------------------------- |
| `trim`          | Strip leading/trailing whitespace |
| `uppercase`     | Upper-case the value              |
| `lowercase`     | Lower-case the value              |
| `remove_spaces` | Remove all whitespace             |
| `prefix`        | Prepend `value`                   |
| `suffix`        | Append `value`                    |

`prefix` and `suffix` read the transform's `value` field; the other ops ignore it. A missing/empty source is never fabricated — transforms only run when the value is present, so a blank source stays blank rather than becoming a bare prefix.

### Formula-injection guard (CSV/TSV)

Outbound files are delivered to third parties and routinely opened in Excel or Google Sheets, which interpret any cell beginning with `=`, `+`, `-`, `@`, a tab, or a carriage return as a **formula**. Because customer-derived column values (raw `attribute.<column>` fields, contact addresses) are untrusted, the CSV and TSV serializers neutralize any such cell by prefixing it with a single apostrophe (`'`) so the spreadsheet treats it as text — the standard OWASP "CSV injection" mitigation (`neutralizeFormula` in `src/lib/delivery/file-format.ts`).

The guard is number-aware: a cell that is a well-formed number — including a negative value (`-500`), a signed value (`+3`), scientific notation (`-1.2e-3`), or an E.164 phone (`+15551234567`) — is left untouched, so numeric data round-trips intact. Only genuinely formula-shaped strings are prefixed. `JSON`/`JSONL` output is **not** guarded (it isn't spreadsheet-interpreted, and a `'` prefix would corrupt the value).

### Ingesting responses

Outbound files carry decisions to a vendor; the vendor's responses (opens, clicks, conversions) come back through a **Data pipeline**, not this config. Build an ingestion pipeline with an outcomes node under [Data → Pipelines](/data/pipelines/flow-overview) to write those responses into interaction history. The campaign editor links to it from the File Output section.

### Retrieving Generated Files

```bash theme={null}
GET /api/v1/runs/artifacts/:id
```

`fileOutputs[].filePath` in the run summary is a real, retrievable path:

* When the campaign's `destinationConnectorId` resolves and the S3 upload succeeds, `filePath` is an `s3://bucket/key` reference.
* Otherwise (download-only, or the S3 upload failed), the file's content is persisted server-side and `filePath` is `/api/v1/runs/artifacts/{id}` — `GET` that path (tenant-scoped, same auth as the rest of the API) to download the file with the correct `Content-Type` and `Content-Disposition`.

<Info>
  Locally-persisted artifacts have no retention/cleanup job yet — they accumulate in the `batch_file_artifacts` table indefinitely. For very large or frequent runs, set the campaign's `destinationConnectorId` to an `aws_s3` connector.
</Info>

## Batch Channels

Only channels with **file**, **integration**, or **manual** delivery mode are included in batch campaigns. API (real-time) channels are excluded because batch runs produce file-based output.

When creating a campaign, you can optionally select specific batch channels via `channelIds` — if the list is empty (the default), every active batch-compatible channel in the tenant is included.

## Campaign Status

| Status     | Description                                                   |
| ---------- | ------------------------------------------------------------- |
| `draft`    | Campaign created but not yet active                           |
| `active`   | Campaign is live -- scheduled runs will execute automatically |
| `paused`   | Campaign is temporarily disabled -- no scheduled runs         |
| `archived` | Campaign is retired -- preserved for history                  |

## Campaign Run Status

| Status                  | Description                                                                                                                                                                                                                                                             |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pending`               | Run created, waiting for worker to pick it up                                                                                                                                                                                                                           |
| `running`               | Currently processing customers through the Decision Flow                                                                                                                                                                                                                |
| `completed`             | All customers processed successfully                                                                                                                                                                                                                                    |
| `completed_with_errors` | Processing finished but something went wrong for a subset of the run: a delivery failure (e.g. S3 upload) or a per-customer decisioning error (that customer is skipped, not the whole run). Check the run's `error` field and `results.summary.errorCount` for detail. |
| `failed`                | Execution encountered an unrecoverable error                                                                                                                                                                                                                            |

## Scoring and Ranking in Batch Runs

A batch run resolves the target Decision Flow's runnable config (latest published snapshot, falling back to the draft) and looks for a `score` node. When that node is configured with `method: "formula"` and a `strategyProfileId`, the batch executor loads that [RankingProfile](/decisioning/scoring-strategies)'s weights and ranks every candidate with them — the same composite scoring (`computeArbitratedScore`) `/recommend` uses, instead of the flat `priority × weight × fitMult` ordering batch runs used previously. When the flow's Score node weights `clv`, the executor looks up each customer's `CustomerCLV.clvScore` and folds it into the composite score; a customer with no CLV row has the `clv` term dropped from their score entirely rather than diluting it with a zero.

<Note>
  **Scope of the fix.** Only the Score node's base `strategyProfileId` is applied in batch — `strategyOverrides` (per-productType/category/channel profile switching, which operates per-candidate in realtime) are not yet reproduced in the batch loop. A flow that relies purely on its base strategy profile now ranks correctly in batch; a flow that depends on `strategyOverrides` still falls back to priority-based ranking for batch runs, same as before this fix. If the profile fails to load (deleted, wrong tenant), the run logs a warning and falls back to priority-based ranking rather than failing.
</Note>

## Run Results

After a campaign run completes, results are available at three levels:

### Overall Statistics

| Metric           | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `totalCustomers` | Number of customers in the target segment                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `processed`      | Segment size processed through the pipeline — equal to `totalCustomers` once the run finishes, **regardless of per-customer errors**. A customer whose decisioning threw is skipped (no delivery, no impression) but still counted here; check `results.summary.processedCount` and `results.summary.errorCount` (nested, not top-level) for the error-adjusted breakdown. `processed` is also a **resume checkpoint**: it is persisted after every batch, so if the worker crashes mid-run and the job is retried, execution resumes from the last persisted offset and delivers only the un-contacted tail — already-contacted customers are never re-delivered. |
| `recommended`    | Customers who received at least one offer                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `avgLatencyMs`   | Average per-customer processing time                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |

### Summary Breakdown

```json theme={null}
{
  "offerBreakdown": [
    { "name": "Premium Credit Card", "count": 12450 },
    { "name": "Personal Loan", "count": 8320 },
    { "name": "Savings Account", "count": 15680 }
  ],
  "channelBreakdown": [
    { "name": "Email", "count": 22100 },
    { "name": "Push", "count": 9850 },
    { "name": "In-App", "count": 4500 }
  ],
  "deliveredCount": 22100,
  "failedCount": 0,
  "storedCount": 0,
  "simulatedCount": 9850,
  "skippedCount": 150,
  "fileOutputs": [
    {
      "channelName": "Batch Email",
      "filePath": "/api/v1/runs/artifacts/8f14e45f-ceea-4c-...",
      "rowCount": 22100,
      "sizeBytes": 1843200,
      "success": true
    }
  ]
}
```

<Note>
  `offerBreakdown` and `channelBreakdown` are arrays of `{name, count}` — not a flat `{name: count}` object.
</Note>

The delivery counts break down each item's outcome:

| Count            | Meaning                                                                                                      |
| ---------------- | ------------------------------------------------------------------------------------------------------------ |
| `deliveredCount` | File outputs written, plus integration-mode items the provider accepted when live delivery is enabled        |
| `failedCount`    | Deliveries that errored (e.g. provider failure, degraded S3 upload, or the local artifact failed to persist) |
| `storedCount`    | Manual-mode and unselected items, queued rather than sent                                                    |
| `simulatedCount` | Integration-mode items not transmitted because `TenantSettings.liveDelivery` is off (the default)            |
| `skippedCount`   | Integration-mode items with no resolved recipient or no configured provider                                  |

<Note>
  Integration-mode delivery is gated behind a per-tenant opt-in: `TenantSettings.liveDelivery` defaults to `false`. While it is off, integration channels are never transmitted and every such item counts toward `simulatedCount` instead of `deliveredCount`. See [Channels -> Delivery Modes](/studio/channels#delivery-modes) for the full behavior.
</Note>

### Per-Customer Results

Each customer's result shows the selected offers with scores:

```json theme={null}
{
  "customerId": "SBX-000001",
  "selections": [
    { "offerId": "...", "offerName": "Premium Credit Card", "score": 0.85, "rank": 1 },
    { "offerId": "...", "offerName": "Personal Loan", "score": 0.72, "rank": 2 }
  ],
  "latencyMs": 32
}
```

<Note>
  Offer IDs in the summary and per-customer results are automatically resolved to human-readable names in the API response.
</Note>

## API Reference

### Create Campaign

```bash theme={null}
POST /api/v1/runs
Content-Type: application/json
```

```json theme={null}
{
  "name": "Weekly High-Value Campaign",
  "description": "Target high-income members every Monday",
  "decisionFlowId": "df_q1_campaign",
  "segmentId": "seg_high_value",
  "status": "active",
  "scheduleType": "weekly",
  "scheduleDayOfWeek": 1,
  "scheduleTime": "09:00",
  "scheduleTimezone": "America/New_York",
  "frequencyCaps": {
    "maxTotalPerRun": 10000,
    "maxPerOffer": 500
  },
  "channelIds": ["ch_batch_email", "ch_manual"]
}
```

<Note>
  File output is configured **on the campaign** (`Run.fileConfig` v2) and `executeBatchRun` reads it for every file-mode channel in the run — see [File Output Configuration](#file-output-configuration) above. Any `fileConfig` still stored on a **Channel** row is ignored by the batch path.
</Note>

### Update Campaign

```bash theme={null}
PUT /api/v1/runs/:id
Content-Type: application/json
```

Accepts any combination of campaign fields to update.

### Trigger Run

```bash theme={null}
POST /api/v1/runs/:id/campaign-runs
```

Creates and starts a new campaign run, returning the run record with status `pending` (`201`). The trigger is **idempotent under concurrency**: it reserves the run under a `FOR UPDATE` lock on the campaign, so two racing triggers (a double-clicked **Run Now**, a client retry, or a manual trigger coinciding with a scheduled tick) can't each start a full-segment execution and double-contact every customer. If a run for the campaign is already `pending` or `running`, that in-flight run is returned with `200` instead of a second being started. A trigger issued after the previous run reaches a terminal status starts a fresh run with the next run number.

### List Campaign Runs

```bash theme={null}
GET /api/v1/runs/:id/campaign-runs
```

Returns the execution history for a campaign, ordered by run number descending.

### Get Campaign Detail

```bash theme={null}
GET /api/v1/runs/:id
```

Returns the campaign config plus the last 10 runs, with offer names resolved in summaries and per-customer results.

### List Campaigns

```bash theme={null}
GET /api/v1/runs?limit=50&offset=0
```

Returns all campaigns with latest run status.

## UI Walkthrough

<Steps>
  <Step title="Navigate to Campaigns">
    Go to **Campaigns** in the sidebar (was "Runs").
  </Step>

  <Step title="Create campaign">
    Click **+ New Campaign**. Configure the decision flow, segment, schedule, frequency caps, batch channels, and file output settings.
  </Step>

  <Step title="Trigger a run">
    Select a campaign and click **Run Now** to trigger manual execution.
  </Step>

  <Step title="Monitor run history">
    Expand individual runs in the history to see stats, offer breakdown, and file outputs.
  </Step>
</Steps>

## Next Steps

<CardGroup cols={2}>
  <Card title="Dashboards" icon="chart-line" href="/operations-reporting/dashboards">
    Monitor campaign performance and batch results.
  </Card>

  <Card title="Decision Flows" icon="diagram-project" href="/decisioning/decision-flows">
    Configure the decisioning pipeline that campaigns execute.
  </Card>
</CardGroup>
