AI campaign drafts

The dashboard’s Campaigns page can draft a campaign for you from a plain-language goal — “win back members who haven’t visited in a while,” “welcome new members with a WhatsApp message.” The model never activates or sends anything: it only ever proposes a structured draft (a name, an audience, a channel and its content, and an optional wallet offer). Your own code validates that draft, prices its audience with the same cost estimator every other campaign uses, and saves it as an ordinary status: "draft" campaign — the exact row shape and creation path the campaign wizard itself uses. You then open it in that wizard, edit anything you like, and activate it through the same gates every hand-built campaign goes through: an approved WhatsApp template, consent checked at send time, and frequency caps.

How a goal becomes a draft

your goal ──▶ model proposes a structured draft (the IR) ──▶ schema-validated (closed, .strict())


                                          OUR code resolves the audience, prices it with the
                                          SAME estimator every campaign uses, and creates a
                                          status="draft" campaign — the wizard's own create path


                                    you review and edit it in the EXISTING wizard, then activate
                                    it through the EXISTING gates (template approval, consent,
                                    frequency caps) — identical to a campaign you built by hand
  1. Model → draft. The model reads your goal plus a system prompt describing your program (see Privacy below) and returns exactly one JSON object — never prose outside it, never SQL, never a cost figure or a member list.
  2. Schema validation. That object is validated against a closed, .strict() schema before anything else happens. Unknown fields — including a hostile attempt to set a status or any other field the draft has no business touching — fail validation right here, before any database write.
  3. Resolve, price, create. Your own code resolves the audience (an existing segment by name, or validates and creates a new one), prices it with the platform’s cost estimator, and creates the campaign — a normal status: "draft" row, through the exact same internal path the wizard uses when you build one by hand.
  4. Review. You open the draft in the wizard like any other campaign, edit whatever you want, and activate it when ready. Activation enforces the same rules it always has — the agent adds no new enforcement point and skips none of the existing ones.

What a draft can propose

The model’s only output is a closed set of fields — it cannot invent a vocabulary outside this list, and every sub-shape (trigger, content, audience definition) is the exact same schema the wizard’s own create endpoint enforces, reused rather than duplicated:

Field What it holds
name Up to 80 characters.
kind blast (one-time; its trigger must be manual) or automation (recurring — welcome, birthday, lapsed, expiry, tier change, or post-visit).
channel email, sms, or whatsapp — exactly one.
content (email/sms only) A subject + body (email) or a single text (sms) — the same authored-content shape the wizard saves.
whatsapp_template (whatsapp only) Not inline content — WhatsApp can only ever send an approved template, so a WhatsApp draft instead proposes a brand-new marketing template (a body, and optionally a name). See The no-bypass guarantee.
segment Either an existing segment by its exact name, or a new audience definition over the same rule fields the Segments page exposes — never an implicit “all members.”
wallet_offer (optional) A headline (≤60 characters) and how many days it should run (1–90) — see Wallet offers.
rationale Up to 500 characters of prose explaining the draft, shown to you for review. It is never a source of any number — see below.

A blast’s trigger must be manual, and an automation’s trigger must be anything else — the model cannot draft an automation that never fires, or a one-time blast on a recurring trigger.

Draft one

Unlike most of the public API, campaign drafting is a dashboard-only endpoint — it authenticates with your dashboard session cookie, not an organization API key (see the quickstart’s “two kinds of caller” table). Any dashboard role with campaigns:manage can call it — owner, admin, and manager by default; cashier cannot.

Using the same cookies.txt session from the quickstart, once you’ve created and activated a program (steps 1–7 — the agent needs exactly one active program to attach the draft to):

curl -sS -b cookies.txt http://localhost:8787/v1/campaigns/agent-draft \
  -H 'Content-Type: application/json' \
  -H 'Origin: http://localhost:8787' \
  -d '{ "goal": "Win back our big spenders with a wallet offer" }' | jq .
{
  "state": "completed",
  "campaign_id": "01J8Z3QW7K2N4M9P6R1T5V0X3Y",
  "promotion_id": "01J8Z3QW8L3P5N0Q7S2U6W1Y4Z",
  "rationale": "The goal pairs re-engagement with a pass-based perk, so this drafts a high-value audience by spend and attaches a wallet offer the merchant can push to member passes.",
  "estimate": { "rows": [], "total_usd_micros": 0 }
}

campaign_id is a normal status: "draft" campaign — open it in the wizard to see (and edit) everything, including the new segment the draft created for you (named <campaign name> audience (…), listed on the Segments page — its id isn’t echoed directly in this response). This example’s estimate is zero because it drafted an email send (email has no per-message rate); a whatsapp/sms draft prices its resolved audience with the same per-country estimator every other campaign uses, capped at a 1,000-member sample and extrapolated for larger segments. promotion_id is set because the goal asked for a wallet offer — see Wallet offers for what that draft promotion needs before it can go live.

The no-bypass guarantee

The endpoint’s only possible writes are a status: "draft" campaign, and — only when the draft proposes one — a status: "draft" wallet-offer promotion, a status: "draft" WhatsApp template, and a new segment. There is no agent-side path to activate or send anything:

The optional wallet offer follows the same principle at one remove: the endpoint can create a draft promotion carrying your proposed headline, but that promotion still needs a merchant to fill in its actual tracked offer (a percent discount, a fixed amount, or a free item) and activate it before it ever appears anywhere — see Wallet offers.

The honesty model

Every request resolves to one of three states, and only one of them ever creates anything:

