@feastalytics/cli 0.1.21 → 0.1.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feastalytics/cli",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Command-line client for the Feastalytics platform — list, create, and update campaigns, automations, offers, and members-program rewards from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,7 +53,7 @@ Query tools (listing, describing, reading) are safe and read-only; over MCP they
53
53
  That last point matters most for the tools that reach the real world rather than just the database:
54
54
 
55
55
  - `sendText` texts a guest or creator immediately, one person per call, with no scheduling and no undo.
56
- - Approving or denying a creator visit (`updateCreatorVisit`) or deciding a submission (`decideCreatorSubmission`) texts that person. `updateCreatorVisit` can preview its texts with `dryRun: true` or skip them with `sideEffects: false`; `decideCreatorSubmission` can skip its text with `skipApprovalText`.
56
+ - Approving or denying a creator visit (`updateCreatorVisit`) or deciding a submission (`updateCreatorSubmission` with a `decision`) texts that person. `updateCreatorVisit` can preview its texts with `dryRun: true` or skip them with `sideEffects: false`; `updateCreatorSubmission` can skip its approval text with `decision.skipApprovalText`.
57
57
  - Paying a creator's bonus (`createInfluencerPayout`, on the CLI) charges the organization's card. Over the MCP server that tool is not available, so the client pays bonuses in the dashboard.
58
58
  - `awardReward` puts a real reward in a member's wallet pass, and a retried call grants a second one.
59
59
  - `inviteUser` sends a real email.
@@ -15,7 +15,7 @@ A campaign is an acquisition effort. It bundles:
15
15
 
16
16
  `listCampaigns` resolves a `campaignId`: use each summary's `id` (a UUID), not the nested Meta campaign id. Summaries also carry the name, `shorthand` (used in reservation links), publish state and referrers; `getCampaign` has the full configuration.
17
17
 
