Campaign content and runtime AI
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. Neither mode requires a separate service or deployment.
Put content directly on the variant:
{
"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. 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 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 for the complete supported grammar, limits, examples and the POST /messages preview body.
Use typed custom fields 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.
- Acceptance records the immutable variant version and recipient context, with a private preparation job in the same PostgreSQL transaction.
- 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.
- 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.
- 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.
- 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 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. variables.* belongs to that standalone request; campaign personalization uses recipient.*.