state Meaning What happened
completed The goal produced a valid draft. A status: "draft" campaign was created (plus a draft promotion, when a wallet offer was proposed). estimate and rationale are populated.
unsupported The goal produced a well-formed draft that still couldn’t be used — an unresolvable segment name, or a deeper check failed (for example, a merge field like {{feedback_url}} that only makes sense on a post-visit trigger, proposed on a one-time blast). Nothing was written — not the campaign, not a segment, not a template. campaign_id, promotion_id, rationale, and estimate are all null.
unavailable The model provider couldn’t be reached — or returned output that failed our validation (for example, a WhatsApp draft missing its template proposal). Either way we won’t guess. Same as unsupported — nothing was written.

Both non-completed outcomes are atomic: the segment/template/campaign/promotion writes all happen inside one transaction, so a failure at any step rolls back everything from that request, never a half-created draft. An unresolvable segment name is a deliberate case of this: the agent never falls back to “all members” just because the name it picked doesn’t exist.

Rate limits

Because every draft costs a model call, each tenant is capped at 10 drafts per 60 seconds — a per-tenant counter, checked before the model is called (and before any of your data is even read). Going over it returns an honest 429 rather than queuing the request. This is a separate, smaller budget than the analytics copilot’s 30-per-60s — drafting a campaign is a heavier model call than answering an analytics question, so it gets a tighter cap.

Privacy: what the model sees

The prompt carries your goal, plus a short system prompt listing: your program’s name and currency, every segment’s name and current member count (a number your own code computed — an aggregate, never a row), your tier names, which channels you have connected, and your approved WhatsApp template names. No member ever enters the prompt — no phone number, email, loyalty number, member id, or individual row of any kind. The model is explicitly told that no individual customer data is available to it.

This extends to numbers, too: every figure you see — the estimate, the segment’s member count — is computed by your own code, either before the model is asked anything or after its draft is already fixed. The model’s rationale is prose for you to read, never a number source; nothing in the dashboard ever renders it as a figure.

If a tenant has zero active programs, or more than one, drafting fails with a plain validation error before the model is ever called — the agent won’t guess which program a multi-program tenant meant.

Audit log

Every request that actually reaches the model writes an ai_audit row — feature campaign_agent — recording your goal, the validated draft (when there was one), a bounded summary (just the created campaign_id/promotion_id, never member data), the provider/model, latency, and outcome (completed, rejected for an unsupported draft, or failed for an unavailable model or an unexpected error). This is the same ai_audit table the analytics copilot writes to (one shared table, two feature values) — and it remains a separate trail from the MCP server’s own agent-governance log, despite the name overlap: this page’s “agent” is the in-dashboard drafting assistant a merchant uses directly, not an external MCP client. Query the table directly if you need the history — there’s no dedicated dashboard page for it yet.

One nuance worth knowing: a request that never reaches the model — a missing/invalid goal, the 429 rate limit, or the “you need exactly one active program” check above — writes no audit row. There’s no model spend to account for yet at that point, so there’s nothing to audit.

Model provider

Campaign drafting reuses the exact same model gateway as the analytics copilot — same AI_PROVIDER/AI_API_KEY/AI_MODEL configuration, same Anthropic-default-or-OpenAI choice, same “runs end-to-end with an honest unavailable if you skip it” behavior. There’s nothing extra to configure for this feature specifically. See the analytics copilot guide and infra/DEPLOY.md’s edge-api leaf for the deploy-time setup.

Wallet offers

Separately from the drafting agent — though the agent can propose one as part of a draft — any promotion can be flagged to appear on member wallet passes. Set wallet_offer_json (a headline up to 60 characters, and an optional expires_at) on a promotion via POST /v1/promotions or PATCH /v1/promotions/:id while it’s draft or paused. This is independent of the promotion’s actual tracked offer (a percent discount, fixed amount, or free item) — the wallet flag only controls what shows on a pass; the promotion still needs a real tracked offer before it can be activated at all, the same activation gate every promotion has always had. That matters specifically for an agent-proposed wallet offer: the draft ships with a placeholder in place of a real tracked offer, so you always have to finish configuring it in the promotions editor before it can go live — never straight from the model’s draft.

When it’s live. Once a wallet-flagged promotion is status: "active", its offer becomes eligible to appear on the pass of any member in its audience (or every member, if it has no segment). A member’s pass can only ever show one offer at a time — if more than one active promotion targets the same member, the most recently created one wins; there’s no stacking or merging. Pausing or ending the promotion clears it immediately, with the same push described below. Passing its expires_at isn’t instant, though — the pass only stops showing the offer the next time it’s rebuilt, and the nightly sweep (below) is what makes that prompt: it proactively rebuilds and pushes any pass whose offer has expired, rather than waiting on some unrelated change to trigger the rebuild.

Where it shows. All three wallet providers render the same headline (and, when set, an “ends …” label) in their own idiom: a back-of-pass field on Apple passes, a text module on Google passes, and a card field on Samsung passes. All three explicitly clear that space the instant no offer applies anymore — a paused, ended, or expired offer is actively removed from the pass, never left showing stale content.

How it reaches the pass. Activating, pausing, or ending a wallet-flagged promotion pushes an update to every affected member’s pass through the same queue every other pass update (balance and tier changes) already uses — batched so a large audience doesn’t overload a single request. The device push itself is still debounced to at most once per minute per member, same as any other pass update, so an offer landing alongside other changes doesn’t cause a burst of pushes. Expiry is swept up nightly: the same nightly job that already scores RFM segments also clears any wallet offer whose expires_at has passed and pushes the resulting change — no separate schedule, and it only ever clears an offer that actually had an expiry set.

Cost. Wallet pass pushes have no per-message provider charge — unlike a WhatsApp, SMS, or email campaign send, which the platform prices per recipient/country before you send it, updating a wallet pass through Apple, Google, or Samsung’s own push services costs nothing extra. Flagging a promotion for the wallet is free regardless of how many members it reaches.

v1 limits

Next steps