18
- Typical flow: `createCampaign`, then `updateCampaign` with `isCreating: false` to finish setup, then a funnel template and automations (`workflows/campaigns.md`). `cloneCampaign` duplicates an existing one (funnel, automations and offers); it needs the source campaign id and a `referrer` (a subdomain from the org's `subdomains2`).
18
+ Typical flow: `createCampaign`, then `updateCampaign` with `isCreating: false` to finish setup, then a funnel template and automations (`workflows/campaigns.md`). To copy an existing campaign (funnel, automations and offers), call `createCampaign` with `sourceCampaignId`; `campaign.referrers` is left out (keeps the source's) or holds exactly one subdomain from the org's `subdomains2`. A copy of a finished campaign is finished, so it skips the `updateCampaign` step.
19
19
 
20
20
  ## Automations and flows
21
21
 
@@ -23,7 +23,7 @@ Typical flow: `createCampaign`, then `updateCampaign` with `isCreating: false` t
23
23
  - A **flow** is a named grouping of automations. A flow belongs to *either* a campaign *or* the members program (never both).
24
24
  - `listAutomationFlows` scopes with input: `{ campaignId }` returns that campaign's flows; `{ scope: "membersProgram" }` returns members-program flows (those with no campaign). `listAutomations` returns every automation in the org, ordered by execution priority.
25
25
  - **Authoring:** `createAutomationFlow` makes a flow; automations are created, updated and deleted through a draft (`createAutomationDraft` → `stageAutomationEdits` → `simulateAutomationDraft` → `saveAutomationEdits`; create ops **require** a `flowId`); `updateAutomationFlow` / `deleteAutomationFlow` manage the flow itself. See `workflows/automations.md` for the ordering and the trigger/condition/send-time rules.
26
- - Templates: `listAutomationTemplates` → `listTemplateAutomations` (preview) → `applyAutomationTemplate`. Only apply a template to a campaign/members-program that has no existing flows.
26
+ - Templates: `listAutomationTemplates` → `applyAutomationTemplate` (creates live, active automations right away). Only apply a template to a campaign/members-program that has no existing flows.
27
27
 
28
28
  ## Offers and promotions
29
29
 
@@ -52,8 +52,7 @@ An effect that reports `error` in the job is a case for the dashboard, not for p
52
52
  ### Reading and steering what's live
53
53
 
54
54
  - `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. To see the whole tree, do not walk it one level per call: pass `includeChildren: true` and each campaign comes back with its `adSets`, each with its `ads` (at `level: "adSet"`, each ad set with its `ads`). `limit` and `effectiveStatus` apply to every level, so add a `campaignId` when you need one campaign's complete tree.
55
- - `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).
56
- - `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.
55
+ - `ads_update_entity`: rename, re-budget, pause, or turn on. Moving a daily budget is how you scale a winner or throttle a loser; budgets are integer cents and **replace** the current value, so read first and confirm the number with the human. Creatives are immutable at Meta, so new copy or media means a new ad (the `addAds` template). `fields.status: "ACTIVE"` is go-live for structures Feastalytics did *not* publish: it spends real money, so only after the human explicitly confirms. Name or budget changes in the same call land first, so the budget is set before spend starts. It does not cascade: activate top-down and check `willDeliver`, since a child under a paused parent is live in name only. For campaigns Feastalytics published, use `setAdCampaignStatus`, which cascades; those are published paused at all three levels, so activating the campaign alone spends nothing.
57
56
  - `ads_get_assets` with `include: ["datasets"]` and an `adAccountId` / `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.
58
57
 
59
58
  > **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.
@@ -78,7 +78,21 @@ When an automation's trigger is `receiveAutomation`, ask the user whether it sho
78
78
 
79
79
  - Default `applyToHistorical: false` (going forward only).
80
80
  - 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.
81
- - Before confirming, call `countParentAutomationRecipients` with `{ "parentAutomationId": "<id>" }` and tell the user the audience size; warn if > 1000. Only backfill after explicit confirmation.
81
+ - Before confirming, count the audience: the distinct members the parent automation has run for. Run `queryData` with exactly this input, putting the parent automation's id in place of `<parentAutomationId>`:
82
+
83
+ ```json
84
+ {
85
+ "schemaName": "core",
86
+ "objectTypeName": "automationLog",
87
+ "commands": [
88
+ { "type": "filter", "filter": { "$automationId": { "string": "<parentAutomationId>", "match": "EQ" } } },
89
+ { "type": "aggregate", "aggregate": { "aggregate": { "$serialNumber": "COUNT_DISTINCT" } } }
90
+ ]
91
+ }
92
+ ```
93
+
94
+ The count is `data[0].serialNumber`. Count on `core.automationLog`, not `texting.textMessage`: the backfill reads the automation log, which also records runs that sent no text or whose text failed, so a count of texts comes out low.
95
+ - Tell the user the number, warn if it is above 1000, and only backfill after they explicitly confirm.
82
96
 
83
97
  ### Rewards inside automations
84
98
 
@@ -24,10 +24,10 @@ Tracking only stops here. Otherwise continue:
24
24
  - `offer-basic`: Sign Up goes straight to the offer wallet. **No payment step.** The template for any offer redeemed in person, priced or not.
25
25
  - `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`.
26
26
  - `reservation-offer-basic` / `reservation-offer-prepay` / `reservation-offer-direct-prepay` / `reservation-only`: the reservation variants of the same split.
27
- - `applyFunnelTemplate` with `{ "campaignId": ..., "templateId": ... }`. Requires a fresh campaign whose funnel is unset; resolves the referrer from the campaign.
27
+ - `applyFunnelTemplate` with `{ "campaignId": ..., "templateId": ... }`. Resolves the referrer from the campaign. If the campaign already has a funnel (`hasFunnel` in `listFunnelTemplates`), applying replaces it: the campaign's own screens and every edit made to them are deleted first, so confirm with the user before replacing a funnel someone has edited. Base screens such as Members Pass are untouched, and an ineligible template is rejected before anything is deleted.
28
28
  - 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.
29
29
  - After applying, confirm with `listFunnelScreens` that the journey matches intent. For a no-prepay offer there must be no `payment` screen.
30
- 4. 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).
30
+ 4. 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. Pick one with `listAutomationTemplates`. The automations go live as soon as it is applied, so read them right after with `listAutomations` (`{ "flowId": "<id>" }`) and check the texts against what the offer promises (an expiring-offer template contradicts a "no expiration" offer); fix anything wrong through an automation draft.
31
31
 
32
32
  Steps 3 and 4 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.
33
33
 
