@wrangled/cli 0.2.0 → 0.3.1

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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * A worked example the map-offers playbook installs beside its SKILL.md: DLC's webinar funnel as Brad described it, with
3
+ * its real offer stack. Ids the evidence would supply (campaigns, the registration page, the webinar) are left for the
4
+ * agent to fill in, so a draft saved from it as-is carries those as warnings.
5
+ */
6
+ export const DLC_EXAMPLE_FILE = "dlc-map.example.json";
7
+ export const dlcExampleMap = {
8
+ offers: [
9
+ { offer_key: "free_webinar", name: "Overages masterclass (free webinar)", tier: "front_end", delivery: "webinar", price: 0, currency: "USD", stack_position: 1 },
10
+ {
11
+ offer_key: "course",
12
+ name: "Overages Blueprint",
13
+ tier: "front_end",
14
+ delivery: "course",
15
+ price: 1497,
16
+ currency: "USD",
17
+ price_note: "or $597 on day one of a 3-pay",
18
+ stack_position: 2,
19
+ matches: [{ platform: "ghl", match_type: "name_contains", value: "Overages Blueprint" }],
20
+ },
21
+ {
22
+ offer_key: "sales_mastery",
23
+ name: "Sales Mastery (order bump)",
24
+ tier: "front_end",
25
+ delivery: "course",
26
+ price: 497,
27
+ currency: "USD",
28
+ price_note: "or $199 x 3",
29
+ stack_position: 3,
30
+ matches: [{ platform: "ghl", match_type: "name_contains", value: "Sales Mastery" }],
31
+ },
32
+ {
33
+ offer_key: "pi_confidential",
34
+ name: "PI Confidential, skip tracing (order bump)",
35
+ tier: "front_end",
36
+ delivery: "course",
37
+ price: 499,
38
+ currency: "USD",
39
+ price_note: "or $197 x 3",
40
+ stack_position: 4,
41
+ matches: [{ platform: "ghl", match_type: "name_contains", value: "PI Confidential" }],
42
+ },
43
+ {
44
+ offer_key: "live_event",
45
+ name: "Overages Workshop Live (OWL and OCA)",
46
+ tier: "back_end",
47
+ delivery: "event",
48
+ price: 3497,
49
+ currency: "USD",
50
+ price_note: "GA $3,497 (some $3,597); VIP upgrade +$597; guest $497; guest VIP $397. The OWL and OCA matches also catch any product whose name merely contains those letters, so check the product names in the evidence.",
51
+ stack_position: 5,
52
+ matches: [
53
+ { platform: "ghl", match_type: "name_contains", value: "OWL" },
54
+ { platform: "ghl", match_type: "name_contains", value: "OCA" },
55
+ ],
56
+ },
57
+ {
58
+ offer_key: "protege",
59
+ name: "Protégé coaching",
60
+ tier: "back_end",
61
+ delivery: "coaching",
62
+ price: 50000,
63
+ currency: "USD",
64
+ price_note: "financed: full pay, or a deposit then financing payments",
65
+ stack_position: 6,
66
+ matches: [{ platform: "ghl", match_type: "name_contains", value: "Protege" }],
67
+ },
68
+ ],
69
+ funnels: [
70
+ {
71
+ funnel_key: "webinar_to_course",
72
+ name: "Masterclass to Overages Blueprint, then OWL and Protégé",
73
+ kind: "front_end",
74
+ entry_offer_key: "course",
75
+ window_days: 365,
76
+ steps: [
77
+ { step_key: "ad", position: 1, step_type: "ad", name: "Facebook ad", rules: [{ kind: "ad_traffic" }] },
78
+ { step_key: "registration_page", position: 2, step_type: "landing_page", name: "Masterclass registration page", rules: [{ kind: "page_visit", params: { host: null, path: null } }] },
79
+ {
80
+ step_key: "register",
81
+ position: 3,
82
+ step_type: "webinar_registration",
83
+ name: "Registers for the masterclass",
84
+ offer_key: "free_webinar",
85
+ rules: [{ kind: "webinar", params: { event_types: ["registered"] } }],
86
+ },
87
+ { step_key: "attend", position: 4, step_type: "webinar", name: "Watches the masterclass", rules: [{ kind: "webinar", params: { event_types: ["attended", "attended_replay"] } }] },
88
+ { step_key: "course", position: 5, step_type: "checkout", name: "Buys Overages Blueprint", offer_key: "course", rules: [{ kind: "purchase", params: { offer_key: "course" } }] },
89
+ {
90
+ step_key: "sales_mastery",
91
+ position: 6,
92
+ step_type: "order_bump",
93
+ name: "Adds Sales Mastery",
94
+ offer_key: "sales_mastery",
95
+ rules: [{ kind: "purchase", params: { offer_key: "sales_mastery" } }],
96
+ },
97
+ {
98
+ step_key: "pi_confidential",
99
+ position: 7,
100
+ step_type: "order_bump",
101
+ name: "Adds PI Confidential",
102
+ offer_key: "pi_confidential",
103
+ rules: [{ kind: "purchase", params: { offer_key: "pi_confidential" } }],
104
+ },
105
+ { step_key: "nurture", position: 8, step_type: "email_nurture", name: "Follow-up emails and texts", wait_note: "the months after the course", rules: [{ kind: "none", params: {} }] },
106
+ { step_key: "live_event", position: 9, step_type: "event", name: "Buys a ticket to OWL or OCA", offer_key: "live_event", rules: [{ kind: "purchase", params: { offer_key: "live_event" } }] },
107
+ { step_key: "protege", position: 10, step_type: "sales_call", name: "Pitched Protégé at the event", offer_key: "protege", rules: [{ kind: "purchase", params: { offer_key: "protege" } }] },
108
+ ],
109
+ },
110
+ ],
111
+ };
package/dist/guide.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { IMPORT_LIMITS } from "./api.js";
2
2
  import { brand, envPrefix } from "./brand.js";
