@feastalytics/cli 0.1.16 → 0.1.18

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.
Files changed (29) hide show
  1. package/README.md +12 -3
  2. package/dist/cli.js +170 -232
  3. package/package.json +6 -3
  4. package/skills/feast/SKILL.md +131 -0
  5. package/{feast → skills/feast}/references/domains.md +9 -7
  6. package/{feast → skills/feast}/references/links.md +11 -11
  7. package/skills/feast/references/setup.md +80 -0
  8. package/skills/feast/references/workflows/ad-copy-creator.md +92 -0
  9. package/skills/feast/references/workflows/ad-copy-guest.md +128 -0
  10. package/skills/feast/references/workflows/ads.md +62 -0
  11. package/skills/feast/references/workflows/automations.md +100 -0
  12. package/skills/feast/references/workflows/campaigns.md +75 -0
  13. package/skills/feast/references/workflows/creators.md +96 -0
  14. package/skills/feast/references/workflows/funnels.md +30 -0
  15. package/skills/feast/references/workflows/guests.md +38 -0
  16. package/skills/feast/references/workflows/members-program.md +30 -0
  17. package/skills/feast/references/workflows/onboarding.md +33 -0
  18. package/feast/SKILL.md +0 -105
  19. package/feast/references/setup.md +0 -70
  20. package/feast/references/workflows/ad-copy-creator.md +0 -94
  21. package/feast/references/workflows/ad-copy-guest.md +0 -130
  22. package/feast/references/workflows/ads.md +0 -50
  23. package/feast/references/workflows/automations.md +0 -98
  24. package/feast/references/workflows/campaigns.md +0 -43
  25. package/feast/references/workflows/creators.md +0 -90
  26. package/feast/references/workflows/funnels.md +0 -34
  27. package/feast/references/workflows/guests.md +0 -22
  28. package/feast/references/workflows/members-program.md +0 -26
  29. package/feast/references/workflows/onboarding.md +0 -34
