@feastalytics/cli 0.1.15 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/dist/cli.js +376 -219
- package/feast/SKILL.md +72 -46
- package/feast/references/domains.md +9 -7
- package/feast/references/links.md +11 -11
- package/feast/references/setup.md +41 -20
- package/feast/references/workflows/ad-copy-creator.md +26 -28
- package/feast/references/workflows/ad-copy-guest.md +28 -30
- package/feast/references/workflows/ads.md +39 -27
- package/feast/references/workflows/automations.md +36 -34
- package/feast/references/workflows/campaigns.md +56 -24
- package/feast/references/workflows/creators.md +40 -36
- package/feast/references/workflows/funnels.md +15 -19
- package/feast/references/workflows/guests.md +27 -11
- package/feast/references/workflows/members-program.md +17 -13
- package/feast/references/workflows/onboarding.md +16 -17
- package/package.json +1 -1
|
@@ -1,58 +1,56 @@
|
|
|
1
1
|
# Guest-facing ad copy (`adCopy`)
|
|
2
2
|
|
|
3
|
-
> Part of the Feastalytics CLI workflows. Confirm a tool exists with `feast tools` before relying on it, and get its exact fields from `feast describe <tool>` — this file gives the *meaning* and *ordering* the schema can't.
|
|
4
|
-
|
|
5
3
|
This is the guest-facing half of Meta ad copy: `adCopy`, which sells the offer and the food to a hungry local scrolling past.
|
|
6
4
|
|
|
7
|
-
**Writing to creators instead? Read `ad-copy-creator.md` and not this file.** `recruitmentAdCopy` sells a paid collaboration to a content creator shopping for brand deals
|
|
5
|
+
**Writing to creators instead? Read `ad-copy-creator.md` and not this file.** `recruitmentAdCopy` sells a paid collaboration to a content creator shopping for brand deals. That is a different audience and a completely different pitch. Conflating the two is the failure mode in this area, and it has happened repeatedly. A creator is not a customer; the food is their perk, not the pitch.
|
|
8
6
|
|
|
9
|
-
Publishing the finished ad is a separate job with its own loop
|
|
7
|
+
Publishing the finished ad is a separate job with its own loop: read `ads.md` for that.
|
|
10
8
|
|
|
11
|
-
**You write the copy yourself.** The dashboard has a "generate copy" button behind an LLM call; there is no
|
|
9
|
+
**You write the copy yourself.** The dashboard has a "generate copy" button behind an LLM call; there is no tool for it and you shouldn't want one, because it would be you calling an endpoint in order to call a model. The copy lands on a plain field of the campaign record, so saving it is trivial and covered at the bottom of this file. Everything between here and there is the part that's actually hard.
|
|
12
10
|
|
|
13
11
|
## Variations: write a set, not a single
|
|
14
12
|
|
|
15
|
-
Meta's Advantage+ creative optimization tests combinations of headlines and primary texts against each other, so you're writing a *set
|
|
13
|
+
Meta's Advantage+ creative optimization tests combinations of headlines and primary texts against each other, so you're writing a *set*: several headlines and several primary texts that genuinely differ.
|
|
16
14
|
|
|
17
15
|
**Genuinely** is the load-bearing word. There is no required count. Three sharp variations that each take a real angle beat five where two are padding, and a set of near-identical rewrites teaches the optimizer nothing. Write as many as the campaign actually supports: a rich offer with a strong landing page and a distinctive neighbourhood might carry five; a thin one-line promo might only honestly carry three. Judge it, and stop when the next variation would be filler.
|
|
18
16
|
|
|
19
|
-
The other reason to write more than one is that the restaurant may want to pick, and people form opinions by seeing alternatives rather than by being handed a single answer. So offering the set is usually the right move
|
|
17
|
+
The other reason to write more than one is that the restaurant may want to pick, and people form opinions by seeing alternatives rather than by being handed a single answer. So offering the set is usually the right move, but you have taste, and you should use it. Recommend the one you'd launch and say why. Cut the weak ones before anyone sees them rather than padding them in to look thorough. Three you'd defend beats five you wouldn't.
|
|
20
18
|
|
|
21
19
|
## Read before you write
|
|
22
20
|
|
|
23
|
-
Generic copy is the failure mode, and specifics are the entire job. The difference between an ad that works and one that doesn't is almost never cleverness
|
|
21
|
+
Generic copy is the failure mode, and specifics are the entire job. The difference between an ad that works and one that doesn't is almost never cleverness. It's whether the copy contains something only this restaurant could have said. So go get those things first:
|
|
24
22
|
|
|
25
|
-
1. `getOrganization
|
|
26
|
-
2. `getCampaign
|
|
27
|
-
3. `listFunnelScreens` `{ "referrer": "<subdomain>", "campaignId": "<id>" }
|
|
28
|
-
4. `queryData` on `interface.catalogItem
|
|
23
|
+
1. `getOrganization`: brand name, cuisine, and the subdomains in `subdomains2[].subdomain`.
|
|
24
|
+
2. `getCampaign`: `name`, `description`, `bannerConfig`, `promotions`, `referrers`, `shorthand`.
|
|
25
|
+
3. `listFunnelScreens` `{ "referrer": "<subdomain>", "campaignId": "<id>" }`: **the actual landing-page copy the guest sees after the click.** This is your source of truth for congruence: the trip from ad to landing page should feel like one continuous thing, not a bait-and-switch. Copy that promises something the landing page doesn't deliver burns the click.
|
|
26
|
+
4. `queryData` on `interface.catalogItem`: real menu item names and real prices, not approximations of them. It's hierarchical; walk the tree with `parentId` or `catalogItemLink`.
|
|
29
27
|
|
|
30
28
|
The landing page URL is `https://{referrer}.feastalytics.com/campaign/{campaignId}`, using a referrer from the campaign's own `referrers` rather than just the org's first subdomain.
|
|
31
29
|
|
|
32
30
|
Mine all of it for things a human would actually remember: opening dates, the street, menu item names, sweepstakes mechanics, numbers, proper nouns. **If the source has real specifics and your copy says "taco time!", you did it wrong.** When you finish a draft, check that you couldn't paste it onto a different restaurant's campaign without anyone noticing.
|
|
33
31
|
|
|
34
|
-
## Headlines
|
|
32
|
+
## Headlines: each ≤ 40 characters
|
|
35
33
|
|
|
36
34
|
The headline appears *below* the image or video. Forty characters is a hard ceiling; Meta truncates past it, and a headline that dies mid-word looks broken.
|
|
37
35
|
|
|
38
|
-
Each headline in your set should take a **different angle**. These five are the ones that work for local restaurants
|
|
36
|
+
Each headline in your set should take a **different angle**. These five are the ones that work for local restaurants, a menu to pick from, not a checklist to complete:
|
|
39
37
|
|
|
40
|
-
1. **Value
|
|
41
|
-
2. **Curiosity
|
|
42
|
-
3. **Social proof
|
|
43
|
-
4. **Urgency
|
|
44
|
-
5. **Locality
|
|
38
|
+
1. **Value**: lead with what they get: the offer, the free item, the deal. The safest angle and usually the strongest, because it answers "what's in it for me" before anyone has to think.
|
|
39
|
+
2. **Curiosity**: make them need to find out ("This spot on Fillmore is hiding something…"). Works only when there's a real answer waiting on the landing page; curiosity with nothing behind it reads as clickbait.
|
|
40
|
+
3. **Social proof**: popularity or local reputation ("The neighborhood's worst-kept secret"). Borrows credibility the restaurant already earned.
|
|
41
|
+
4. **Urgency**: time pressure or scarcity ("This week only", "Limited spots"). Only when it's true. Manufactured urgency on an evergreen offer is the fastest way to sound like every other ad in the feed.
|
|
42
|
+
5. **Locality**: the neighborhood, the street, the local identity. The one angle a national chain can't copy, and often the most distinctive thing available to you.
|
|
45
43
|
|
|
46
44
|
No generic marketing language. Write like a person, not a brand.
|
|
47
45
|
|
|
48
|
-
## Primary text
|
|
46
|
+
## Primary text: each 2 to 4 sentences
|
|
49
47
|
|
|
50
48
|
The primary text appears *above* the image or video. It's the first thing anyone reads, and it's read in a fast scroll on a phone.
|
|
51
49
|
|
|
52
|
-
**The formatting rule that matters most: separate every sentence with a blank line (two newlines).** Each sentence has to stand alone visually. A paragraph is a wall; a wall gets skipped. This is not optional polish, and it is the single most-ignored rule in this file
|
|
50
|
+
**The formatting rule that matters most: separate every sentence with a blank line (two newlines).** Each sentence has to stand alone visually. A paragraph is a wall; a wall gets skipped. This is not optional polish, and it is the single most-ignored rule in this file. Check for it explicitly before you save anything.
|
|
53
51
|
|
|
54
52
|
- No hashtags.
|
|
55
|
-
- No "click the link below" / "tap below"
|
|
53
|
+
- No "click the link below" / "tap below". Meta owns the CTA button, and pointing at a link that isn't there is just confusing.
|
|
56
54
|
- Emoji are welcome when they add energy or visual punch. **At most one per sentence**, never forced. A well-placed emoji > no emoji > emoji spam.
|
|
57
55
|
- Each variation takes a different approach, but all of them must work whether the viewer sees a static image or a video (see `creativeMix` below).
|
|
58
56
|
|
|
@@ -97,33 +95,33 @@ That's lunch sorted.
|
|
|
97
95
|
Claim your voucher and come see what the hype is about.
|
|
98
96
|
```
|
|
99
97
|
|
|
100
|
-
Study what actually changes between them. The bad versions aren't wrong on the facts
|
|
98
|
+
Study what actually changes between them. The bad versions aren't wrong on the facts; they carry the same information. They fail because they *announce* where the good ones *react*. "We are giving away" is a press release; "Yeah, you read that right" is a person. The good versions also front-load the hook into the first line, break every sentence onto its own visual row, and trade a complete sentence for a fragment wherever the fragment hits harder. Notice too that neither good version is longer than the bad one it replaces. This is compression, not decoration.
|
|
101
99
|
|
|
102
100
|
## `creativeMix` changes what the copy may assume
|
|
103
101
|
|
|
104
102
|
Set this to what's actually true of the assets that will run, then write to it:
|
|
105
103
|
|
|
106
|
-
- **`static_only
|
|
107
|
-
- **`video_only
|
|
108
|
-
- **`mixed
|
|
104
|
+
- **`static_only`**: the offer is printed on the image. Reference it directly; the viewer always sees it.
|
|
105
|
+
- **`video_only`**: videos are awareness-driven and **do not show the offer on screen**. The copy has to stand up with no offer visible: intrigue, the restaurant, the experience.
|
|
106
|
+
- **`mixed`**: the hard case. Meta shows some viewers a video with no offer and others a static with the offer front and centre. The copy must read correctly **both** ways, which usually means naming the offer in words rather than gesturing at it ("free matcha with any banh mi", not "check out the deal above").
|
|
109
107
|
|
|
110
108
|
Copy that only makes sense next to a visible offer, running as `mixed`, will quietly underperform for half the audience.
|
|
111
109
|
|
|
112
110
|
## Video-led campaigns: the one thing you can't do
|
|
113
111
|
|
|
114
|
-
The in-app generator **feeds the video assets to the model as multimodal input** and mines them for quotes, moments and on-screen specifics that end up in the copy. **You cannot watch
|
|
112
|
+
The in-app generator **feeds the video assets to the model as multimodal input** and mines them for quotes, moments and on-screen specifics that end up in the copy. **You cannot watch the video through these tools.**
|
|
115
113
|
|
|
116
|
-
So for a `video_only` or `mixed` campaign, either work from a description or transcript the user gives you
|
|
114
|
+
So for a `video_only` or `mixed` campaign, either work from a description or transcript the user gives you (saying plainly that's what you're working from), or write what you can from the landing page and tell the user the in-app dialog will do better here, because it can see the footage. Don't quietly produce video-campaign copy that never references the video and present it as equivalent. It isn't.
|
|
117
115
|
|
|
118
116
|
## Saving it
|
|
119
117
|
|
|
120
|
-
One `updateCampaign` call writes `adCopy`.
|
|
118
|
+
One `updateCampaign` call writes `adCopy`. Read `updateCampaign`'s input schema for the fields: alongside the headlines and primary texts it wants the landing page URL, the creative mix, a timestamp, and optional indices for the variation you're recommending.
|
|
121
119
|
|
|
122
120
|
**The one thing the schema won't tell you: `update.adCopy` replaces the whole object rather than merging into it.** Read the campaign with `getCampaign` first and send back everything you want kept, not just what changed.
|
|
123
121
|
|
|
124
122
|
## Publishing
|
|
125
123
|
|
|
126
|
-
**
|
|
124
|
+
**Drivable through the tools; read `ads.md`.** The loop is `listAdTemplates` → gather variables → `planAds` → `publishAds` (with its effects) → `getJob` → `setAdCampaignStatus`, and that file carries the ordering, the idempotency-key discipline, and the effect declarations that make the publish self-bookkeeping.
|
|
127
125
|
|
|
128
126
|
---
|
|
129
127
|
|
|
@@ -1,50 +1,62 @@
|
|
|
1
1
|
# Publishing and steering Meta ads
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Publishing is drivable end to end through the tools: resolve a template, plan, publish, activate. The planner is the only door in. **Never hand-assemble Meta campaign parameters**; `publishAds` re-derives everything from the template variables and refuses anything else.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
For the *words* in the ads, read the copywriting file for your audience first — `ad-copy-guest.md` for guest-facing offer ads, `ad-copy-creator.md` for creator recruitment. Copywriting is its own discipline with its own failure modes.
|
|
5
|
+
For the *words* in the ads, read the copywriting file for your audience first: `ad-copy-guest.md` for guest-facing offer ads, `ad-copy-creator.md` for creator recruitment. Copywriting is its own discipline with its own failure modes.
|
|
8
6
|
|
|
9
7
|
### The model: template → plan → publish → activate
|
|
10
8
|
|
|
11
|
-
- A **template** is a server-owned recipe. `listAdTemplates` returns each one with its variables, budget range, and which plan paths may be overridden. Each variable that names a `producedBy` tool is telling you exactly where its value comes from
|
|
9
|
+
- A **template** is a server-owned recipe. `listAdTemplates` returns each one with its variables, budget range, and which plan paths may be overridden. Each variable that names a `producedBy` tool is telling you exactly where its value comes from. Treat that as the shopping list.
|
|
12
10
|
- **`planAds`** resolves template + variables into the exact tree of campaigns, ad sets and ads that would be created. It creates nothing on Meta and changes no Feastalytics data. It returns the tree, a `planHash`, the fully defaulted variables, and validation issues.
|
|
13
|
-
- **`publishAds`** takes those variables, overrides and hash back *unchanged*, re-derives the tree server-side, and refuses on a mismatch
|
|
14
|
-
- **`setAdCampaignStatus`** `ACTIVE` starts a campaign Feastalytics published, cascading to every ad set and ad. This is the moment real money starts moving
|
|
11
|
+
- **`publishAds`** takes those variables, overrides and hash back *unchanged*, re-derives the tree server-side, and refuses on a mismatch, so a stale plan fails loudly instead of publishing something the human never saw. Everything is created **paused**.
|
|
12
|
+
- **`setAdCampaignStatus`** `ACTIVE` starts a campaign Feastalytics published, cascading to every ad set and ad. This is the moment real money starts moving: explicit user confirmation first, every time.
|
|
15
13
|
|
|
16
14
|
### The loop
|
|
17
15
|
|
|
18
|
-
1. `listAdTemplates
|
|
19
|
-
2. Gather variables with those tools: `ads_get_ad_accounts`, `ads_get_user_pages`, `ads_get_ig_accounts`, `listCreatives`, `getCampaign`, etc. Prefer a Page with `usedByOrganization: true
|
|
20
|
-
|
|
16
|
+
1. `listAdTemplates`: pick the template, read each variable's `producedBy`.
|
|
17
|
+
2. Gather variables with those tools: `ads_get_ad_accounts`, `ads_get_user_pages`, `ads_get_ig_accounts`, `ads_get_custom_audiences`, `listIgMedia`, `listCreatives`, `getCampaign`, etc. Prefer a Page with `usedByOrganization: true`; the token reaches other businesses' Pages and nothing stops you publishing from the wrong one.
|
|
18
|
+
- `ads_get_custom_audiences` `{ "adAccountId": "..." }` supplies the `customAudienceIds` and `excludedCustomAudienceIds` variables. Skip any audience with `isReadyForUse: false` (Meta will not deliver to it, so an ad set targeting it reaches nobody), and remember audience ids belong to one ad account and are rejected by another.
|
|
19
|
+
- `listIgMedia` `{ "pageId": "..." }` lists up to 50 recent posts (id, caption, thumbnail, mediaType, permalink, timestamp) from the Instagram business account linked to that Page, plus the `instagramUserId` that goes on an `igMedia` creative reference. A Page with no linked Instagram business account returns `instagramAccount: null`.
|
|
20
|
+
- `ads_get_user_pages` shows each Page's linked Instagram business account when it has one; `ads_get_ig_accounts` `{ "pageId": "..." }` is the full list of identities that Page can run ads as, for the `instagramAccountId` variable (`kind: "pageBacked"` is the identity Meta creates for a Page with no Instagram account, and is valid too).
|
|
21
|
+
- `ads_get_custom_audiences`: an audience's size bounds are approximate and go stale while Meta is updating it. Nothing says how fresh the underlying list is: a customer-list audience is a snapshot of the last upload, and `timeUpdated` is when that happened.
|
|
22
|
+
3. `planAds`: fix every issue with severity `error` and re-plan. Summarize the resulting tree (campaign name, budget, targeting, ad count) for the user before going further; the plan is the thing they're approving.
|
|
21
23
|
4. `publishAds` with the returned `variables`, `overrides` and `planHash` unchanged, plus:
|
|
22
|
-
- `confirm: true
|
|
23
|
-
- an `idempotencyKey` you generate. Reuse the same key when retrying the *same* publish
|
|
24
|
-
- `effects
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
- `confirm: true`. The schema demands it; this is the only tool with a schema-level confirm.
|
|
25
|
+
- an `idempotencyKey` you generate. Reuse the same key when retrying the *same* publish: a duplicate key is refused with the earlier job's id, so read that job with `getJob` instead of publishing twice. Never reuse one for a new publish.
|
|
26
|
+
- `effects`: see below.
|
|
27
|
+
|
|
28
|
+
If the organization resolves differently than when you planned (a `PLAN_STALE` refusal), re-run `planAds` and show the human what changed before publishing again.
|
|
29
|
+
5. Poll `getJob` with the returned `jobId` + `jobType` until `COMPLETED` or `FAILED`. `{ "job": null }` means not landed yet, so keep polling. **Read the job's effect outcomes.** Each declared effect reports `done`, `skipped` or `error` with a human-readable detail, and effect failures do not fail the job (the ads already exist by then), so this is the only place you find out.
|
|
30
|
+
6. `setAdCampaignStatus` to go live, after the user says go. Check the preflight counts in the response. For guest-facing ads linked to a Feast campaign, run the campaign readiness check first (`getTaskboard` with the campaign scope, see "Before a campaign goes live" in `campaigns.md`). Ads that send traffic to a funnel with no automations pay for sign ups that never receive their offer.
|
|
27
31
|
|
|
28
32
|
### Effects: the write-back is declared, not called afterwards
|
|
29
33
|
|
|
30
|
-
Bookkeeping that must happen once the ads exist travels *inside* the publish as `effects`, and the worker runs it as part of the job
|
|
34
|
+
Bookkeeping that must happen once the ads exist travels *inside* the publish as `effects`, and the worker runs it as part of the job, because a follow-up call you're supposed to remember is a follow-up call that gets missed, silently. Both effects below are required for their template: `publishAds` refuses the publish when one is missing rather than skipping it.
|
|
31
35
|
|
|
32
|
-
- **A recruitment publish must declare `linkRecruitmentOffer`** with its `offerId` and `creativeIds
|
|
33
|
-
-
|
|
36
|
+
- **A recruitment publish must declare `linkRecruitmentOffer`** with its `offerId` and `creativeIds`. The effect stamps the creatives as published, stamps the offer, links the location's creator board that the monthly sourcing cap and the dashboard's spend both read, and texts the program's approver that sourcing is live.
|
|
37
|
+
- **A `directOffer` publish must declare `linkFeastCampaign`** with the `campaignId` it runs for. The effect records the published Meta campaign onto that Feast campaign, which is what puts its spend on the campaign's ads panel and KPIs.
|
|
34
38
|
|
|
35
|
-
An effect that reports `error` in the job is a case for the dashboard, not for patching around
|
|
39
|
+
An effect that reports `error` in the job is a case for the dashboard, not for patching around. Surface it to the user.
|
|
36
40
|
|
|
37
41
|
### Which template
|
|
38
42
|
|
|
39
|
-
- **`directOffer
|
|
40
|
-
- **`recruitment
|
|
41
|
-
- **`addAds
|
|
43
|
+
- **`directOffer`**: guest-facing offer ads for a campaign. Requires the `linkFeastCampaign` effect. Copy rules: `ad-copy-guest.md`.
|
|
44
|
+
- **`recruitment`**: creator-recruitment ads. An always-on trickle with an enforced budget floor and ceiling. Requires the `linkRecruitmentOffer` effect. Creatives come from `createRecruitmentCreatives` → `listCreatives` (pass each creative's `imageKey` as a `libraryAsset` reference); copy rules: `ad-copy-creator.md`; program context: `creators.md`.
|
|
45
|
+
- **`addAds`**: add fresh creatives to an ad set that is already running. Copy the settings the new ads must match from an existing ad; Meta will happily publish a mismatched ad (pointing somewhere different from its siblings) rather than reject it, so copy the values, never invent them. Call `ads_get_ad_entities` with `level: "ad"` and that `adSetId`, skip ads whose `effectiveStatus` is `DELETED` or `ARCHIVED`, take the first one left, and read from its creative:
|
|
46
|
+
- `pageId`: `objectStorySpec.page_id`
|
|
47
|
+
- `instagramAccountId`: `objectStorySpec.instagram_actor_id`, or `instagram_user_id` when that is absent (both spellings occur)
|
|
48
|
+
- `urlTags`: `urlTags`
|
|
49
|
+
- `headline`, `primaryText`, `landingUrl`: from whichever of three shapes the ad uses. `assetFeedSpec`, when present, wins: `titles[0].text`, `bodies[0].text`, `link_urls[0].website_url`. Otherwise `objectStorySpec.link_data` for an image ad: `name`, `message`, `link`. Otherwise `objectStorySpec.video_data` for a video ad: `title`, `message`, `call_to_action.value.link`.
|
|
42
50
|
|
|
43
51
|
### Reading and steering what's live
|
|
44
52
|
|
|
45
|
-
- `ads_get_ad_entities
|
|
46
|
-
- `ads_update_entity
|
|
47
|
-
- `ads_activate_entity
|
|
48
|
-
- `ads_get_datasets` / `ads_create_dataset
|
|
53
|
+
- `ads_get_ad_entities`: read campaigns/ad sets/ads on an account, creatives attached. The diagnostic read for everything below. `level` says what comes back; an id at the requested level fetches that one object, and an id from a level above lists that object's children: `campaignId` with `level: "adSet"` returns that campaign's ad sets, and with `level: "ad"` every ad in it across all its ad sets. Where several ids apply, the narrowest wins.
|
|
54
|
+
- `ads_update_entity`: rename, re-budget, or pause; moving a daily budget is how you scale a winner or throttle a loser. Budgets are integer cents and **replace** the current value; read first, confirm the number with the human. Creatives are immutable at Meta, so new copy or media means a new ad (the `addAds` template).
|
|
55
|
+
- `ads_activate_entity`: go-live for structures Feastalytics did *not* publish. No cascade: activate top-down and check `willDeliver`; a child under a paused parent is live in name only. For campaigns Feastalytics published, `setAdCampaignStatus` cascades and is the right tool: those are published paused at all three levels, so activating the campaign alone would spend nothing.
|
|
56
|
+
- `ads_get_datasets` / `ads_create_dataset`: pixel checks and creation. The pixel a campaign should optimise against is the one its funnel actually fires (from the layout config), not whichever pixel looks plausible on the account. After creating one, write its id back with `updateBrandIdentity`; creation alone connects nothing. That layout config value is what makes the funnel fire the pixel and what the onboarding task reads.
|
|
57
|
+
|
|
58
|
+
### Reference scripts for video ads
|
|
59
|
+
|
|
60
|
+
`listReferenceScripts` returns the reference ad scripts Content Studio offers as Concept presets for Bevyl videos, each distilled from an ad that performed: `description` (what the video shows), `videoUrl` (a public MP4 preview), `structure` (the ordered beats), `keyPhrases` (lines to adapt, with `<placeholders>` filled from the campaign's facts) and `concept` (the exact text Content Studio sends to Bevyl). Copy the structure and pacing, not the words. The list is the same for every organization.
|
|
49
61
|
|
|
50
|
-
> **Not exposed:** ad-copy generation (write it yourself
|
|
62
|
+
> **Not exposed:** ad-copy generation (write it yourself: `ad-copy-guest.md` / `ad-copy-creator.md`), creative *content* editing on Meta (immutable there), and publishing creator content as partnership ads.
|
|
@@ -1,43 +1,45 @@
|
|
|
1
1
|
# Automations
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
**Fully authorable** (create / edit / delete / dry-run). This is the richest workflow — the ordering is simpler than the app's, but the domain rules below are what separate a professional flow from a carrier-blocked mess. Follow them when creating, and use them as a checklist when reviewing.
|
|
3
|
+
**Fully authorable** (create / edit / delete / dry-run). This is the richest workflow. The ordering is simpler than the app's, but the domain rules below are what separate a professional flow from a carrier-blocked mess. Follow them when creating, and use them as a checklist when reviewing.
|
|
6
4
|
|
|
7
5
|
### The model: automations live inside flows
|
|
8
6
|
|
|
9
7
|
- An **automation** is one trigger → (conditions) → action unit (e.g. "on checkout, send a text").
|
|
10
|
-
- A **flow** groups automations by a shared trigger, and belongs to *either* a campaign *or* the members program
|
|
11
|
-
- **The rule that matters most: every automation needs a `flowId`.
|
|
8
|
+
- A **flow** groups automations by a shared trigger, and belongs to *either* a campaign *or* the members program, never both.
|
|
9
|
+
- **The rule that matters most: every automation needs a `flowId`. A create op without one throws.** So you must resolve the flow *before* creating. Never invent a flowId.
|
|
12
10
|
|
|
13
|
-
### The
|
|
11
|
+
### The loop: draft → stage → share → save
|
|
14
12
|
|
|
15
|
-
Automations have a staging tier, and it is the default path. Changes accumulate on a **draft**
|
|
13
|
+
Automations have a staging tier, and it is the default path. Changes accumulate on a **draft** (an off-prod overlay) until someone explicitly promotes them. A draft carries a link that renders the change as a text-message thread with the edits highlighted, which is what you hand a human to look at before anything reaches a real guest.
|
|
16
14
|
|
|
17
|
-
1. `listAutomationFlows
|
|
15
|
+
1. `listAutomationFlows`: find an existing flow. Pass `{ "campaignId": "<id>" }` for a campaign's flows, or `{ "scope": "membersProgram" }` for members-program flows. Reuse a matching flow when one fits.
|
|
18
16
|
2. If none fits, `createAutomationFlow` to make one. If the campaign/members-program has **no flows at all**, strongly prefer `applyAutomationTemplate` (then customize) over building from scratch. Only apply a template when there are no existing flows.
|
|
19
|
-
3. `listAutomations` with `{ "flowId": "<id>" }` to see the automations already in that flow before editing (omit the input to list every automation in the org). **Do this immediately before every `update` op you stage, not just once at the start of the session.** An `update` replaces an automation's entire `actions` array
|
|
20
|
-
4. `createAutomationDraft` with a short `title` describing the change in the user's terms ("Shorten the day-3 nudge")
|
|
21
|
-
5. `stageAutomationEdits` with `{ "draftId": "<id>", "operations": [...] }`.
|
|
22
|
-
- `{ "type": "create", "automation": {
|
|
17
|
+
3. `listAutomations` with `{ "flowId": "<id>" }` to see the automations already in that flow before editing (omit the input to list every automation in the org). **Do this immediately before every `update` op you stage, not just once at the start of the session.** An `update` replaces an automation's entire `actions` array; it does not merge one action in. If you reconstruct `actions` from an earlier tool result or from what you remember discussing, rather than the automation's current live state, you silently drop whatever isn't in your reconstruction (a reward grant, a task action, anything not under discussion in that turn). This is true even a few messages later in the same conversation, once the user has asked for a second or third change to the same automation.
|
|
18
|
+
4. `createAutomationDraft` with a short `title` describing the change in the user's terms ("Shorten the day-3 nudge"). That title is what the reviewer sees. Keep the returned `draftId`; **there is no way to list drafts, so if you lose it the draft is unreachable.**
|
|
19
|
+
5. `stageAutomationEdits` with `{ "draftId": "<id>", "operations": [...] }`. Each operation is the named type `AutomationOperation` (the same ones `batchEditAutomations` takes), and its automation fields use the named types `UserCondition`, `AutomationTrigger`, `AutomationAction`, `AutomationSendTime` and `AutomationVariant`. Fetch those shapes once before writing your first op:
|
|
20
|
+
- `{ "type": "create", "automation": { "automationId": "<new-uuid>", "flowId": "<id>", "title": "...", "isActive": true, "triggers": [...], "conditions": [...], "actions": [...], "time": {...} } }`: generate a fresh UUID for `automationId` (the key is `automationId`, not `id`). `automationId`, `isActive`, `triggers`, `conditions`, `actions` and `time` are required; set the `flowId` and a descriptive title too. Create ops require the flowId.
|
|
23
21
|
- `{ "type": "update", "automationId": "<id>", "automation": { ...changed fields... } }`
|
|
24
|
-
- `{ "type": "delete", "automationId": "<id>" }
|
|
22
|
+
- `{ "type": "delete", "automationId": "<id>" }`: blocked at save time if the automation already has sends.
|
|
23
|
+
- `{ "type": "createVariant", "automationId": "<id>", "variantId": "<new-uuid>", "variant": <AutomationVariant> }` and `{ "type": "updateVariant", "automationId": "<id>", "variantId": "<id>", "variant": { "triggers"?, "conditions"?, "actions"?, "time"? } }` add or change an A/B variant of an automation.
|
|
25
24
|
Call it repeatedly to build a change up; ops append in order.
|
|
26
|
-
6. `simulateAutomations` with `{ "flowId": "<id>", "edits": <the draft's operations> }
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
6. `simulateAutomations` with `{ "flowId": "<id>", "edits": <the draft's operations> }`: dry-run against a synthetic event timeline with **no real sends** and confirm the right automations fire. If the simulation surprises you, stage a correction rather than promoting and patching live.
|
|
26
|
+
- **First call:** pass `flowId` and omit `events`. The server seeds a timeline from that flow's triggers (a `viewCampaign` event when the flow has a campaign, then the first eligible trigger event 15 seconds later) and returns it as `eventsUsed`.
|
|
27
|
+
- **Later calls:** to test another day or continue the guest's journey, change `at` on those events or append more, and pass the array back as `events`. Each event is `{ "type": "...", "at": "<ISO 8601 timestamp>" }` plus a few optional fields per type; the server fills in the guest, organization and campaign.
|
|
28
|
+
- The result is `scheduledTexts` (what would be sent, and when) plus `eventsUsed`.
|
|
29
|
+
7. **Give the user the `previewUrls` from the draft** and let them look before you promote. Each entry is one flow's before/after view. Don't promote unprompted work on the user's behalf; staging exists so a human sees the change first.
|
|
30
|
+
8. `saveAutomationEdits` with `{ "draftId": "<id>" }`: this is the write to production. It refuses if any automation the draft touches was changed by someone else since you staged, naming which; re-stage against the current state rather than retrying.
|
|
29
31
|
|
|
30
|
-
`discardAutomationDraft` throws a draft away without promoting. `getAutomationDraft` re-reads one by id. Drafts expire after 14 days.
|
|
32
|
+
`discardAutomationDraft` throws a draft away without promoting. `getAutomationDraft` re-reads one by id, including `resultingAutomations` (each touched automation as it will look after the save); check it for fields that changed or disappeared. Drafts expire after 14 days.
|
|
31
33
|
|
|
32
|
-
**`batchEditAutomations`
|
|
34
|
+
**`batchEditAutomations` writes straight to production in one call.** Reach for it only when the user explicitly wants an immediate live change and has said so, not as a shortcut past the review step.
|
|
33
35
|
|
|
34
|
-
`updateAutomationFlow` renames/retitles a flow; `deleteAutomationFlow` removes a flow and its automations (blocked at ≥20 sends
|
|
36
|
+
`updateAutomationFlow` renames/retitles a flow; `deleteAutomationFlow` removes a flow and its automations (blocked at ≥20 sends; turn it off instead).
|
|
35
37
|
|
|
36
|
-
> **Not exposed:** actually *firing* an automation at a live member (the app's "run") is intentionally not a
|
|
38
|
+
> **Not exposed:** actually *firing* an automation at a live member (the app's "run") is intentionally not a tool, because it sends a real SMS. Use `simulateAutomations` for verification; real sends happen in the app.
|
|
37
39
|
|
|
38
40
|
### Choosing the trigger
|
|
39
41
|
|
|
40
|
-
- **Campaign flows: prefer `viewCampaign` over `signUp`.** A guest viewing the campaign page is the natural entry point
|
|
42
|
+
- **Campaign flows: prefer `viewCampaign` over `signUp`.** A guest viewing the campaign page is the natural entry point: it captures new sign-ups *and* returning guests. Use `signUp` only for members-program welcome flows or a fire-once-at-registration moment.
|
|
41
43
|
- **`offerExpiration` is rarely a *flow* trigger.** Use it on an individual automation inside an expiration nurture chain, not as a standalone flow's trigger type.
|
|
42
44
|
|
|
43
45
|
### Conditions: the nested event/occur shape
|
|
@@ -50,17 +52,17 @@ Event conditions nest the event and its timing. The `occur` object uses `match`
|
|
|
50
52
|
"occur": { "match": "GTE", "duration": 86400000 } } }
|
|
51
53
|
```
|
|
52
54
|
|
|
53
|
-
- **Positive duration = past** (event already happened)
|
|
54
|
-
- **Negative duration = future
|
|
55
|
+
- **Positive duration = past** (event already happened), for `signUp`, `addPass`, `visit`, `offerRedemption`, etc.
|
|
56
|
+
- **Negative duration = future**, only for `offerExpiration` (e.g. "expires within 2 hours" → `LTE`, `-7200000`).
|
|
55
57
|
- `EQ` matches within the whole increment (day/week/hour).
|
|
56
58
|
|
|
57
59
|
### Send times & prime texting windows
|
|
58
60
|
|
|
59
61
|
Send times are `immediate`, `relativeDelay` (`delayMs` after the trigger), or `absoluteDelay` (`utcHour`/`utcMinute`, optional `utcDayOfWeek` or `utcDay`). Think in the **org's local timezone**, then convert to UTC.
|
|
60
62
|
|
|
61
|
-
**Always schedule inside a prime window
|
|
62
|
-
- Morning: **8:00
|
|
63
|
-
- Afternoon: **4:00
|
|
63
|
+
**Always schedule inside a prime window. Never arbitrary times, never before 8 AM or after 9 PM:**
|
|
64
|
+
- Morning: **8:00 to 11:30 AM** (org timezone)
|
|
65
|
+
- Afternoon: **4:00 to 6:00 PM** (org timezone)
|
|
64
66
|
|
|
65
67
|
Which window depends on meal service: breakfast/lunch-only → all morning; dinner-only → mostly afternoon, at most one morning; both → roughly 50/50. Determine meal service from existing automations/funnel/settings, or ask the user. **Vary the minutes** so no two automations in a flow share a send time (e.g. 9:03, 9:17, 4:22). Recommend specific times rather than asking.
|
|
66
68
|
|
|
@@ -77,22 +79,22 @@ Chain with the `receiveAutomation` trigger (automation B fires because A was rec
|
|
|
77
79
|
When an automation's trigger is `receiveAutomation`, ask the user whether it should apply only going forward or also to everyone who already received the upstream automation:
|
|
78
80
|
|
|
79
81
|
- Default `applyToHistorical: false` (going forward only).
|
|
80
|
-
- For past recipients, set `applyToHistorical: true` as a **top-level sibling** of `automation` on the create/update op (never inside a trigger
|
|
81
|
-
- Before confirming, call `countParentAutomationRecipients` and tell the user the audience size; warn if > 1000. Only backfill after explicit confirmation.
|
|
82
|
+
- For past recipients, set `applyToHistorical: true` as a **top-level sibling** of `automation` on the create/update op (never inside a trigger). It isn't stored; it only enqueues a one-shot backfill on that save.
|
|
83
|
+
- Before confirming, call `countParentAutomationRecipients` with `{ "parentAutomationId": "<id>" }` and tell the user the audience size; warn if > 1000. Only backfill after explicit confirmation.
|
|
82
84
|
|
|
83
85
|
### Rewards inside automations
|
|
84
86
|
|
|
85
|
-
- **Checkout auto-creates the reward.** For a checkout-triggered automation, do NOT add an `awardReward` action
|
|
87
|
+
- **Checkout auto-creates the reward.** For a checkout-triggered automation, do NOT add an `awardReward` action; the reward is already granted. Checkout flows only send texts. Use `awardReward` for non-checkout flows (visit milestones, sign-up rewards).
|
|
86
88
|
- **Members-program `awardReward` defaults to a 30-day expiration**: `{ "type": "relative", "relative": { "offsetMs": 2592000000 } }`. Mention it in your summary; omit only if the user says the reward shouldn't expire. This does not apply to campaign offers.
|
|
87
89
|
|
|
88
90
|
### Text-content best practices (rules when creating, checklist when reviewing)
|
|
89
91
|
|
|
90
|
-
1. **Descriptive names
|
|
91
|
-
2. **Lead with the pass link
|
|
92
|
+
1. **Descriptive names**: "Day 2: Visit Reminder with Pass Link", not "Reminder 1".
|
|
93
|
+
2. **Lead with the pass link**: the first post-signup text MUST include it ("add your pass: {{pass link}}").
|
|
92
94
|
3. **Always `https://`** on every link (carriers block bare/protocol-less links).
|
|
93
|
-
4. **Mobile Google Maps links only
|
|
94
|
-
5. **Correct reservation links
|
|
95
|
+
4. **Mobile Google Maps links only**: `https://maps.app.goo.gl/...`, never desktop `maps.google.com`.
|
|
96
|
+
5. **Correct reservation links**: `https://{subdomain}.feastalytics.com/i/{shorthand}/reservation` using the *current* campaign's shorthand (from `listCampaigns`) and a valid subdomain. Never reuse another campaign's link.
|
|
95
97
|
6. **Personalize** with `{{firstName}}`; **vary** tone/wording across automations; **re-share** useful info (pass link, hours, maps, reservation) in reminders; keep **empty lines** between blocks for readability.
|
|
96
|
-
7. **Align offer expirations with open hours
|
|
98
|
+
7. **Align offer expirations with open hours**: never expire an offer while the restaurant is closed.
|
|
97
99
|
|
|
98
100
|
---
|
|
@@ -1,43 +1,75 @@
|
|
|
1
1
|
# Campaigns and offers
|
|
2
2
|
|
|
3
|
-
> Part of the Feastalytics CLI workflows. Confirm a tool exists with `feast tools` before relying on it, and get its exact fields from `feast describe <tool>` — this file gives the *meaning* and *ordering* the schema can't.
|
|
4
|
-
|
|
5
|
-
|
|
6
3
|
## Creating a campaign
|
|
7
4
|
|
|
8
|
-
Fully doable
|
|
5
|
+
Fully doable through the tools. The server does the heavy lifting (id generation, default config, the funnel prerequisite); you sequence the calls.
|
|
9
6
|
|
|
10
|
-
1. `getOrganization
|
|
11
|
-
2. `createCampaign` with `{ "campaign": { "name": "...", "
|
|
12
|
-
3. `populateCampaign` with
|
|
13
|
-
- `"
|
|
14
|
-
- `"
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
- `
|
|
7
|
+
1. `getOrganization`: read the org to get valid **referrers** (subdomains, from `subdomains2[].subdomain`) and location ids.
|
|
8
|
+
2. `createCampaign` with `{ "campaign": { "name": "..." } }`. The `campaign` object takes only `name` (required), `description`, `referrers` (subdomains) and `imageUrl` (`{ "type": "s3", "key": "..." }` or `{ "type": "url", "url": "..." }`). Keep the returned campaign **id** (a UUID). The tool creates the campaign mid-setup (`isCreating: true`), hidden from the dashboard until step 3 runs.
|
|
9
|
+
3. `populateCampaign` with `{ "campaignId": "..." }` finishes setup and makes the campaign visible. It only works on a campaign in creation mode. Optional fields:
|
|
10
|
+
- `simpleRewardsConfig`: `{ "promotionName": "...", "imageKey"?: "...", "imageUrl"?: "..." }`. Creates one promotion with no price. The image is either an uploaded `imageKey` or a public `imageUrl`.
|
|
11
|
+
- `prepayConfig`: `{ "promotionName": "...", "price": 12, "imageKey": "..." }`, all three required. Creates one promotion with that `price` and `canPrePay: true`. There is no URL form, so upload the image first.
|
|
12
|
+
- Pass at most one of the two; with neither, the campaign has no offer (the `OFFER` feature is turned off).
|
|
13
|
+
- `contentStrategy`: `"self"` (drops creator sourcing), `"creator"`, or `"tracking_only"` (turns off every feature and **publishes the campaign immediately**).
|
|
14
|
+
- To get an `imageKey`: `getMediaUploadUrl` with `{ "scope": "organizationFilePublic", "fileName": "offer.png", "fileType": "image/png" }`, PUT the file bytes to the returned `presignedUrl`, and pass the returned `key`. The image becomes the pass strip, so use a PNG.
|
|
15
|
+
- `populateCampaign` builds no funnel screens. The funnel comes from step 4.
|
|
16
|
+
4. (optional) the funnel, the **acquisition** half: the funnel screens a guest sees. Same list → pick → apply shape as automations:
|
|
17
|
+
- `listFunnelTemplates` with the `campaignId`: the template catalog with per-campaign `eligible`/`ineligibleReason`, a `recommended` id, and each template's guest `journey`. Read this before applying; never guess a template id.
|
|
18
18
|
- Pick by what the guest should experience, not by whether the offer has a price:
|
|
19
|
-
- `offer-basic
|
|
20
|
-
- `offer-prepay` / `offer-direct-prepay
|
|
21
|
-
- `reservation-offer-basic` / `reservation-offer-prepay` / `reservation-offer-direct-prepay` / `reservation-only
|
|
19
|
+
- `offer-basic`: Sign Up goes straight to the offer wallet. **No payment step.** The template for any offer redeemed in person, priced or not.
|
|
20
|
+
- `offer-prepay` / `offer-direct-prepay`: a Stripe payment screen is part of the funnel (after Sign Up for `offer-prepay`, straight from the landing page for `offer-direct-prepay`). Only eligible when the promotion has `canPrePay: true` **and** a `price`; anything else is rejected with `PRECONDITION_FAILED`.
|
|
21
|
+
- `reservation-offer-basic` / `reservation-offer-prepay` / `reservation-offer-direct-prepay` / `reservation-only`: the reservation variants of the same split.
|
|
22
22
|
- `applyFunnelTemplate` with `{ "campaignId": ..., "templateId": ... }`. Requires a fresh campaign whose funnel is unset; resolves the referrer from the campaign.
|
|
23
|
-
- The promotion's `canPrePay` flag does **not** change what a template builds
|
|
24
|
-
- After applying, confirm with `listFunnelScreens` that the journey matches intent
|
|
25
|
-
5.
|
|
23
|
+
- The promotion's `canPrePay` flag does **not** change what a template builds; it only gates eligibility. A "no prepay" request means `offer-basic` (or another no-payment template), full stop.
|
|
24
|
+
- After applying, confirm with `listFunnelScreens` that the journey matches intent. For a no-prepay offer there must be no `payment` screen.
|
|
25
|
+
5. The automations, the **retention** half: the follow-up messaging. **Required whenever the funnel has a sign up form or a checkout**, which covers every `offer-*` and `reservation-offer-*` template. Read `automations.md` before this step. `applyAutomationTemplate` provisions the campaign's flow *and* its automations in one call, so you don't hand-build a flow for this path. Preview options first with `listAutomationTemplates` / `listTemplateAutomations`, and check the template's texts against what the offer promises (an expiring-offer template contradicts a "no expiration" offer).
|
|
26
|
+
|
|
27
|
+
Steps 4 and 5 are the two halves of a working campaign: the funnel (what the guest sees) and the automations (what happens after they sign up). They are not independent. Outside checkout, the guest's reward is granted by an `awardReward` action inside a sign up automation, so a funnel with no automations signs guests up, hands them a pass with nothing on it, and sends no text. A campaign is not finished until both halves are in place, even when the user only asked about the ad or the landing page. If you stop before the automations, say so plainly in your summary as an open item that blocks going live.
|
|
28
|
+
|
|
29
|
+
### Before a campaign goes live
|
|
30
|
+
|
|
31
|
+
Setting `isPublished: true` with `updateCampaign` puts the campaign in front of guests, and so does switching on its Meta ads. The server checks nothing on either path. So before either one:
|
|
32
|
+
|
|
33
|
+
1. Run `getTaskboard` with `{ "scope": { "type": "campaign", "campaign": { "campaignId": "..." } } }`.
|
|
34
|
+
2. Any `issue` entry with severity `error` blocks going live. The one that matters most is `campaign-automations-missing`: guests would sign up and get nothing. Fix it (step 5), or stop and tell the user exactly what is missing and what guests would experience. Do not publish around it.
|
|
35
|
+
3. Tell the user about `warning` entries before going live; they can choose to proceed.
|
|
36
|
+
|
|
37
|
+
A short approval like "save it" or "looks good" is not a go-live instruction when the readiness check has not passed. Report what is missing first.
|
|
38
|
+
|
|
39
|
+
**Reading a campaign back:** `getCampaign` returns the full config for one campaign (funnel/offer config, referrers, status); `listCampaigns` is the summary list; performance is covered in "Reading a campaign's performance" below. Read with `getCampaign` before any `updateCampaign`.
|
|
40
|
+
|
|
41
|
+
**Updating a campaign:** `updateCampaign` takes `{ "campaignId": "...", "update": { ... } }`. The update is merged one level deep: each top-level field you pass replaces the stored value whole. `promotions` is an array, so pass the full list with your change applied, never just the one promotion you edited. Other things to know:
|
|
42
|
+
- `imageUrl` (the offer image) whose url or key contains the word `placeholder` counts as unset, and onboarding keeps asking for an image.
|
|
43
|
+
- Saving a recurring promotion with a `price` creates a live Stripe product and monthly price in the connected account (a changed price creates a new price and archives the old one). After that, the campaign's Stripe account cannot be switched until those promotions are archived; the server rejects the change and says so.
|
|
44
|
+
|
|
45
|
+
**Cloning:** `cloneCampaign` with `sourceCampaignId`, `newCampaignName`, and a `referrer` (subdomain) duplicates funnel + automations + offers and returns a `newCampaignId`. **Gotcha:** the cloned automations contain the *source* campaign's reservation links. After cloning, review the new campaign's automations and rewrite any reservation link to the new campaign's shorthand. The format is `https://{subdomain}.feastalytics.com/i/{new-shorthand}/reservation`.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Reading a campaign's performance
|
|
50
|
+
|
|
51
|
+
Three tools, all keyed by the Feast campaign `id` from `listCampaigns` (a UUID), never the Meta campaign id nested inside the campaign. They share one set of camelCase metric ids (`signupRate`, `thumbStopRatio`, `uniqueClickthrough`, `revenue`, ...) and one set of units.
|
|
26
52
|
|
|
27
|
-
|
|
53
|
+
**Units.** `count`; `percent` as 0 to 100 (not 0 to 1); `usd` in dollars (not cents); `days`; `multiple` for ROAS (2 means 2x). In the breakdown: sessions, visitors, signups, impressions and reach are counts; every `*Rate`, `thumbStopRatio`, `holdRate` and `uniqueClickthrough` are percents; spend, cpm, revenue, costPerSignup and revenuePerSignup are USD; averageTimeToShow is days from signup to first scan.
|
|
28
54
|
|
|
29
|
-
**
|
|
55
|
+
**Headline numbers: `getCampaignKpis`** with `{ "campaignId": "...", "start"?: ..., "end"?: ... }`. Pass both `start` and `end` for a date range; with either missing it covers all time. `"isPrimaryOnly": true` returns only the primary metrics. Returns one `{ id, type, value, unit }` row per metric, covering ad performance (spend, impressions, hook rate, hold rate, CTR, from synced Facebook data, so ROAS is revenue divided by spend), the funnel, automations and results. Rate metrics with a target band also carry `benchmark: { min, good, great }` in the same unit. A metric whose value would be zero is left out rather than returned as 0. So a missing spend row means no spend or no Facebook sync yet, never a confirmed $0.
|
|
30
56
|
|
|
31
|
-
**
|
|
57
|
+
**Grading: `getCampaignBenchmarks`** (no input) returns `{ id, label, unit, description, formula, benchmark }` for every metric id. Call it once and reuse it; it is the same for every campaign. Grade a value green at or above `good`, yellow at or above `min`, red below `min`; `great` is a stretch level (null for ROAS). `benchmark` is null when a metric has no target band. The bands are fleet percentiles (P25/P50/P75 of campaigns with over 500 visitors), rounded, not per organization; ROAS is anchored at 1x break-even.
|
|
32
58
|
|
|
33
|
-
|
|
59
|
+
**Where the numbers come from: `getCampaignBreakdown`** with `{ "refs": [...], "start": ..., "end": ... }`. `start`/`end` (both required) are the session window. It is a tree loaded a batch at a time: pass node refs, get back each node's metrics plus the refs of its children (identifiers only, no metrics), then pass those refs back in to go a level deeper. Up to 100 refs per call, and refs from different campaigns can be mixed.
|
|
60
|
+
- Start with `{ "type": "campaign", "campaign": { "campaignId": "..." } }`. It returns the campaign totals and its channel refs.
|
|
61
|
+
- The tree: campaign → channel (`facebook`, `influencer`, `tiktok`, `google`, `misc`, `referral`, `unknown`), then per channel: facebook → fbCampaign → fbAdset → fbAd; google → googleCampaign; tiktok → tiktokCampaign; misc → miscSource; referral → referrer; influencer → creator.
|
|
62
|
+
- A `null` id inside a ref is the "Unknown" bucket: sessions that could not be matched to a specific child.
|
|
63
|
+
- **Variants** split the campaign by pass rather than by session. The campaign node lists them in `details.campaign.variants` (empty when the campaign has none). Load one with `{ "type": "variant", "variant": { "campaignId": "...", "variantId": "..." } }`; `variantId: null` is the default variant. Variant nodes carry only signups, pass registration and show rate, time to show, revenue and revenue per signup.
|
|
64
|
+
- **Missing key vs null.** A metric key that is absent means the metric does not apply to that node (spend on a Google row, for example). `null` means it applies but could not be computed: no denominator, or Facebook was unreachable (see `details.facebook.error`).
|
|
65
|
+
- Facebook delivery metrics in the breakdown are read live from Meta, while `getCampaignKpis` reads synced Facebook data, so the two can differ.
|
|
34
66
|
|
|
35
67
|
---
|
|
36
68
|
|
|
37
69
|
## Offers and promotions
|
|
38
70
|
|
|
39
|
-
- A campaign's **promotions** are part of the campaign record: read them with `getCampaign`, edit them with `updateCampaign` (including a promotion's `staffInstructions`, and prices
|
|
40
|
-
- **Real menu data** for grounding any offer or promotion copy comes from `queryData` on `interface.catalogItem
|
|
71
|
+
- A campaign's **promotions** are part of the campaign record: read them with `getCampaign`, edit them with `updateCampaign` (including a promotion's `staffInstructions`, and prices, noting the Stripe-products warning in `updateCampaign`'s description).
|
|
72
|
+
- **Real menu data** for grounding any offer or promotion copy comes from `queryData` on `interface.catalogItem`: POS-agnostic, hierarchical via `parentId`/`catalogItemLink`.
|
|
41
73
|
- When you write guest-facing offer language anywhere, frame it as an "offer," never a "discount" or "deal."
|
|
42
74
|
|
|
43
75
|
---
|