@@ -47,7 +47,7 @@ A short approval like "save it" or "looks good" is not a go-live instruction whe
47
47
  - `imageUrl` (the offer image) whose url or key contains the word `placeholder` counts as unset, and onboarding keeps asking for an image.
48
48
  - 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.
49
49
 
50
- **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`.
50
+ **Cloning:** `createCampaign` with `{ "sourceCampaignId": "<id>", "campaign": { "name": "...", "referrers": ["<subdomain>"] } }` copies the source's funnel, automations and offers and returns the new id. `referrers` holds exactly one subdomain (it becomes the copy's only referrer and the one whose funnel is copied), or leave it out to copy the source's first referrer's funnel and keep the source's referrers. `description` and `imageUrl` replace the source's when given. The copy keeps the source's `isCreating`, so a copy of a finished campaign needs no `updateCampaign` finish step. The landing page banner usually keeps the source's title, description and image until edited. **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`.
51
51
 
52
52
  ---
53
53
 
@@ -29,7 +29,7 @@ Other fields worth knowing on the same call:
29
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
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
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"`.
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 `updateCreatorSubmission` with `decision.approvalType: "ad"`.
33
33
 
34
34
  ### Booking windows
35
35
 
@@ -69,7 +69,7 @@ The ads that bring applicants in are tool-drivable end to end:
69
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
70
  3. The creator books, visits, and submits content on their own; none of that is driven from here.
71
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 send `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 send `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 zero; `"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`.
72
+ 5. `updateCreatorSubmission` with `{ "submissionId": "...", "decision": { "status": ..., "approvalType": ... } }`. `status` is `approved`, `rejected`, `revision_requested`, or `under_review`. **Approving texts the creator too**, unless you send `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 send `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 zero; `"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
73
 
74
74
  ### Paying the bonus
75
75
 
@@ -79,7 +79,7 @@ Over the MCP server `createInfluencerPayout` is not available: the client pays c
79
79
 
80
80
  ### Reimbursing boards
81
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.
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). `updateCreatorSubmission` with `{ "submissionId": "...", "reimbursementPaid": { "note": "..." } }` **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. A call may carry both a `decision` and `reimbursementPaid`: the decision is applied first and the reimbursement is checked against the decided submission, so approving and recording the reimbursement in one call works. If either part is refused, nothing is saved and no text is sent.
83
83
 
84
84
  ### Conversations
85
85
 
@@ -1,6 +1,6 @@
1
1
  # Funnels
2
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.
3
+ **Applying a funnel template** expands a whole screen tree server-side in one call: `applyFunnelTemplate`, which resolves the referrer from the campaign. On a campaign that already has a funnel it replaces it: the campaign's own screens are deleted, along with every edit made to them, and its overrides reset before the template is applied. Base screens such as Members Pass are untouched, and an ineligible template is rejected before anything is deleted. `applyFunnelTemplate` with `{ "campaignId": "...", "templateId": null }` deletes the funnel and applies nothing, returning the campaign to the choose-template state.
4
4
 
5
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
6
 
@@ -26,7 +26,7 @@ The recipient is always named by id, never by phone number, and the type must ma
26
26
 
27
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
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`. Send `args.fields` to return only the columns you need on wide object types, and page by sending the returned `nextCursor` back as `args.cursor` (no `nextCursor` means no more rows).
29
+ - Writing a query: `commands` run in order (`filter`, `pivot`, `join`, `aggregate`), and `pivot` and `join` must come before any `aggregate`. Aggregate functions are `SUM`, `MAX`, `MIN`, `AVG`, `COUNT` and `COUNT_DISTINCT`: `SUM` and `AVG` need a number column, `COUNT` (non-null values) and `COUNT_DISTINCT` (distinct non-null values) work on any column, and without a `groupBy` the result is one row, e.g. `{ "aggregate": { "$serialNumber": "COUNT_DISTINCT" } }` for distinct members. 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`. Send `args.fields` to return only the columns you need on wide object types, and page by sending the returned `nextCursor` back as `args.cursor` (no `nextCursor` means no more rows).
30
30
  - Example, opted-in members with more than 5 visits, newest first:
31
31
  ```json
32
32
  { "schemaName": "core", "objectTypeName": "guest",