@@ -0,0 +1,62 @@
1
+ # Publishing and steering Meta ads
2
+
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
+
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.
6
+
7
+ ### The model: template → plan → publish → activate
8
+
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.
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.
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.
13
+
14
+ ### The loop
15
+
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.
23
+ 4. `publishAds` with the returned `variables`, `overrides` and `planHash` unchanged, plus:
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.
31
+
32
+ ### Effects: the write-back is declared, not called afterwards
33
+
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.
35
+
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.
38
+
39
+ An effect that reports `error` in the job is a case for the dashboard, not for patching around. Surface it to the user.
40
+
41
+ ### Which template
42
+
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`.
50
+
51
+ ### Reading and steering what's live
52
+
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.
61
+
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.
@@ -0,0 +1,100 @@
1
+ # Automations
2
+
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.
4
+
5
+ ### The model: automations live inside flows
6
+
7
+ - An **automation** is one trigger → (conditions) → action unit (e.g. "on checkout, send a text").
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.
10
+
11
+ ### The loop: draft → stage → share → save
12
+
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.
14
+
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.
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.
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.
21
+ - `{ "type": "update", "automationId": "<id>", "automation": { ...changed fields... } }`
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.
24
+ Call it repeatedly to build a change up; ops append in order.
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.
31
+
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.
33
+
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.
35
+
36
+ `updateAutomationFlow` renames/retitles a flow; `deleteAutomationFlow` removes a flow and its automations (blocked at ≥20 sends; turn it off instead).
37
+
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.
39
+
40
+ ### Choosing the trigger
41
+
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.
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.
44
+
45
+ ### Conditions: the nested event/occur shape
46
+
47
+ Event conditions nest the event and its timing. The `occur` object uses `match` (GTE/LTE/EQ) and `duration` (milliseconds):
48
+
49
+ ```json
50
+ { "type": "event",
51
+ "event": { "event": { "type": "signUp" },
52
+ "occur": { "match": "GTE", "duration": 86400000 } } }
53
+ ```
54
+
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`).
57
+ - `EQ` matches within the whole increment (day/week/hour).
58
+
59
+ ### Send times & prime texting windows
60
+
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.
62
+
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)
66
+
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.
68
+
69
+ ### Chaining vs. keeping independent
70
+
71
+ Chain with the `receiveAutomation` trigger (automation B fires because A was received) **only when B always follows A**.
72
+
73
+ - **Good:** welcome → follow-up tips 2 days later; expiration nurture reminders (per-guest timeline).
74
+ - **Do NOT linearly chain a calendar countdown** ("1 week before" → "3 days before" → "day of"). If an early step fails or the guest joins late, every later step is blocked. Instead **fan out from a shared parent**: every countdown message uses `receiveAutomation` → the same entry automation, each with its own `absoluteDelay` date. Then no single message can block the rest.
75
+ - **Expiration loops are valid** when gated by user action + a state change: e.g. `... → expired → (guest texts EXTEND, a reply-trigger automation runs extendReward) → re-enters "expires in 3 days"`. The loop is safe because EXTEND gates re-entry and `extendReward` moves the expiration date so conditions re-evaluate. A loop with no user action or no state change is invalid (infinite).
76
+
77
+ ### Backfill (chained automations against past recipients)
78
+
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:
80
+
81
+ - Default `applyToHistorical: false` (going forward only).
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.
84
+
85
+ ### Rewards inside automations
86
+
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).
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.
89
+
90
+ ### Text-content best practices (rules when creating, checklist when reviewing)
91
+
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}}").
94
+ 3. **Always `https://`** on every link (carriers block bare/protocol-less 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.
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.
98
+ 7. **Align offer expirations with open hours**: never expire an offer while the restaurant is closed.
99
+
100
+ ---
@@ -0,0 +1,75 @@
1
+ # Campaigns and offers
2
+
3
+ ## Creating a campaign
4
+
5
+ Fully doable through the tools. The server does the heavy lifting (id generation, default config, the funnel prerequisite); you sequence the calls.
6
+
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
+ - Pick by what the guest should experience, not by whether the offer has a price:
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
+ - `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; 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.
52
+
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.
54
+
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.
56
+
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.
58
+
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.
66
+
67
+ ---
68
+
69
+ ## Offers and promotions
70
+
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`.
73
+ - When you write guest-facing offer language anywhere, frame it as an "offer," never a "discount" or "deal."
74
+
75
+ ---
@@ -0,0 +1,96 @@
1
+ # Creator sourcing
2
+
3
+ Restaurants recruit local creators to visit and post about them. A recruitment ad brings applicants in; from there it's a queue of decisions: approve the applicant, then later approve the content they made. Both decisions text the creator, so neither is a quiet status change.
4
+
5
+ ### The model: the application IS the visit row
6
+
7
+ There is no separate application object. One row covers a creator's whole journey with a location, and you read the stage off its columns rather than a single status field:
8
+
9
+ - `approvalStatus` `pending_approval` → awaiting your decision, then `approved` or `denied`.
10
+ - `startTime` **null** on an approved row → they're approved but haven't booked yet. Set → scheduled.
11
+ - `preVisitConfirmationStatus` `confirmed` → they confirmed they're coming.
12
+ - `postVisitFollowUpSentAt` set → the visit is done.
13
+
14
+ **Gotcha:** denying an application stamps `postVisitFollowUpSentAt` and every content follow-up with the current time, as the way to suppress the remaining message sequence. So a denied row looks *completed* on those timestamps. Always pair a timestamp check with `approvalStatus == "approved"`.
15
+
16
+ ### Setting up the program
17
+
18
+ The program lives on a **location**, not the organization: one config per `locationId`, which you get from `queryData interface.location`.
19
+
20
+ `updateInfluencerBoardConfig` is an **upsert**. There is no create tool: call it for a location with no program and it writes one, seeding a 5000-cent dining credit and leaving `landingPageConfirmed`, `passConfigured` and `reimbursementEnabled` false. Omitted fields are left alone on subsequent calls. `foodCreditAmountCents` has a floor of 2500.
21
+
22
+ **Every program is apply-only.** Creators apply, the approver reviews them, and the creator AI agent texts approved creators to book the visit. There is no scheduling mode to choose.
23
+
24
+ **The setup task and the launch check agree.** The *Design creator program* task and the launch both need a positive credit and `landingPageConfirmed: true`. Since the credit is seeded at 5000 and can't go below 2500, `landingPageConfirmed` is the one field you actually have to set.
25
+
26
+ Other fields worth knowing on the same call:
27
+
28
+ - **`maxCreatorsPerMonth`** caps how many creators the location's recruitment ads source per calendar month. When the cap is reached every recruitment campaign at the location pauses automatically until the 1st of the next month; changing or clearing the cap (`null`) reconciles the campaigns immediately, so raising it can restart paused ads.
29
+ - **`agentPaused: true`** turns the creator AI agent off for the location: no AI replies, visit reminders or content follow-ups until it is set back to `false`. Texts sent by people (including `sendText`) deliver as usual.
30
+ - **`reimbursementEnabled`** switches the board from comping the meal to reimbursing a meal the creator paid for, and `foodCreditAmountCents` becomes the reimbursement cap rather than a dining credit. It changes what creators are promised on the landing page, brief and rights agreement, so **never set it unless the client asks for it**. See *Reimbursing boards* below.
31
+
32
+ `getInfluencerBoardConfig` returns the config (or `null` when the location has no program), including the location's recruitment Meta campaign, ad set and saved status (`recruitmentFacebookCampaignId`, `recruitmentFacebookAdSetId`, `recruitmentStatus`). **Read it before writing recruitment copy**: the dining credit, creator bonus and follower minimum you're supposed to quote live here and nowhere else. It's also how you check the bonus is non-zero before calling `decideCreatorSubmission` with `approvalType: "ad"`.
33
+
34
+ ### Booking windows
35
+
36
+ `listAvailability` (no arguments, **org-wide**: filter by `locationId` or `campaignId` yourself), `createAvailability`, `updateAvailability`, `deleteAvailability`.
37
+
38
+ A window's `block` is one of two shapes: `once`, with a `utcStart` and `utcEnd`; or `weekly`, with start and end hour/minute, the `utcDaysOfWeek` it repeats on, and `blockUtcStart` for when the repetition begins.
39
+
40
+ **Everything is UTC and the restaurant will describe it in local time.** For weekly blocks `utcDaysOfWeek` is the day of week *in UTC*, so an evening local window that crosses midnight UTC lands on the **following** day: 9pm Friday New York is 02:00 Saturday UTC, and writing `Friday` there opens the wrong night. Convert the day and the time together, never just the time. This fails silently: you get a valid window on a day nobody asked for. The same applies in reverse when you read `listAvailability` back: convert each window to local day and time before describing it to the restaurant.
41
+
42
+ **Set `campaignId`, not just `locationId`.** It's optional in the schema and required by the task: *Set booking windows* completes only when a window carries the first campaign's id. Without it the window books fine and the task stays open forever.
43
+
44
+ `updateAvailability` replaces `block` whole rather than merging it, so send the complete block including the parts you aren't changing, and it returns nothing: re-read with `listAvailability` to confirm. `deleteAvailability` **succeeds silently on an id that doesn't exist**, so no error is not proof anything was removed; take ids from `listAvailability`. Deleting closes future slots but does not cancel visits already booked inside the window; those are separate rows.
45
+
46
+ ### The creative brief
47
+
48
+ `createCreativeStrategy` has two paths behind one tool, and only one of them finishes synchronously:
49
+
50
+ - **`awareness`**: assembled from a fixed template and saved before the call returns. `generationStatus` comes back `complete`.
51
+ - **`cta`**: handed to a background LLM. You get a `strategyId` and `generationStatus: "generating"` immediately. **Poll `getCreativeStrategy` until it reads `complete` or `failed`** before using the brief or quoting anything from it. The `jobId` and `jobType` that come back track the same run through `getJob`; reach for that only when the strategy reads `failed` and you want the job's `errorMessage`.
52
+
53
+ `updateCreativeStrategy` is the revision step. Two things to get right: omitting `strategyId` **creates a new strategy** instead of editing the one you meant, and it replaces the fields you send rather than merging them, so read first, apply your edits to the full `concepts` array, and send the whole thing back. Generating into a strategy that isn't a draft is rejected rather than silently overwritten.
54
+
55
+ ### Recruitment creatives and the recruitment ad
56
+
57
+ The ads that bring applicants in are tool-drivable end to end:
58
+
59
+ 1. `createRecruitmentCreatives` with `{ "locationId": "...", "foodCredit": ..., "campaignId": "..." }`. `locationId` and `foodCredit` are required; take the credit from `getInfluencerBoardConfig`. Pass `campaignId` and the tool resolves (or creates) the campaign's recruitment offer itself, which is what groups the creatives and carries the monthly sourcing cap; pass `offerId` instead only when you already have the exact offer. One of the two is needed, or the creatives are generated, charged for, and attached to nothing. Each run calls an image model per missing type; `force` deletes and regenerates the whole set, so don't pass it casually.
60
+ 2. `listCreatives`: each creative's `imageKey` is the reference `planAds` takes as a `libraryAsset` (`selectedImageUrl` is the same picked render as a URL). `imageUrl` is the base render, not the ad asset, so don't choose among the image fields yourself. `staleCreativeIds` flags creatives generated from an older version of their offer, and is only populated when you pass `offerId`.
61
+ 3. Publish through the `recruitment` template in `ads.md`, declaring the **`linkRecruitmentOffer` effect**; the publish is refused without it. The effect stamps the creatives, links the offer (which the sourcing cap and dashboard spend read), and texts the program's approver that sourcing is live.
62
+ 4. Copy rules for the ad live in `ad-copy-creator.md` (`recruitmentAdCopy`; conflating it with guest copy is the classic failure).
63
+
64
+ ### The decision loop
65
+
66
+ 1. `listCreatorApplications`: the approval queue, newest first, across every location. Takes no arguments. Use this rather than querying the data model: it carries **`instagramFollowerCount`**, which is usually the deciding factor and isn't reachable any other way. Each row also carries the brief assigned to the visit as `strategyId`/`strategyTitle`, with its `campaignId`/`campaignName`, all null when no brief is assigned. **Check `strategyId` is non-null before approving**: the approval text links whatever brief the visit carries at that moment. No tool assigns a brief to a visit, so when it is null, have the user assign one on the Creator approvals page first.
67
+ 2. `updateCreatorVisit` with `{ "eventId": "...", "status": "approved" | "denied" }`. **This texts the creator immediately**: approved sends their booking link and creative brief, denied sends a decline. A denial is reversible: approving a denied row later sends a "we changed our mind" text and re-arms the scheduled texts. Approval also **consumes the location's monthly creator sourcing allowance**, and recruitment auto-pauses once that limit is reached, so an approval is both a message and a spend. Confirm with the user before working through a queue; don't batch-approve on your own initiative. **Preview first with `dryRun: true`**: it returns the exact creator text(s) the same call would send and writes nothing, so show the user that before the real call. Approving a row that isn't actionable is a no-op and comes back with `changed: false` rather than texting twice.
68
+
69
+ The same tool is how you reschedule and how you record what happened. `startTime` set to a date texts the creator a confirmation and alerts the approver; `null` clears the time and texts the creator asking for a new one. `startTime` is rejected while the row is `pending_approval` and in any call that passes `status: "approved"`, so approve first, then set the time in a second call (`status: "pending_approval"` clears the time itself; don't pass `startTime` with it). `status` also accepts `confirmed`, `visited`, `missed`, `issue` and `cancelled`; of these only `cancelled` texts the creator. `locationId` moves the visit to another location with a creator program and texts no one, so tell the creator yourself. `notes` sets staff notes shown on the scanner, never sent to the creator. Pass `sideEffects: false` to make any update silent (same field writes, but no creator text, no allowance spend, no post-approval automation), which is what you want when correcting a record after the fact rather than making the decision now.
70
+ 3. The creator books, visits, and submits content on their own; none of that is driven from here.
71
+ 4. `listCreatorSubmissions` with `{ "status": "submitted" }` (and `"revision_requested"`): the content review queue. Submissions are stored outside the queryable data model, so this tool is the only way to read them.
72
+ 5. `decideCreatorSubmission`: `approved`, `rejected`, `revision_requested`, or `under_review`. **Approving texts the creator too**, unless you pass `skipApprovalText: true` (use that only for silent record corrections). `revision_requested` always texts: it sends your `feedbackMessage` verbatim plus a resubmit link, so write it as something the creator will read, not an internal note. **Always pass `approvalType` explicitly when approving**, because an omitted one means `"ad"`: `"ad"` means the content may run in paid ads, stamps the board's bonus on the submission and marks it pending (paid later through `createInfluencerPayout`), and is rejected when the board's bonus is $0; `"organic"` is for content only on their own channels, and earns no payout. Re-approving an approved submission is rejected, except upgrading an `organic` approval to `ad`.
73
+
74
+ ### Paying the bonus
75
+
76
+ Over the MCP server `createInfluencerPayout` is not available: the client pays creator bonuses in the dashboard, so point them there. On the CLI it remains available, as follows.
77
+
78
+ `createInfluencerPayout` with `{ "eventId": "..." }` charges the organization's card and starts the creator's bonus on its way. **Never call it on your own initiative**: every call needs the client's explicit, fresh approval to pay this specific creator; a standing instruction doesn't count. The endpoint enforces its own preconditions (a submission approved with `approvalType: "ad"`, no payout already active for the visit: one per visit). The amount defaults to the bonus stamped on the submission when it was approved (falling back to the board config), grossed up to cover the Stripe fee; pass `amountCents` only when the client explicitly asks to pay this one creator a different amount. It applies to this payout only, is written back to the submission so reporting matches what was paid, and leaves the board config unchanged. A visit whose only attempts are FAILED or REFUNDED may be retried, which voids the earlier attempt's open invoice first. After the charge, Stripe webhooks carry it to the creator with no further action from you. Follow progress in `queryData` `creators.creatorPayout`, joined to the visit on `visitEventId`.
79
+
80
+ ### Reimbursing boards
81
+
82
+ On a board with `reimbursementEnabled`, the creator pays for the meal and uploads a receipt with their submission, and the client pays them back by their own means (up to the `foodCreditAmountCents` cap). `markReimbursementPaid` with `{ "submissionId": "...", "reimbursementPaidNote": "..." }` **moves no money**: it only records that the client already sent it. **Call it only after the client tells you the money has gone out.** The submission must be approved with its reimbursement pending; a submission with no receipt was never on a reimbursing board and is rejected. Read the receipt total (`receiptTotalCents`) and `reimbursementStatus` off the `listCreatorSubmissions` row before recording anything.
83
+
84
+ ### Conversations
85
+
86
+ `listCreatorConversations` is the "who is waiting on a reply" queue: every creator's SMS thread with `hasUnread` (their last message came in after ours and nobody has marked it read, so a human needs to answer), the last message body, time and direction, the creator's handles, `visitLocationIds` (every location they have a visit at, in any status), and a derived `visitStatus` chip that's more reliable than reading raw columns.
87
+
88
+ `getCreatorConversation` with a row's `userId` loads the full thread behind it, newest first: each message's body, direction and timestamps. It comes back empty when the creator has no phone number on file. Read it before characterizing an exchange or drafting a reply; the queue's last-message snippet is not enough context to speak for a whole conversation.
89
+
90
+ **Replying is `sendText` with `{ "to": { "type": "creator", "userId": "..." }, "message": "..." }`.** It is a real SMS, sent immediately, with no undo and no scheduling. **Show the user the exact text and get their go-ahead before sending**; drafting is yours, sending is theirs to approve. Use `type: "creator"` for a creator thread even if the person also holds a guest pass. One recipient per call; there is no bulk form. Creator messages are sent verbatim (no handlebars). Sending also dismisses any reply the AI agent has staged for that creator and re-runs the agent with your message in context, so it doesn't talk over you. Marking a conversation read is the one thing that stays in the dashboard.
91
+
92
+ ### Everything else: queryData
93
+
94
+ The `creators` schema exposes `creator` (the person, one row shared across all their applications), `creatorVisitApplication` (one application/visit), and `creatorPayout` (one initiated bonus payout, joined to the visit on `visitEventId`). Join person to visit on `creator.influencerId = creatorVisitApplication.userId`. Use it for anything the tools above don't answer: no-shows, per-location counts, repeat creators, payout history. Content submissions are **not** in the catalog; `listCreatorSubmissions` is the only read.
95
+
96
+ > **Not exposed:** marking a creator conversation read, assigning a brief to a visit, the dashboard's launch-program button itself (its bookkeeping rides the recruitment publish effect, see above), and publishing a creator's submitted content as a partnership ad.
@@ -0,0 +1,30 @@
1
+ # Funnels
2
+
3
+ **Applying a funnel template** expands a whole screen tree server-side in one call: `applyFunnelTemplate` (needs the campaign's funnel unset, as on a fresh campaign, and resolves the referrer from the campaign). `deleteFunnel` with `{ "campaignId": "..." }` tears one down: it deletes the campaign's own screens and resets its overrides, returning the campaign to the choose-template state.
4
+
5
+ **Individual funnel screens are edited** through a **draft → preview → promote** loop. You never apply edits locally: you stage them on an off-prod draft, preview the result at a stable URL, then save. Tools: `listFunnelScreens`, `createFunnelDraft`, `stageFunnelEdit`, `stageFunnelScreen`, `getFunnelDraft`, `listFunnelDrafts`, `discardFunnelDraft`, `saveFunnelEdits`.
6
+
7
+ ### The loop
8
+
9
+ 1. **`listFunnelScreens`** `{ "referrer": "<subdomain>", "campaignId": "<id>" }`: read the funnel's screens to get each `screenId` and its renderables' `id`s + content. **Read before any `update` edit**: an update replaces a renderable by id, so you need its current shape. (Omit `campaignId` for a base/members-program funnel.)
10
+ 2. **`createFunnelDraft`** `{ "referrer": "...", "campaignId": "..." }`: creates an off-prod overlay; keep the returned `draftId` and **reuse it for the rest of the conversation**. One draft holds as many edits as you need, so don't open a second one per change. Start a fresh draft after a promote (which seals the old one) or when you're abandoning what you staged, and `discardFunnelDraft` the one you're leaving. Don't adopt a draft you didn't create here: an open one may hold edits someone else staged, and promoting it would ship them. If the user asks you to pick up earlier work, `listFunnelDrafts` shows what's open (filter with `"status": "open"`); confirm which one with them before staging onto it. Nothing is live until the save. **Immediately inspect the funnel's current state**: open the draft's preview URL (see step 4; with no edits staged it renders the live funnel as-is) so you have a visual baseline of what you're about to change. If you have a browser/screenshot tool, open and screenshot it first; if you can't view it yourself, share the URL with the user before editing.
11
+ 3. **`stageFunnelEdit`** `{ "draftId": "...", "screenId": "...", "edit": <RenderableEdit> }`: one renderable edit per call; the server validates it against the current screen. Repeat per change. The `edit` is the named type `RenderableEdit`, a discriminated union (the renderables inside it are the named types `Renderable` and `RootRenderable`):
12
+ - `{ "type": "update", "id": "<renderableId>", "renderable": { ...clone of what you read, with your changes... } }`: keep the same `id`.
13
+ - `{ "type": "create", "renderable": { "id": "<new-uuid>", ... }, "targetId": "<sibling id>", "position": "before" | "after" | "inside" }`: generate a fresh UUID.
14
+ - `{ "type": "delete", "id": "<renderableId>" }` and `{ "type": "move", "id": "...", "targetId": "...", "position": "..." }`.
15
+ **Need a brand-new screen?** `stageFunnelScreen` `{ "draftId": "...", "title": "...", "description"? }` stages an empty screen on the draft and returns its **permanent `screenId`**. The id is assigned at staging time, not at save, so you can immediately `stageFunnelEdit` content into it and reference it from navigation/buttons on other screens; nothing gets re-keyed at promote. On a campaign draft the screen is campaign-scoped unless you pass `"campaignScoped": false`. The screen only exists on the draft (and in draft-scoped `listFunnelScreens`/previews) until `saveFunnelEdits` promotes it, which reports it in `createdScreenIds`.
16
+ 4. **Preview**, two ways; use whichever fits how you can look at things:
17
+ - `previewFunnelDraft` renders every screen to a **PDF** (one screen per page, mobile viewport) and returns a short-lived download URL. This is the option that works when you can read files but not browse.
18
+ - The live preview page `https://{referrer}.feastalytics.com/preview/{draftId}/{campaignId}` (drop `/{campaignId}` for a members-program draft) renders the funnel as a flow diagram with the edits applied. This is the link to hand the user.
19
+ Iterate: re-run `listFunnelScreens` **with the `draftId`** to read the funnel *with* the staged edits, stage more, re-preview, until it's right.
20
+ 5. **`saveFunnelEdits`** `{ "draftId": "..." }`, its only input. It **promotes to prod, with no confirmation prompt**: applies the draft's edits to the live funnel, creates any staged screens, and marks the draft `promoted`. To abandon instead, `discardFunnelDraft`.
21
+
22
+ **Promote once per piece of work, not once per change.** `stageFunnelEdit` rejects a draft that is not `open`, so a promote ends that draft and the next edit needs a new one. A user asking for four tweaks in a row wants four tweaks, not four promotes. Stage them together and promote when the funnel is where they asked for it, or when they say to publish. Every funnel edit goes through a draft; there is no one-shot save.
23
+
24
+ ### Domain rules
25
+ - **Base screens vs campaign screens.** A campaign-owned screen is edited in place; a **base/shared screen edited in a campaign context becomes a campaign *override***, and the shared screen is left untouched. `saveFunnelEdits` decides this automatically from the draft's `campaignId`, so a change scoped to one campaign only affects that campaign.
26
+ - **Read before every `update`.** Construct the edit from what `listFunnelScreens` returned (renderable ids are stable), never from memory.
27
+ - **One edit per `stageFunnelEdit`**, staged incrementally; each is validated as it lands.
28
+ - **Drift guard.** `saveFunnelEdits` rejects the promote if the live funnel changed since the draft was created; re-create the draft in that case. Do not lean on it to catch someone else editing the same funnel; keeping to one open draft (step 2) is the real protection.
29
+
30
+ ---
@@ -0,0 +1,38 @@
1
+ # Guests and members
2
+
3
+ `searchUsers` returns a page of recent member activity: one event per member, each carrying the member's `serialNumber` plus the event (type, time, related object).
4
+
5
+ - Filter with `query` (free-text name), `eventTypes` (e.g. `sentText`, `receivedText`, `scan`, `order`, `rewardAwarded`, `rewardRedeemed`, `checkout`, the `*Attribution` types), `campaignId`, `progressMinBound`/`progressMaxBound` (visit-count range), `isUnread: true` (members with unanswered inbound texts; it overrides any broader `eventTypes`), `orderBy` (ASC|DESC by event time).
6
+ - Paginate with `limit` (default 100) and `cursor` (pass back the `cursor` from the previous call; an undefined cursor means no more pages).
7
+
8
+ `getMemberConversation` with a member's `serialNumber` loads their thread, newest first: the pair to `searchUsers` the same way `getCreatorConversation` pairs with `listCreatorConversations`. Always pass `eventTypes`: `["sentText","receivedText"]` is the SMS thread, and adding `scan`/`order`/`checkout`/`rewardAwarded`/`rewardRedeemed` interleaves what happened between the messages. Unfiltered it returns the member's entire history unpaginated.
9
+
10
+ ## Replying to a guest
11
+
12
+ `sendText` sends one SMS from the organization's texting number, the reply you would otherwise type into the dashboard chat. It is high priority and sent immediately: **no scheduling, no undo, no bulk form**.
13
+
14
+ 1. Find the guest with `searchUsers` (`isUnread: true` is the "waiting on a reply" queue) and read the thread with `getMemberConversation` before drafting anything.
15
+ 2. **Show the user the exact text and get their go-ahead before sending.** Drafting is yours; sending is theirs to approve, every time.
16
+ 3. `sendText` with `{ "to": { "type": "guest", "serialNumber": "..." }, "message": "..." }`. Guest messages render `{{firstName}}`-style handlebars (the same ones text automations use) before sending. `mediaUrls` attaches up to 10 images.
17
+ 4. **One recipient per call.** To reach several guests, call once per guest, each with its own confirmed text; for a broadcast, use a text blast automation instead.
18
+
19
+ The recipient is always named by id, never by phone number, and the type must match the thread: a guest who is also a creator exists in both tables, so use `type: "guest"` for a member thread and `type: "creator"` for a creator thread (see `creators.md`). A guest who doesn't belong to the organization is a 404, not a text to a stranger.
20
+
21
+ **Unknown senders.** Someone who texted the organization's number without being a member or a creator is answered with `{ "type": "unknownSender", "phoneNumber": "+1..." }`. It is the only form that takes a raw number, and it is refused unless that number has an inbound message to the organization on file, so it can only answer, never cold-text.
22
+
23
+ ## Everything else: the data catalog
24
+
25
+ `searchUsers` answers "recent activity, one event per member." Every other read question about guests (and about orders, menu items, texts, reservations, creator visits, payouts) goes through **`describeData` → `queryData`**:
26
+
27
+ - `describeData` with no arguments returns the index of every queryable object type plus the full query grammar; narrowed by schema or object type it returns full column detail (type, enum values, nullability, description, and the link names `pivot` and `join` take). Pass `includeGrammar: false` once you have the grammar. Never guess column names.
28
+ - `queryData` is read-only and always scoped to the organization; never filter on organizationId yourself.
29
+ - Writing a query: `commands` run in order (`filter`, `pivot`, `join`, `aggregate`), and `pivot` and `join` must come before any `aggregate`. A filter leaf is one column, written as the column name prefixed with `$`; combine leaves with `{ "type": "and" | "or", "filters": [...] }`. Use `{ "strings": [...] }` for any-of rather than a large `or`. Pass `args.fields` to return only the columns you need on wide object types, and page by passing the returned `nextCursor` back as `args.cursor` (no `nextCursor` means no more rows).
30
+ - Example, opted-in members with more than 5 visits, newest first:
31
+ ```json
32
+ { "schemaName": "core", "objectTypeName": "guest",
33
+ "commands": [{ "type": "filter", "filter": { "type": "and", "filters": [{ "$optIn": { "boolean": true } }, { "$progress": { "number": 5, "match": "GT" } }] } }],
34
+ "args": { "limit": 500, "order": { "field": "timeAdded", "direction": "DESC" }, "fields": ["serialNumber", "phoneNumber", "progress"] } }
35
+ ```
36
+ - Six schemas: `interface` (POS-agnostic orders, order items, menu `catalogItem`s, `location`s, reservations: the same shape whichever POS the org runs), `core` (guests/members), `events` (user events), `texting` (SMS logs), `creators` (visits and payouts), `attribution` (campaign attribution). Prefer `interface` for anything POS-shaped.
37
+
38
+ Typical uses: visit counts and cohorts, order history for one guest, menu items with real prices for grounding copy, text delivery history, creator payout status.
@@ -0,0 +1,30 @@
1
+ # Members program and wallet pass
2
+
3
+ ## Members program (retention)
4
+
5
+ The retention counterpart to campaigns: flows with no `campaignId`.
6
+
7
+ - **Automations are authorable** (see the automations workflow): use `listAutomationFlows` with `{ "scope": "membersProgram" }`, then the same create/edit loop. Remember the members-program 30-day `awardReward` default.
8
+ - **Rewards are fully manageable.** `listMembersProgramRewards` returns every reward with its catalog item's `name`, `staffInstructions`, `pointsCost` and `source`. The item is resolved across the Feast, Toast, Square and Clover catalogs, and a `null` name means it doesn't exist in any of them.
9
+ - **Creating takes one of two shapes.** `{ "type": "item", "itemId" }` promotes an existing catalog item; prefer it whenever the item already exists in the POS. `{ "type": "name", "name" }` looks the name up across all four catalogs and creates a new Feast item only if nothing matches; **the match is exact, so a near-miss silently duplicates a menu item the restaurant already has**. Check `listMembersProgramRewards` or the catalog first. When several items share the name, a Feast item among them wins; otherwise you get a CONFLICT listing the candidates so you can pass `itemId` instead. `staffInstructions` only exist on Feast items and are rejected for POS-sourced ones.
10
+ - **The `pointsCost` fork matters.** A reward with `pointsCost` set is redeemed *by the member with points* and is never auto-awarded. Omit `pointsCost` for automation-awarded rewards, and then actually pair the reward with an `awardReward` automation (see the automations workflow), or it will never reach anyone. To find orphans, cross-reference `listAutomations` for `awardReward` actions carrying the reward's `itemId`.
11
+ - `updateMembersProgramReward` corrects a reward in place; `pointsCost: null` converts a points reward into an automation-granted one. `deleteMembersProgramReward` is the orphan cleanup; it leaves the catalog item alone (it may be a real menu item) and doesn't claw back anything already redeemed.
12
+
13
+ ### Giving one member a reward
14
+
15
+ `awardReward` grants a reward to a single member right now, like the dashboard's Give Reward button. It is **not** `createMembersProgramReward`: that defines a reward the program offers, this puts one into a specific guest's wallet pass.
16
+
17
+ - Input: `{ "serialNumber": "...", "itemId": "...", "expiresInDays": 14 }`. Get `serialNumber` from `searchUsers` and `itemId` from `listMembersProgramRewards` (or a catalog query). Both are checked against the organization, and a wrong id is rejected rather than granted.
18
+ - Expiry is optional, and a reward with none never expires. `expiresInDays` ends at the end of that day in the restaurant's timezone (what a guest reads "14 days" to mean); `expiresAt` takes an exact ISO 8601 instant. Pass one or the other. `locationId` restricts redemption to one participating location.
19
+ - It recomputes the member's progress, which **re-evaluates their automations**, so a flow triggered by earning a reward will fire (and may text them).
20
+ - **No undo and no idempotency key: a retried call grants a second reward.** Confirm the member, item and expiry with the user before calling, call once per member, and if a call's outcome is unclear, check the member's `rewardAwarded` events with `getMemberConversation` before retrying.
21
+
22
+ ---
23
+
24
+ ## Wallet pass configuration
25
+
26
+ The pass (the wallet membership card) is read and written as a whole document.
27
+
28
+ - **`getPassConfiguration`** `{}`: returns the latest live configuration (`sections`, `features`, `locations`, `metadata`, and its `version`), or `null` when none has been saved.
29
+ - **`updatePassConfiguration`**: **a full-document save, not a patch.** Anything you omit is dropped from the new version. The only safe workflow is read → modify the returned document → save the complete result. `sections` is a `PassSections` and `features` a `PassFeatures`. Saving appends a new version (history is preserved server-side), and a change to sections, features, locations or `passStyle` re-pushes the pass to every member's wallet. `passStyle` is the Apple pass style every one of the organization's passes is built with (`eventTicket`, `storeCard`, `generic`, `coupon`); unset means `eventTicket`. There is no confirmation prompt, so treat it with the same care as a live send. Omit `passStyle` to keep the current style; `null` clears it back to `eventTicket`.
30
+ - Pass **image generation** (punch-card strips etc.) is not exposed; image workflows go through the app.
@@ -0,0 +1,33 @@
1
+ # Onboarding and brand
2
+
3
+ ## Onboarding tasks (the taskboard)
4
+
5
+ `getTaskboard` is the single "what needs fixing or finishing" surface: one `entries` list discriminated by `kind`. `task` entries are the org's onboarding tasks; `issue` entries are live-computed misconfigurations (placeholder content, inactive automations, a missing "Text STOP" opt-out, unawarded rewards, wallet pass and pixel problems), each with a `severity`, a human `message` and a `fixHint`. Funnel checks cover only screens reachable from the funnel's start screen, so an orphaned screen raises no issue. Scope with `{"scope":{"type":"onboarding"}}` for tasks only, `{"type":"task","task":{"taskId":"..."}}` for one task, or leave the default `all`.
6
+
7
+ - **Start from `completionInstructions`, not guesswork.** Every task entry says exactly what completes it and whether it needs a human in a browser. Trust it over inferring from the task name.
8
+ - **Split the work accordingly.** Campaigns, automations, funnel fixes, rewards, brand identity, image uploads and the onboarding form are all completable through the tools, so do them. Tasks that need OAuth (Facebook, POS), physical device setup, or in-restaurant staff training cannot be: hand the user that task's **`completionUrl`**, a page where they complete exactly that task. Paste the URL directly in your reply so the user can open it.
9
+ - **Never claim a task complete or try to mark one.** Statuses are derived from live data by a recompute (triggered by every taskboard read, ~30s lag). Do the underlying work, then re-read the taskboard to confirm the checkmark flipped.
10
+ - Working through onboarding = repeat: `getTaskboard` (scope `onboarding`) → do the tool-doable incomplete required tasks → hand over completionUrls for the rest → re-read to verify.
11
+
12
+ ## The onboarding form
13
+
14
+ Some tasks read self-reported answers rather than observed data: launch date, funnel direction, per-step `isComplete` markers. `getOnboardingForm` reads them (`null` when the org has no form yet); `updateOnboardingForm` writes them (and creates the form when there is none). Top-level keys you omit are left alone. **Nested step objects are replaced, not merged**: read first and send back the whole step you're editing (`data` is the exception and is merged). Setting `pos.details.type` to `"other"` provisions a manual-entry POS location as a side effect.
15
+
16
+ Two POS setup tasks complete off `updateOrganization` instead: `staffInstructions.scan` completes *Members Program Visits POS setup*, and `.prepaid` is additionally required for *Campaign POS setup* when the promotion allows pre-pay. `staffInstructions` is replaced wholesale, so send every key you want to keep.
17
+
18
+ Other `updateOrganization` fields: `timezone` is an IANA zone (`America/New_York`); `minimumSpendValue` is in dollars. `periodCalendar` sets how Impact and revenue plans group weeks into periods: `{"type":"fiscal","fiscal":{"pattern":"4-4-5","yearEndWeekday":"sunday","yearEndRule":"nearestDec31"}}`, where `pattern` is `4-4-5`, `4-5-4`, `5-4-4` or `13x4`, `yearEndWeekday` is `sunday` through `saturday`, and `yearEndRule` is `nearestDec31` or `lastInDecember`. `null` resets it to the default: 13 four-week periods ending on the Sunday nearest Dec 31. `restaurantType`, `isArchived` and `isReadOnly` are admin-only and rejected for anyone else.
19
+
20
+ ## Establishing brand identity
21
+
22
+ 1. `searchGooglePlaces`: resolve the restaurant to its Google Place. `{"type":"search","query":"Todays Pizza, Brooklyn NY"}` returns ranked candidates; include the city, since a bare name is usually ambiguous. Pass `referrer` to bias the search toward that site's saved coordinates. `confidentMatch` is non-null only when one candidate is unambiguous: its website domain matches the site's, or it is the only candidate whose name matches the query. Otherwise show the candidates and let the customer pick. `{"type":"lookup","placeId":"..."}` returns that one place (name, address, website, coordinates), useful for inspecting a place already stored on a site.
23
+ 2. `createBrandIdentity`: the branded site: a subdomain, layout config, and a full default screen tree. The subdomain is claimed **across all organizations** and gates everything funnel-shaped downstream, so confirm the name with the customer first. `referrer` is the subdomain label only (letters and numbers, no dots) and is lowercased; a label another restaurant uses is rejected. Only an admin can create a second site for an organization that already has one. Get `logoUrl` via `getMediaUploadUrl`.
24
+ 3. `updateBrandIdentity`: business data, theme, tracking pixel IDs, OpenTable links, and the Google Place link. It deep-merges, so send only what you're changing. **Setting `googleConfig.placeId` enqueues a review/photo scrape; changing an existing placeId orphans everything scraped under the old one.** Confirm before replacing. A new placeId on a site whose `googleConfig` has coordinates also buys and bills the nearest texting number when the organization has none yet (an onboarding form exists, its phone step is incomplete, and it is not a test organization). Changing `hostname` enqueues a DNS update.
25
+
26
+ Browser-only: the brand *import* intelligence (auto-extracting a usable palette and logo from a scraped site) lives in the app, not the tools. If the customer wants that flow, hand them the dashboard.
27
+
28
+ ## Plumbing the taskboard leans on
29
+
30
+ - **Texting number**: there is no tool to search for or buy one. The restaurant's texting number is bought automatically from its location when its Google place is set (see `updateBrandIdentity` above), or the client chooses one in the dashboard's "Choose texting number" task. If neither has happened, hand the user that task's `completionUrl`.
31
+ - **Media**: `getMediaUploadUrl` (PUT the bytes to the presigned URL, then reference the returned key), `listMedia`, `deleteMedia`. This is how logos and offer images get in through the tools.
32
+ - **Team**: `inviteUser` sends a real email immediately and **defaults to OWNER** (full billing access), so always pass `role` explicitly; VIEWER is read-only, SCANNER is for staff running the scanner app. A new person gets an invitation valid for 14 days; someone with a Feast account gets a login reminder and is added right away. Re-inviting an email cancels its pending invites and sends a fresh one. Only an OWNER can invite.
33
+ - **Billing**: `getBillingStatus`, read-only: `hasAccess` answers "can they use the product," `needsPayment` flags the states worth acting on and is what the dashboard reads to put the app behind a payment form. `currentTier` and `subscriptionStatus` describe the plan. `existingOrganizations` lists every organization billed under the same billing admin's subscription (this one included when it is on that subscription), with names and tiers; it is absent for per-organization billing. Every billing write stays in the dashboard.