---
title: Campaign content and runtime AI
description: Write personalized variants without a reusable-template or AI-jobs service.
---

> Examples use the deployed API contract. Runtime AI requires an operator-configured provider and a workspace budget; deploying the API does not enable AI generation.

A campaign owns ordered `sequences`; each sequence is one email with its A/B variants. Create them inline with `POST /campaigns`, or replace the graph atomically with `PATCH /campaigns/{id}` and `sequences`. Include existing sequence/variant IDs when editing. The first step's A and the follow-up's B are separate choices. Step revisions preserve the settings an enrollment captured; content versions preserve what an accepted message will send. By default these snapshots stay fixed; `live_configuration:true` resolves current deterministic content during normal claiming. Existing enrolled step order stays fixed. See [campaign settings](/campaign-settings). Neither mode requires a separate service or deployment.

Put content directly on the variant:

```json
{
  "name": "A",
  "subject": "Hello {{ recipient.given_name | default(\"there\") }}",
  "preheader": "A quick idea for {{ recipient.given_name | default(\"your team\") }}",
  "html": "<p>Hello {{ recipient.given_name | default(\"there\") }},</p><p>A short introduction.</p>",
  "text": "A short introduction.",
  "weight": 1
}
```

`name` is the internal variant label. `subject` is the email's subject; `preheader` is the optional inbox preview shown beside it. Provide `html` or `text`: text alone creates escaped HTML with line breaks; customer HTML keeps its layout and styles. When both are supplied, `text` is the full plain-text MIME alternative. A preheader never replaces that text alternative. See [text, native HTML and Zapier](/email-content). Its rendered value is escaped and inserted as hidden HTML at the start of the body. Email clients decide how much preview text to display. Omit it or use `null` to let the client choose a snippet from the body. Preview text supports the same placeholders, with a 300-character limit before and after rendering, and is versioned with the content. AI generation changes subject/body but preserves the selected version's preheader.

Use [campaign settings](/campaign-settings) for sending hours, organization timezone defaults, and campaign-wide pixel/link tracking overrides.

There is no `template_id`, `/templates`, `/ai/drafts`, or `/ai/jobs` API. Existing accepted messages retain their immutable content; removing the reusable-content API does not erase history. The old database table is retained for rollback and historical cleanup.

## Personalization

The renderer uses MiniJinja with escaped recipient paths, bounded conditional branches and `default`, `lower`, `upper`, `title` and `trim` filters. It is not full Liquid. Read [conditional email and sent previews](/personalized-previews) for the complete supported grammar, limits, examples and the `POST /messages` preview body.

Use [typed custom fields](/custom-field-definitions) to define enum options and stable English keys before importing institution directories.

## Runtime generation

Add `prompt` to the same variant, for example `"Write a concise introduction using only the supplied facts. Ask one relevant question."`. Subject and either HTML or text supply base copy and context for the model. `prompt` accepts 1–4,000 characters; `null` disables generation.

1. Acceptance records the immutable variant version and recipient context, with a private preparation job in the same PostgreSQL transaction.
2. When the message is due, its campaign is active and its mailbox enabled, the existing worker renders the base copy and calls the configured provider. No database transaction stays open during the network call.
3. The existing budget ledger reserves a conservative maximum, then settles actual token usage once. An interrupted or ambiguous call is charged at its reservation and fails; it is not automatically regenerated.
4. Subject and plain-text body are validated. HTML is produced by escaping that body; model output is never evaluated as template code. Input is bounded to 32 KB and output to 2,000 model tokens, with independent subject/body limits.
5. The durable output releases the message to the normal SMTP queue. Suppressions, paused campaigns, mailbox limits, tracking, unsubscribe headers and uncertain-send handling retain their existing checks. Every SMTP retry uses the same generated content.

The private queue is an implementation detail, not a customer resource to create or poll. The public message remains `Queued` during preparation. A preparation failure becomes `Failed` with `status_detail` when the sender processes it; no silent fallback is sent. Examples include `ai_disabled`, `budget_exceeded`, `provider_error`, `provider_timeout`, `invalid_input`, `invalid_output` and `interrupted`.

Configure `AI_PROVIDER=off|openai|openrouter|groq|cloudflare|openai_compatible` on the worker. Enable it with an explicit model, token prices and workspace budget; use `.env.example` for the supported keys. All presets use one OpenAI-compatible Chat Completions client. The selected model must support strict JSON Schema and `max_completion_tokens`. Claude requires a compatible gateway/model route; direct Anthropic configuration is retired. The library never automatically retries uncertain inference calls.

The worker is serial per workspace: this feature does not promise 1,000 generations per minute. Measure queue latency and provider limits before increasing concurrency or volume.

## Campaign updates

Use `PATCH /campaigns/{id}` with `{"status":"Paused"}` or `{"status":"Active"}`. An SMTP submission already in progress may finish. Repeating the same status does not advance `updated_at`. For metrics, use `GET /campaigns/{id}?expand=analytics`; the returned report includes its freshness marker. Pause, activate and report action URLs are removed.

## Operator rollout

Apply all checked-in migrations through `20260928001100` first. Stop old sender and worker instances before starting the new roles and enabling prompts: old senders do not understand the preparation barrier. Reconcile any in-flight SMTP sends using the normal uncertain-send procedure. Deploy manually using the existing infrastructure tooling, then publish the matching docs. The migration preserves previous content and cost records. Rollback requires disabling new prompt-bearing sends and reconciling their private jobs before starting an older sender.

## Current limits

Sending pools belong to the campaign (`mailbox_ids`); account pacing and daily limits remain shared across campaigns. Follow-ups keep the original account unless it is removed, disabled or permanently fails authentication. Quota exhaustion and backoff do not rotate identities.

`exit_on` currently accepts `Reply`; suppressions always block delivery. Opening/clicking as an exit condition and automated replies remain pending. Sent previews use the messages resource. Unsupported exit conditions are rejected, not silently stored as working features.

## Copies and audience selection

Variants may include fixed `cc` and `bcc` arrays. They are versioned with the content; accepted messages keep their original copies. Use at most 49 distinct copy addresses combined: the contact adds the primary recipient. Copies count against the mailbox's recipient quota and see the same contact-personalized email. BCC stays out of delivered SMTP headers. Use separate messages for separate personalized recipients.

Read [people and campaign audiences](/people-and-audiences) for CSV IDs, groups, segments and contact IDs. For HTML without a campaign or `step_id`, use the same `POST /messages` endpoint with the [standalone email body](/direct-email-and-sender-pools). `variables.*` belongs to that standalone request; campaign personalization uses `recipient.*`.
