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
- 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.
- Schema validation. That object is validated against a closed,
.strict()schema before anything else happens. Unknown fields — including a hostile attempt to set astatusor any other field the draft has no business touching — fail validation right here, before any database write. - 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. - 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 draft schema has no
statusfield at all — the model cannot set one, and a hostile attempt to smuggle one in is rejected by the schema before anything is written. - The composed campaign is re-validated through the exact same schema the wizard’s own create endpoint enforces, immediately before creation — a second, independent check on top of the draft’s own validation.
- A WhatsApp draft doesn’t send anything either: it creates a new marketing-template proposal at the same “pending Meta review” status any hand-authored template starts at. The campaign stays blocked from activating until that template is actually approved — the identical gate a hand-built WhatsApp campaign has always had, now proven to hold for an AI-drafted one too.
- An automation drafted this way gets the same holdout carve-out the wizard applies by default (10% held out to measure lift; 0% for one-time blasts) — nothing the agent does skips it.
- Consent checks, frequency caps, and every other send-time rule apply exactly as they do to a campaign you typed in yourself, because activation and sending are, quite literally, the same code path regardless of how the draft was created.
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
- One goal, one draft — there’s no conversation or follow-up; each request is independent, and there’s no way to ask the agent to revise a draft it already made (edit it in the wizard instead).
- Exactly one active program — a tenant with zero or several active programs gets a plain validation error rather than a guess.
- One wallet offer per pass — the newest active match wins; offers don’t combine or stack.
- Env-level provider, not per-tenant — same limitation as the analytics copilot: every tenant on a deployment shares the platform’s configured provider and key.
Next steps
- API reference — the full request/response schema for
POST /v1/campaigns/agent-draft, and for the promotions endpoints that carrywallet_offer_json. - Analytics copilot — the platform’s other dashboard-facing AI surface, and the model-provider setup this page reuses.
- AI agents (MCP) — the platform’s AI surface for external agents, with its own separate audit trail.
infra/DEPLOY.md— deploy-time setup (no new secrets for this feature — see its edge-api leaf).