3
+ import { MAP_OFFERS_SKILL_NAME, PLAYBOOK_CHOICES } from "./playbooks.js";
3
4
  import { PULL_STATE_FILE } from "./pull.js";
4
5
  const cli = brand.slug;
5
6
  export const guide = `# ${brand.name} CLI
@@ -13,15 +14,19 @@ export const guide = `# ${brand.name} CLI
13
14
 
14
15
  ## Start with the data guide
15
16
 
16
- Run \`${cli} guide\` first. It says, in plain words, what this workspace has: each connected source with when it last synced and how many rows it holds over which dates; the content library's ads, emails, text messages, landing pages, videos and posts and how many videos and recordings have transcripts; and what is missing, such as a source that is not connected, a failing sync, or videos and recordings without a transcript yet.
17
+ Run \`${cli} guide\` first. It says, in plain words, what this workspace has. It starts with the settled facts: rows to leave out of the numbers, known gaps in the data, last month's normal numbers and the latest changes, with \`${cli} facts\` for the rest. Then each connected source with when it last synced and how many rows it holds over which dates; the content library's ads, emails, text messages, landing pages, videos and posts and how many videos and recordings have transcripts; and what is missing, such as a source that is not connected, a failing sync, or videos and recordings without a transcript yet.
18
+
19
+ ## Before writing ads: the campaign brief
20
+
21
+ Before writing new ads or a campaign plan, run \`${cli} brief\`. In one read it gives the offer (from the confirmed offers and funnels map, else the brand notes and landing pages, or it says the offer is unknown), the brand voice, the best ads by spend and by results with their words and a piece of their video transcript, the ads to avoid repeating, the pages those ads send people to, the funnel and top campaigns, who the top Facebook campaigns were aimed at, and what is missing. \`--from\` and \`--to\` pick the dates (default: the last 30 days), \`--channel\` or \`--campaign <id>\` narrow it, and \`--top <n>\` sets how many of each list it shows (default 5, at most 20). It prints plain Markdown to paste into a prompt; \`--format json\` gives the same brief as data. It says what it cut to stay short.
17
22
 
18
23
  ## Content: search before SQL
19
24
 
20
25
  To find content, such as "the 10 emails with the best click rate", "our 5 best videos by return on ad spend" or "every landing page that mentions the guarantee", use \`${cli} content search\` before writing SQL on the \`content_\` tables.
21
26
 
22
- - \`--text "<words>"\` finds words in the current version's subject, headline and body, or in a video's transcript. Words are stemmed ("guarantee" finds "guaranteed"); use "quotes" for a phrase and -word to leave a word out.
27
+ - \`--text "<words>"\` finds words in the current version's subject, headline and body, or in a video's or call recording's transcript. Words are stemmed ("guarantee" finds "guaranteed"); use "quotes" for a phrase and -word to leave a word out.
23
28
  - \`--mode meaning\` finds content that means something close to \`--text\`, even in other words: \`--text "saves time"\` also finds "done in five minutes". Results come nearest first, each with its distance (0 is the same meaning) and the closest passage as its words. It takes no \`--sort\` or \`--order\`, and finds only content embedded so far (each hour, new versions and transcripts are embedded).
24
- - \`--kind\` (ad, email, sms, landing_page, video, social_post, conversation), \`--platform\` (meta, ghl, klaviyo, roezan, bayengage, google_ads, web, youtube, instagram, workspace, upload) and \`--origin\` (discovered or made) narrow it. \`--from\` and \`--to\` keep content live on those days: from its first day with numbers (or the day the library first saw it) to its last.
29
+ - \`--kind\` (ad, email, sms, landing_page, video, social_post, conversation), \`--platform\` (meta, ghl, klaviyo, roezan, bayengage, activecampaign, mailchimp, hubspot, kit, instantly, kajabi, google_ads, tiktok, microsoft_ads, pinterest, web, youtube, instagram, facebook_page, callrail, zoom, fathom, google_drive, workspace, upload) and \`--origin\` (discovered or made) narrow it. \`--from\` and \`--to\` keep content live on those days: from its first day with numbers (or the day the library first saw it) to its last.
25
30
  - \`--sort\` is \`recent\` (the default, newest first) or any number in the \`content_scorecard\` view, such as \`click_rate\`, \`open_rate\`, \`roas\`, \`spend\` or their \`_30d\` twins for the last 30 days. Content with money in several currencies has a number per currency and sorts by its best. Content without that number comes last. \`--order asc\` turns it around; \`--limit\` is 1 to 50 (default 10).
26
31
  - Each result has the item's id or SKU, its words, how many files it has, whether it has a transcript, and its scorecard numbers. \`${cli} content show <sku | item-id>\` prints one item in full: its words, how many versions it has, its transcript and every number; \`--format json\` adds every version, and links to its files.
27
32
  - To add marketing that lives somewhere no source connects to, such as a sales video, a webinar recording, a script or an email from another tool, run \`${cli} content capture --kind <kind> --name <name>\` with \`--body\`, \`--file\`, \`--source\` (where it came from) and \`--url\`. It is kept as found, with no SKU, so it is never approved or published; \`--platform upload\` finds these. Videos and audio are transcribed on their own; give \`--transcript @file\` when you already have one, so none is paid for.
@@ -32,16 +37,18 @@ To read the whole library with your own tools, such as "every email we have" or
32
37
 
33
38
  1. Run \`${cli} warehouse schema --summary\` before writing any SQL. It lists each table once: whether it is a table or a view, which source fills it, how many columns it has, and a one-line summary. Then run \`${cli} warehouse schema --table <name>\` (repeat for several) or \`--search <words>\` for the full detail of just the tables you need: every column with its Postgres type, nullability, and description. Plain \`${cli} warehouse schema\` prints every column of every table, which on a big warehouse is a lot. Read the descriptions: they say what null means and which metrics do not add up. Use only these names. Never guess a table or column name.
34
39
  2. Write one SQL statement in the PostgreSQL dialect. Refer to tables unqualified (\`FROM orders\`). The workspace schema is already on the search path.
35
- 3. Run it: \`${cli} warehouse query "SELECT ..."\`. For long SQL, use \`--file query.sql\` or pipe it on stdin.
40
+ 3. Run it: \`${cli} warehouse query "SELECT ..."\`. For long SQL, use \`--file query.sql\` or pipe it on stdin. To keep the rows out of your context, add \`--out results.csv\` (or \`.json\`): it saves every row the server returned and prints one line.
36
41
  4. Aggregate in SQL (\`GROUP BY\`, \`SUM\`, \`date_trunc\`) and add \`LIMIT\`. Do not pull raw rows to aggregate them yourself.
37
42
  5. Before trusting recent or old numbers, run \`${cli} sources list\`. It shows when each source last synced, its last error, and when it syncs next. \`${cli} sources show <id>\` adds each table's row count and date range, the history still to fetch, and the last sync runs.
38
43
 
44
+ For a worked start, run \`${cli} examples\`. It prints plain questions, such as "what did each campaign cost per booked call last month?", each with SQL that answers it, for only the tables this workspace has, and a note on what a wrong query on the same question gets wrong, such as adding money across currencies. Run one as it is with \`${cli} warehouse query\`, or change it. \`--format json\` prints \`{ examples: [{ id, question, needs, sql, notes }] }\`.
45
+
39
46
  ## Funnel, campaign and money reports
40
47
 
41
48
  For "how is the funnel doing?", "which campaigns are working?" and "where does the money come from and go?", start with these three reports before writing SQL. All three cover the last 30 days ending today in the workspace reporting time zone unless \`--from\` and \`--to\` (YYYY-MM-DD, both days included) say otherwise, and at most 400 days.
42
49
 
43
50
  - \`${cli} funnel [--grain day|week|month]\` prints ad spend, leads, booked calls, calls held, sales and net revenue per day, week (starting Monday) or month, with cost per lead, per booked call and per sale and ROAS, then totals for the whole range.
44
- - \`${cli} campaigns [--channel <channel>] [--limit <n>]\` prints each campaign's spend, leads, booked calls, sales and revenue, best first (most first-touch revenue, then most spend), then totals over every matching campaign. Each lead and sale is credited to the person's first touch and last touch side by side. \`--limit\` is 1 to 200 (default 50); \`--channel\` is meta, google, affiliate, a utm_source, or unknown.
51
+ - \`${cli} campaigns [--channel <channel>] [--limit <n>]\` prints each campaign's spend, leads, booked calls, sales and revenue, best first (most first-touch revenue, then most spend), then totals over every matching campaign. Each lead and sale is credited to the person's first touch and last touch side by side. \`--limit\` is 1 to 200 (default 50); \`--channel\` is meta, google, affiliate, a utm_source, unknown or recorded_in_crm.
45
52
  - \`${cli} money [--grain day|week|month]\` prints money in (gross, refunds, net, orders), ad spend, affiliate payouts, what is left after ad costs and ROAS per period, then totals for the whole range, money in per payment source (stripe, ghl) and the biggest refund days. Refunds come off on the day they were made, not the day of the sale. \`left_after_ad_costs\` is net less ad spend and affiliate payouts; it is not profit. Its \`roas\` is net over ad spend alone, so it differs from \`${cli} funnel\`'s, which divides by all spend including affiliate payouts.
46
53
  - Money is never converted or added across currencies: there is one row per currency, and one total per currency. In \`${cli} funnel\`, leads and calls are counted for the whole period and repeat on every currency row, so never add them across its rows. In \`${cli} campaigns\`, each campaign's leads and calls sit on the row of the currency it spends in (or a blank-currency row when it has no spend), so they do add up across rows and totals. Ratios come from the summed numbers, not averages. An empty cell means the source is not connected, or a ratio would divide by 0.
47
54
  - To go deeper, query the views behind them: \`daily_funnel\`, \`campaign_results_daily\`, \`daily_spend\` and \`daily_sales\`. Their descriptions in \`${cli} warehouse schema\` say what each counts.
@@ -92,9 +99,11 @@ Save anything you make before it goes anywhere: an ad written without a tool, an
92
99
 
93
100
  ### Publishing
94
101
 
95
- \`${cli} content publish <sku> --to <publisher> --target '<json>' --idempotency-key <key>\` publishes an approved item through one of ${brand.name}'s publishers, adding \`--plan <plan-id>\` when it is part of a launch plan and \`--run <run-id>\` from a helper's run. Only the approved version is published; an item edited after approval must be approved again. The server checks every rule first and refuses with each failure listed, recording nothing: one unfinished send per platform account, and at least 15 seconds between sends to one account. Otherwise it records exactly what it sends before sending it, then saves the platform's ids, marks the item launched, and moves the launch plan to live once all its items are out. For Meta without the \`--run\` of a helper in live mode, nothing is sent: the publication is saved as \`not_sent_practice\` with the full request. A send that may or may not have landed stays \`sending\` and is never retried; \`${cli} content reconcile <publication-id>\` asks the platform and finishes it. The same key returns the same publication. A page host is the publisher \`page_host:<host>\` with the target \`{"site":"...","path":"/..."}\`: it puts an approved landing page's HTML online, tags each link that leaves the page with the SKU, and is never held back by practice mode. \`${cli} content show <sku>\` lists the page's deployments with their ids. \`${cli} content unpublish <deployment-id>\` takes one live page down; the site's other pages stay up, and the deployment records when and by whom. It is refused while a synced Meta ad that is delivering links to the page, naming each ad, because a broken link in a running ad wastes money; \`--force\` takes it down anyway. Never roll a site back in Vercel's dashboard instead: the next deploy brings the rolled-away pages back.
102
+ \`${cli} content publish <sku> --to <publisher> --target '<json>' --idempotency-key <key>\` publishes an approved item through one of ${brand.name}'s publishers, adding \`--plan <plan-id>\` when it is part of a launch plan and \`--run <run-id>\` from a hand's run. Only the approved version is published; an item edited after approval must be approved again. The server checks every rule first and refuses with each failure listed, recording nothing: one unfinished send per platform account, and at least 15 seconds between sends to one account. Otherwise it records exactly what it sends before sending it, then saves the platform's ids, marks the item launched, and moves the launch plan to live once all its items are out. For Meta without the \`--run\` of a hand in live mode, nothing is sent: the publication is saved as \`not_sent_practice\` with the full request. A send that may or may not have landed stays \`sending\` and is never retried; \`${cli} content reconcile <publication-id>\` asks the platform and finishes it. The same key returns the same publication. A page host is the publisher \`page_host:<host>\` with the target \`{"site":"...","path":"/..."}\`: it puts an approved landing page's HTML online, tags each link that leaves the page with the SKU, and is never held back by practice mode. \`${cli} content show <sku>\` lists the page's deployments with their ids. \`${cli} content unpublish <deployment-id>\` takes one live page down; the site's other pages stay up, and the deployment records when and by whom. It is refused while a synced Meta ad that is delivering links to the page, naming each ad, because a broken link in a running ad wastes money; \`--force\` takes it down anyway. Never roll a site back in Vercel's dashboard instead: the next deploy brings the rolled-away pages back.
103
+
104
+ The \`meta_ads\` publisher puts an approved ad on Facebook, always paused, in an ad set that already exists, \`--to meta_ads --target '{"accountId": "act_<id>", "adsetId": "<id>"}'\`, or in one made with \`${cli} meta adset publish\` (below), \`"adsetPublicationId": "<id>"\` in place of \`adsetId\`. \`"urlTags": "sku"\` (the default) adds the SKU tracking tags to the link; \`"urlTags": "none"\` sends the link exactly as written, Meta macros such as \`{{ad.name}}\` included. The ad needs pictures or a video (not both), body text and an https link in \`fields.link_urls\` (\`fields.cta\`, such as "Shop now", sets the button; \`fields.bodies\`, \`fields.titles\` and \`fields.descriptions\` add variations). The account, the ad set's campaign and the ad set must all be on the hand's publish list, which only an operator sets, and the ad set and campaign must be in the last Meta sync and not archived. A live send also needs a run of the Facebook ads hand, Meta numbers pulled under 12 hours ago, and room under the daily limit of new ads. The ad is named \`<SKU> | <name>\` and its creative's \`url_tags\` carry the SKU. In practice mode the same command stores the full request for review and sends nothing. Each publication's \`targetSnapshot\` names the campaign and ad set as they were at publish time.
96
105
 
97
- The \`meta_ads\` publisher puts an approved ad on Facebook, always paused, in an ad set that already exists: \`--to meta_ads --target '{"accountId": "act_<id>", "adsetId": "<id>"}'\`. The ad needs pictures or a video (not both), body text and an https link in \`fields.link_urls\` (\`fields.cta\`, such as "Shop now", sets the button; \`fields.bodies\`, \`fields.titles\` and \`fields.descriptions\` add variations). The account, the ad set's campaign and the ad set must all be on the helper's publish list, which only an operator sets, and the ad set and campaign must be in the last Meta sync and not archived. A live send also needs a run of the Facebook ads helper, Meta numbers pulled under 12 hours ago, and room under the daily limit of new ads. The ad is named \`<SKU> | <name>\` and its creative's \`url_tags\` carry the SKU. In practice mode the same command stores the full request for review and sends nothing. Each publication's \`targetSnapshot\` names the campaign and ad set as they were at publish time.
106
+ \`${cli} meta adset publish --plan '<json>' --idempotency-key <key> --run <run-id>\` makes one new Meta ad set, always paused, for ads to go into. A copy, \`{"kind": "copy", "accountId": "act_<id>", "campaignId": "<id>", "sourceAdsetId": "<id>", "name": "<new name>"}\`, copies a proven ad set without its ads; the source must be on the hand's list of ad sets that may be copied. A new one from settings is \`{"kind": "create", "accountId": ..., "campaignId": ..., "name": ..., "settings": {"optimizationGoal": "...", "billingEvent": "...", "targeting": {...}, "dailyBudget": 5000}}\`, with the budget in the currency's smallest unit (5000 is 50.00 USD) and left out when the campaign holds it. The account and campaign must be on the publish list, the name unused in the campaign, and the day under the limit of new ad sets (3 unless an operator sets another). Ad sets and ads share one account's turn: one unfinished send at a time, and at least 15 seconds apart. ${brand.name} reads the new ad set back and checks it: paused, in the right campaign, with the right name, and for a copy, targeting equal to the source's. Its \`verification.ok\` must be true before you add ads; an ad set that fails the check stays paused and takes none. In practice mode nothing is sent and the ad set is recorded as \`not_sent_practice\`, and ads published into it are practice too. \`${cli} meta adset show <id>\` prints it with its budget. One left \`sending\` or \`unknown\` is never sent again; \`${cli} meta adset reconcile <id>\` looks for it in the campaign by its exact name.
98
107
 
99
108
  When you send something yourself instead, record it in ${brand.name} before anything is sent, so the record never depends on the platform remembering it:
100
109
 
@@ -102,7 +111,7 @@ When you send something yourself instead, record it in ${brand.name} before anyt
102
111
  2. \`${cli} content publish-finish <publication-id> --status sending\` immediately before the platform call.
103
112
  3. Right after, \`${cli} content publish-finish <publication-id> --status published --input '{"externalId": "<platform id>"}'\`, adding \`links\` for other ids (such as \`[{"objectType": "creative", "externalId": "..."}]\`) and \`response\`; or \`--status failed --input '{"error": "..."}'\`. Published ids join the item to its results.
104
113
 
105
- A publication still \`sending\` after 15 minutes reads \`unknown\`: the send may or may not have happened. Never send it again; check the platform, then finish it as \`published\` or \`failed\`. Unless the publication was recorded with the \`runId\` of a helper in live mode, ${brand.name} refuses \`sending\`, \`published\` and \`failed\` for Meta: finish the record as \`not_sent_practice\` instead. \`${cli} content show <sku>\` lists each publication, and the \`content_publications\` table holds them all.
114
+ A publication still \`sending\` after 15 minutes reads \`unknown\`: the send may or may not have happened. Never send it again; check the platform, then finish it as \`published\` or \`failed\`. Unless the publication was recorded with the \`runId\` of a hand in live mode, ${brand.name} refuses \`sending\`, \`published\` and \`failed\` for Meta: finish the record as \`not_sent_practice\` instead. \`${cli} content show <sku>\` lists each publication, and the \`content_publications\` table holds them all.
106
115
 
107
116
  When a sync later brings back something made here, found by a published id, an id already linked, or the SKU in its name, it is recorded on the made item, not as a new discovered one. A copy edited on the platform becomes a new version captured by the sync.
108
117
 
@@ -118,28 +127,48 @@ The \`touches\` view reads the SKU back into its \`sku\` column, and \`item_resu
118
127
 
119
128
  A launch plan writes down one campaign push before you make things for it, and keeps those things together: what the push is for, where it runs, the landing page its ads send people to, and the saved items that belong to it. Nothing in a plan is sent anywhere.
120
129
 
121
- 1. \`${cli} plans create --name "P1 cold, guarantee page" --goal "Five new static ads" --channel meta --target '{"account_id": "act_1", "campaign_id": "<id>", "adset_id": "<id>"}' --landing-url https://example.com/guarantee\` makes a draft plan and prints its id. Channels are \`meta\`, \`ghl\`, \`klaviyo\` and \`web\`. A meta plan's target uses ids from \`meta_campaigns\` and \`meta_adsets\`, never names: \`adset_id\` adds the ads to that ad set, \`template_adset_id\` instead copies that ad set for the push. The campaign and ad set must be synced and belong together, and with \`--run <agent-run-id>\` they must be among the campaigns your agent may touch. \`--landing <sku>\` names a landing page made here instead of an address.
130
+ 1. \`${cli} plans create --name "P1 cold, guarantee page" --goal "Five new static ads" --channel meta --target '{"account_id": "act_1", "campaign_id": "<id>", "adset_id": "<id>"}' --landing-url https://example.com/guarantee\` makes a draft plan and prints its id. Channels are \`meta\`, \`ghl\`, \`klaviyo\` and \`web\`. A meta plan's target uses ids from \`meta_campaigns\` and \`meta_adsets\`, never names: \`adset_id\` adds the ads to that ad set, \`template_adset_id\` instead copies that ad set for the push. The campaign and ad set must be synced and belong together, and with \`--run <agent-run-id>\` they must be among the campaigns your agent may touch. \`--landing <sku>\` names a landing page made here instead of an address. \`--funnel <key> --step <key>\` says which step of a funnel on the confirmed map the push feeds, such as \`--funnel webinar_to_course --step ad\`; keys are in \`${cli} funnels show\`.
122
131
  2. \`${cli} content create --kind ad ... --plan <plan-id>\` saves a new item straight into the plan. \`${cli} plans add <plan-id> <sku>\` adds one already saved; an item may sit in several plans. The role defaults from the item's kind (\`ad\`, \`email\`, \`sms\`, \`landing_page\`, \`video\`, or \`post\` for a social post); \`--role copy\` or \`--role image\` says otherwise, and \`--position <n>\` sets its place. \`${cli} plans remove <plan-id> <sku>\` takes it out.
123
132
  3. \`${cli} plans status <plan-id> ready\` moves the plan one step along \`draft\`, \`ready\`, \`publishing\`, \`live\`, \`done\`. \`cancelled\` ends a plan that is not done. A done or cancelled plan cannot be edited.
124
- 4. \`${cli} plans show <plan-id>\` prints the plan and its items in order. Every plan is in the \`content_launch_plans\` and \`content_launch_plan_items\` tables for \`${cli} warehouse query\`.
133
+ 4. \`${cli} plans show <plan-id>\` prints the plan and its items in order. A plan that feeds a funnel step also shows the brief: which step of which funnel, what that funnel sells, and the funnel's steps; \`--format json\` adds each step's numbers for the last 30 days. If the current map has since dropped that step, it shows the step from the map version the plan was made with, and a funnel note says so. Every plan is in the \`content_launch_plans\` and \`content_launch_plan_items\` tables for \`${cli} warehouse query\`.
125
134
 
126
135
  ## The campaign trail
127
136
 
128
137
  \`${cli} trail <plan-id>\` follows a plan's items from how they were made to where they ran and how they did; \`${cli} trail <sku>\` does it for one item. For each item it shows the paid tool runs it was made from (the tool, the prompt and the cost; \`--format json\` has every prompt) and every place it was published: one line per placement, so an ad in three ad sets has three lines. Each Meta line names the campaign, ad set and ad by their name now, with the name they had when published in brackets when it changed, and the ad's spend and purchases. Leads and sales are per item, credited to its SKU. Everything joins by Meta id, so a renamed campaign or an ad whose name lost its SKU still shows. For SQL, the same lines are in the \`content_trail\` and \`content_trail_results\` views.
129
138
 
139
+ ## Offers and funnels
140
+
141
+ Each workspace keeps one map of what the client sells (the offer stack) and the steps a person walks from first seeing an ad to buying (its funnels). \`${cli} funnels show\` prints it in plain words: the version and who confirmed it, each offer in stack order with its tier, price and delivery, then each funnel's numbered steps with the offer it sells, its assets (marked \`(not found)\` when the id is not in synced data yet) and what counts as reaching it. It ends with the draft on hold, if any, printed in full. \`--version <n>\` prints an older version; \`--format json\` prints the whole map, which is the shape a draft takes.
142
+
143
+ To change the map, write the whole map as JSON (\`{"offers": [...], "funnels": [...]}\`, the \`map\` of \`${cli} funnels show --format json\`) and run \`${cli} funnels draft --file map.json --note "<what changed and why>"\`. Add \`--evidence '<json>'\` or \`@file.json\` with what you looked at: counts and ids, never names, emails or phones. It is checked against synced data, and when every step's rule is filled in it goes live at once, confirmed by \`system:auto\`; a person can undo it in the app. A map with a rule not filled in stays on hold as the draft and counts nothing; saving replaces the draft on hold. Never invent an id or a number to fill a rule. Pass \`--based-on <n>\` with the version you read (the draft on hold, else the confirmed map): if someone saved since, the save is refused as \`draft_conflict\`, and you show again and redo your change on top. Leave out \`--based-on\` only for the first map, when there is neither a map nor a draft; once there is one, a save without it is refused as \`draft_conflict\`. A map that does not fit is refused with every problem by path, such as \`funnels[0].steps[1].offer_key: no offer has the key x\`. Saving prints whether the map went live, what is left to fill in (an asset not synced yet, a rule with no calendar chosen), and the parts synced data does not back up, such as an offer with fewer than 3 buyers in a year.
144
+
145
+ \`${cli} funnels numbers\` counts each step of each funnel in the confirmed map over \`--from\` and \`--to\` (default the last 30 days; \`--funnel <key>\` for one funnel): how many reached it, and how many of those who reached the step before in those days moved on to it within the window, with the drop-off and the median days it took. A step whose rules name a person (webinar, GoHighLevel, payments) counts people and follows the same people from step to step; a step that cannot (ad clicks, page visitors) counts events, and its comparison is a rate of counts. A step whose data is not connected says unknown, never 0. Numbers are worked out after each sync by the rules of the map version confirmed at the time, so a renamed step keeps its numbers. For SQL, read \`saved_funnel_steps\`, \`funnel_step_daily\` and \`funnel_conversion\`.
146
+
147
+ \`${cli} funnels evidence\` gathers what the workspace's own data says it sells, to draft a map from: the prices people paid in the last 365 days on each platform with buyer counts, the most common orders in which one person paid (\`1500 → 497 → 3500\`, with the median days between), GoHighLevel funnels and their steps, calendars with bookings and shows, pipeline stages, the most used tags, webinars with sign-ups, viewers and offer clicks, the landing pages ads link to, and active Facebook campaigns with 30-day spend. A part whose source is not connected says so. Ids, counts and amounts only. The \`map-offers\` playbook (\`${cli} skill install map-offers\` installs it as the \`${MAP_OFFERS_SKILL_NAME}\` skill, with a worked example map beside it; \`${cli} docs map-offers\` prints it) walks an agent from the evidence to a draft.
148
+
149
+ \`${cli} funnels versions\` lists every version with its status (draft, confirmed, replaced or discarded), who wrote and confirmed it and its note; \`--diff <a> <b>\` says what changed from version a to version b.
150
+
151
+ ## Settled facts
152
+
153
+ Each workspace keeps settled facts about its data: things worked out once and true until something changes them, such as "Facebook ad numbers only go back to 2026-04-07" or "the bulk external payments in GoHighLevel are another business's sales; never credit them to ads". Read them before you count anything. \`${cli} facts\` lists them (\`--kind <kind>\`, \`--subject <type>[:<ref>]\` such as \`--subject table:ghl_payments\`, \`--status proposed|confirmed|rejected|retired\`, \`--json\`); by default it shows the proposed and confirmed ones. \`${cli} facts show <id>\` prints one with its value and evidence.
154
+
155
+ Kinds are \`exclusion\`, \`gap\`, \`baseline\`, \`change\`, \`definition\`, \`campaign_role\`, \`ad_status\`, \`watch\`. A suggested gap, change, watch or campaign role shows at once, its summary starting "Not yet confirmed:". An exclusion, baseline, definition or ad status changes money numbers or what counts, so it shows only once a person confirms it. The same rows are in the \`facts\` table: leave out what a confirmed exclusion names with \`WHERE status = 'confirmed'\`.
156
+
157
+ To suggest a fact, write it as JSON and run \`${cli} facts propose --file fact.json\` (or pipe it on stdin): \`{"kind": "gap", "subject_type": "table", "subject_ref": "meta_insights_daily", "key": "history_start", "value": {"table": "meta_insights_daily", "from": "2026-04-07"}, "summary": "Facebook ad numbers only go back to April 7, 2026.", "evidence": {"query": "SELECT min(date) FROM meta_insights_daily"}}\`. You only suggest: a person confirms, fixes or rejects it in the app. A fact with the same kind, subject and key already open is refused as \`fact_exists\`. Evidence is counts, queries and ids, never names, emails or phones.
158
+
130
159
  ## The Facebook ads agent
131
160
 
132
- The daily Facebook ads agent follows its own playbook: install it with \`${cli} skill install meta-ads\` (or \`--codex\` for an AGENTS.md in the current directory) and read it with \`${cli} docs meta-ads\`. It works in runs (\`${cli} agent start\`, \`${cli} agent finish\`), keeps four memory notes (\`${cli} memory\`), a brand profile (\`${cli} brand\`), and makes proposals (\`${cli} proposals\`) that a person approves in the web app. Nothing it does is sent to Meta.
161
+ The daily Facebook ads agent follows its own playbook: install it with \`${cli} skill install meta-ads\` (or \`--codex\` for an AGENTS.md in the current directory) and read it with \`${cli} docs meta-ads\`. It works in runs (\`${cli} agent start\`, \`${cli} agent finish\`), reads and suggests settled facts (\`${cli} facts\`), keeps a brand profile (\`${cli} brand\`), and makes proposals (\`${cli} proposals\`) that a person approves in the web app. Nothing it does is sent to Meta.
133
162
 
134
- ## The P3 refresh helper
163
+ ## The P3 refresh hand
135
164
 
136
- The weekly P3 refresh helper is a second agent. Pass \`--agent p3_refresh\` to the agent and memory commands to use it instead of the default \`--agent meta_ads\`. \`${cli} agent draft --agent p3_refresh\` prints the counted draft its run starts from: the P3 purchase campaign's ad sets ranked over 7, 14 and 30 completed days, the audience template, and every P1 winner marked new, already in P3, or unresolved. Its run then proposes one \`p3_refresh_batch\` with \`${cli} proposals create\`: the ad set to retire, the template ad set, and the roster of ads, for a person to approve. Its playbook installs with \`${cli} skill install p3-refresh\` (or \`--codex\` for a Codex skill under \`$CODEX_HOME/skills\`) and prints with \`${cli} docs p3-refresh\`.
165
+ The weekly P3 refresh hand is a second agent. Pass \`--agent p3_refresh\` to the agent commands to use it instead of the default \`--agent meta_ads\`. \`${cli} agent draft --agent p3_refresh\` prints the counted draft its run starts from: the P3 purchase campaign's ad sets ranked over 7, 14 and 30 completed days, the audience template, and every P1 winner marked new, already in P3, or unresolved. Its run then proposes one \`p3_refresh_batch\` with \`${cli} proposals create\`: the ad set to retire, the template ad set, and the roster of ads, for a person to approve. Its playbook installs with \`${cli} skill install p3-refresh\` (or \`--codex\` for a Codex skill under \`$CODEX_HOME/skills\`) and prints with \`${cli} docs p3-refresh\`.
137
166
 
138
- ## Workers
167
+ ## Hands on a schedule
139
168
 
140
- A worker is a skill from the library that runs for this workspace on a schedule. \`${cli} worker list\` shows each one: its skill, the version in use (and one on trial), schedule and time zone, mode, runner, whether it is on, its next run, and how its last run went. \`${cli} worker runs <worker-id | skill> [--limit <n>]\` shows its last runs, newest first: status (a skipped run says why), when it was queued and finished, how long it took, its cost as AI, tools and compute, its error, and the first line of its report. \`${cli} worker run-now <worker-id | skill>\` queues one run now, even for a worker that is off. It is refused (\`open_run\`) while the worker has a run queued or going, and (\`monthly_cap\`) when the run could take the workspace over its monthly limit for workers. A worker run's own key cannot start runs. Only the operator changes a worker's schedule, mode or limits.
169
+ A scheduled hand is a skill from the library that runs for this workspace on a schedule. The commands for them are named \`worker\`, from before hands had their name. \`${cli} worker list\` shows each one: its skill, the version in use (and one on trial), schedule and time zone, mode, runner, whether it is on, its next run, and how its last run went. \`${cli} worker runs <hand-id | skill> [--limit <n>]\` shows its last runs, newest first: status (a skipped run says why), when it was queued and finished, how long it took, its cost as AI, tools and compute, its error, and the first line of its report. \`${cli} worker run-now <hand-id | skill>\` queues one run now, even for a hand that is off. It is refused (\`open_run\`) while the hand has a run queued or going, and (\`monthly_cap\`) when the run could take the workspace over its monthly limit for scheduled hands. A scheduled run's own key cannot start runs. Only the operator changes a hand's schedule, mode or limits.
141
170
 
142
- A worker's run acts as its skill's agent, the way the Facebook ads agent works in runs. Inside a run the agent and memory commands use \`--agent skill\` unless told otherwise, and only a worker run's own key can. A published skill should run \`${cli} agent start\` first, which opens the run's log and prints its run id and notes, and \`${cli} agent finish <run-id> --status succeeded|failed --file <report.md>\` last, with a report whose first line says what the run did. Each run of the worker gets a run of its own; the four notes (\`${cli} memory\`) carry over from one run to the next. A skill's agent proposes draft ads (\`creative\`) with \`${cli} proposals create\` and the run id, under the same daily limit as a new Facebook ads agent, and nothing else.
171
+ A scheduled hand's run acts as its skill's agent, the way the Facebook ads agent works in runs. Inside a run the agent commands use \`--agent skill\`. A scheduled run's key acts only as its own skill's agent, never as the Facebook ads or P3 agent, and only that key can act as it. A published skill should run \`${cli} agent start\` first, which opens the run's log and prints its run id, and \`${cli} agent finish <run-id> --status succeeded|failed --file <report.md>\` last, with a report whose first line says what the run did. Each run of the scheduled hand gets a run of its own; what one run should pass to the next goes in settled facts (\`${cli} facts propose\`). A skill's agent proposes draft ads (\`creative\`) with \`${cli} proposals create\` and the run id, under the same daily limit as a new Facebook ads agent, and nothing else.
143
172
 
144
173
  ## Output
145
174
 
@@ -161,8 +190,9 @@ ${cli} login validate a key (prompted, piped, or ${envPre
161
190
  ${cli} logout delete saved credentials
162
191
  ${cli} whoami show the current workspace
163
192
  ${cli} guide what this workspace's data holds, how fresh it is, and what is missing: read it first
193
+ ${cli} brief [--from <date>] [--to <date>] [--channel <channel>] [--campaign <id>] [--top <n>] the offer, voice, best and worst ads, pages, results and audience: start here before writing ads
164
194
  ${cli} warehouse schema [--summary] [--table <name> ...] [--search <words>] list tables and columns; --summary for one row per table
165
- ${cli} warehouse query "<sql>" run one read-only query (or --file <path>, or stdin)
195
+ ${cli} warehouse query "<sql>" run one read-only query (or --file <path>, or stdin); --out <file.csv | file.json> saves the rows
166
196
  ${cli} funnel [--from <date>] [--to <date>] [--grain day|week|month] spend, leads, calls and sales per period, with totals
167
197
  ${cli} money [--from <date>] [--to <date>] [--grain day|week|month] money in, refunds, ad spend, payouts and what is left, per period, with totals
168
198
  ${cli} campaigns [--from <date>] [--to <date>] [--channel <channel>] [--limit <n>] each campaign's results, best first, with totals
@@ -193,22 +223,29 @@ ${cli} content diff <sku | item-id> [--from <n>] [--to <n>] what changed betwe
193
223
  ${cli} content publish <sku> --to <publisher> --target '<json>' --idempotency-key <key> [--plan <id>] [--version <n>] [--run <run-id>] publish an approved item
194
224
  ${cli} content reconcile <publication-id> check whether a publish left sending landed
195
225
  ${cli} content unpublish <deployment-id> [--force] take one deployed landing page down
226
+ ${cli} meta adset publish --plan '<json>' --idempotency-key <key> [--run <run-id>] make a paused Meta ad set to add ads to
227
+ ${cli} meta adset show <id> | meta adset reconcile <id> read an ad set publication, or check whether one left sending landed
196
228
  ${cli} content publish-record <sku> --file <publication.json> record a publish before sending it
197
229
  ${cli} content publish-finish <publication-id> --status sending|published|failed|not_sent_practice [--input '<json>']
198
230
  ${cli} content track-link <sku> <url> [--channel facebook|email|sms|web] [--campaign <id>] tag a link with its SKU
199
231
  ${cli} agent start | show <run-id> | runs | finish <run-id> --status <s> --file <report> [--agent meta_ads|p3_refresh]
200
- ${cli} agent draft --agent p3_refresh the P3 refresh helper's counted draft
201
- ${cli} memory get [<note>] | memory set <note> --file <path> [--agent meta_ads|p3_refresh]
232
+ ${cli} agent draft --agent p3_refresh the P3 refresh hand's counted draft
202
233
  ${cli} brand get | brand set --file <path> [--sources '<json>' | @file.json]
203
234
  ${cli} proposals create --input '<json>' | proposals list [--status <s>] [--run <run-id>]
204
235
  ${cli} content add-page <url> add a landing page and read it now
205
- ${cli} plans create --name <name> --goal <text> --channel meta|ghl|klaviyo|web [--target '<json>'] [--landing <sku> | --landing-url <url>] [--run <agent-run-id>] plan a campaign push
236
+ ${cli} plans create --name <name> --goal <text> --channel meta|ghl|klaviyo|web [--target '<json>'] [--landing <sku> | --landing-url <url>] [--funnel <key> --step <key>] [--run <agent-run-id>] plan a campaign push
206
237
  ${cli} plans show <plan-id> | plans add <plan-id> <sku> [--role <role>] [--position <n>] | plans remove <plan-id> <sku>
207
238
  ${cli} plans status <plan-id> draft|ready|publishing|live|done|cancelled
208
239
  ${cli} trail <plan-id | sku | item-id> how a plan's items were made, where they ran, and their results
209
- ${cli} worker list | worker runs <worker-id | skill> [--limit <n>] | worker run-now <worker-id | skill> this workspace's workers, their runs and costs, and Run now
210
- ${cli} skill install [meta-ads|p3-refresh] [--project] [--codex] install a Claude Code skill (or the playbook for Codex)
211
- ${cli} docs [meta-ads|p3-refresh] print this guide, or a helper's playbook
240
+ ${cli} funnels show [--version <n>] | funnels versions [--diff <a> <b>] the offers and funnels map, as text, and its versions
241
+ ${cli} funnels numbers [--funnel <key>] [--from <date>] [--to <date>] how many reached each funnel step and moved on
242
+ ${cli} funnels evidence prices paid, buyer paths, GoHighLevel funnels, calendars and tags, webinars, pages and campaigns, to draft the map from
243
+ ${cli} funnels draft --file <map.json> [--note <why>] [--evidence '<json>' | @file.json] [--based-on <n>] save the whole map; it goes live when every step can be counted
244
+ ${cli} facts [--kind <kind>] [--subject <type>[:<ref>]] [--status <status>] [--json] | facts show <id> settled facts about the data, to treat as true
245
+ ${cli} facts propose --file <fact.json> suggest a fact for a person to confirm
246
+ ${cli} worker list | worker runs <hand-id | skill> [--limit <n>] | worker run-now <hand-id | skill> this workspace's scheduled hands, their runs and costs, and Run now
247
+ ${cli} skill install [${PLAYBOOK_CHOICES}] [--project] [--codex] install a Claude Code skill (or the playbook for Codex)
248
+ ${cli} docs [${PLAYBOOK_CHOICES}] print this guide, or a hand's playbook
212
249
  \`\`\`
213
250
 
214
251
  Global options: \`--format table|json\`, \`--api-url <url>\` (or \`${envPrefix}_API_URL\`).