@flowapt/flowiq-cli 0.9.6 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,9 +77,13 @@ Round-trips an org's active WhatsApp agent's system prompt
77
77
  flowiq prompts pull <organization_id>
78
78
  # edit ./.flowiq/prompts/<slug>.json — modify section titles/content
79
79
  flowiq prompts push <slug>
80
+ flowiq prompts pull <organization_id> --out /tmp/check.json # a scratch copy; the working file is untouched (0.9.8)
80
81
  ```
81
82
 
82
83
  Notes:
84
+ - **A new section needs no id (0.9.8).** Add `{ "title": …, "content": … }` and push:
85
+ the CLI writes a fresh uuid into the local file first, says which sections got
86
+ one, then pushes, so the next pull keeps the same id.
83
87
  - `system_prompt` is regenerated server-side from `prompt_sections` on
84
88
  push using the `## SECTION: <title>` format.
85
89
  - Sections support optional `hidden: boolean` + `channels: string[]` (`web`, `whatsapp`, `messenger`, `instagram`, `email`; empty = every channel)
@@ -348,12 +352,17 @@ flowiq bc route <org_id> <broadcastId> --clear --commit # switc
348
352
  the **full broadcastId** per row + template, status, recipient count and SAST
349
353
  created time — the discovery step `status` / `retry` need (previously the id
350
354
  existed only in the send output or the DB). `--limit <n>` (default 25, max 200),
351
- `--template <substr>` (case-insensitive contains), `--since <date>`, `--json`.
352
- Read-only. NOTE: plain `bc list` (no org) still lists your **local campaign
355
+ `--template <substr>` (case-insensitive contains), `--since <date>`,
356
+ `--min-recipients <n>` (0.9.8: `2` hides one-recipient traffic like popup welcomes
357
+ and cart reminders, which otherwise fill the cap and hide campaign sends), `--json`.
358
+ When more rows match than are shown it prints `N of M shown` and says so. Read-only. NOTE: plain `bc list` (no org) still lists your **local campaign
353
359
  files** — the remote verb is named after `pinboard list-remote`.
354
360
  - **`status <org> <broadcastId>` (v0.3.5)**: live delivery **funnel** for a
355
361
  broadcast by its id — `accepted` → `delivered` → `read`, plus `pending` and
356
- `failed`, with percentages, from `helpdesk_messages`. Built for the `--python`
362
+ `failed`, with percentages, from `helpdesk_messages`. Since 0.9.8 it also prints
363
+ what the send said (`sent with:` header / body / button slot values) and, for a
364
+ button short code, the link's destination with its UTM tags and click count
365
+ (`payload` in `--json`). Built for the `--python`
357
366
  fire-and-forget engine (which returns a `broadcastId` but has no CLI status
358
367
  log), but works for any broadcast id (dashboard sends included).
359
368
  **`--failures` (v0.3.7)** additionally lists each failed recipient + the Meta
@@ -654,6 +663,14 @@ flowiq doctor --json # same, machine-readable; exits 1 when something needs fi
654
663
  ✓ server contract 0.6.8 or newer · build 1f0fd48c
655
664
  ```
656
665
 
666
+ **A Vercel security checkpoint is named as one (0.9.8).** When Vercel's bot
667
+ protection challenges this network, every command (and `doctor`) now says so:
668
+ "Vercel is challenging this network… not your key". Wait a few minutes or switch
669
+ network; do not re-login and do not narrow the request. Only a JSON 401/403 from
670
+ the API means the key itself was refused. A timeout on a WRITE now says the server
671
+ may have finished it (check before retrying); `templates create` names the exact
672
+ `templates status --live` command to check with.
673
+
657
674
  **Three ways this install tells you it is stale**, so nobody — person or AI
658
675
  assistant — has to remember to check:
659
676
 
@@ -745,6 +762,12 @@ Spring Promotion is **`9Sep_SpringPromotion`**.
745
762
 
746
763
  - The resolved tag is **printed before anything is minted**, and `shorten` is
747
764
  dry-run by default, so you always see it first.
765
+ - **A running campaign never forks (0.9.8).** Before minting, the org's tags from
766
+ the last 120 days are checked: when one normalises to the same tag but is
767
+ spelled differently (`5Sept_BraaiDayOffer` running, you asked for
768
+ `5Sep_BraaiDayOffer`), the dry run prints `⚠ CAMPAIGN FORK` and `--commit` is
769
+ refused. Reuse the running tag with `--campaign 5Sept_BraaiDayOffer --raw-campaign`,
770
+ or pass `--new-campaign` when it really is a separate campaign.
748
771
  - `--date` takes `9Sep`, `2026-09-24` or `24/9/2026` — use it whenever the send
749
772
  is not today.
750
773
  - `--content` is the **per-link** tag (which button, which product), so it gets
@@ -797,6 +820,17 @@ flowiq kw push <slug> # id-keyed upsert (never touches keywor
797
820
  flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from the file (dry-run first!)
798
821
  ```
799
822
 
823
+ - **Runtime order and collisions (0.9.8).** `kw pull` lists ACTIVE rows in the
824
+ order the runtime tries them (by `type`, first match answers), then the inactive
825
+ ones, and prints `⚠ TRIGGER COLLISION` when two active rows match the same
826
+ message: which row wins, and whether `allowed_tags` lets the other one answer
827
+ some contacts. `kw push` warns the same way (a dry run predicts the order).
828
+ - **Schedule labels.** The keyword-scheduler cron switches `active` on at
829
+ `start_date` and off at `end_date` (every 30 min); the runtime reads `active`
830
+ only. So `kw pull` shows `starts … SAST` on a scheduled inactive row, and a loud
831
+ `⚠ LIVE BEFORE ITS START` on an active row whose start is still ahead (it is
832
+ answering now), or `⚠ PAST ITS END` on one not yet switched off.
833
+
800
834
  - **Identity = the `id`s.** Keep an id to update that row; drop the id to
801
835
  create a new one; remove an *action* from a kept keyword to delete it.
802
836
  A file id that doesn't exist for the org aborts (stale file → re-pull).
@@ -915,7 +949,11 @@ flowiq audit show <audit_id> --out entry.json # dump the whole entry
915
949
  ```
916
950
 
917
951
  Filters: `--endpoint --action --status --agent --target --user --since --until
918
- --limit` (default 50, max 200) `--json`.
952
+ --before --limit` (default 50, max 200) `--json`. `--since` / `--until` take a date
953
+ (SAST day start), an ISO time, or a window like `2d` / `12h` / `2w` (0.9.8). A
954
+ `--limit` above 200 is clamped (it used to fall back to 50 silently); when more
955
+ entries match than are shown, the header reads `200 of 1,044 matching entries`
956
+ and the footer prints the `--before <created_at>` that pages further back.
919
957
 
920
958
  **What gets logged.** Every command that CHANGES something writes a row —
921
959
  including two that were missed until 4 Aug 2026 and are now covered:
@@ -1273,10 +1311,13 @@ flowiq report deck pull <org_id> 2026-08 # → ./.flowiq/repor
1273
1311
  predates the current build logic, unless it is approved, sent or held).
1274
1312
  - Send fees are estimated at the category each template had on its send day. A send whose category
1275
1313
  cannot be known shows no fee and is left out of the broadcast return (`totals.unpriced_sends`).
1276
- - `inputs.json` shape: `{ "changes": { "<update_id>": { "hidden": false, "tag": "client_raised|flowapt_shipped", "title": "…", "description": "…" } }, "changes_reviewed": true, "action_points": { "<title>": "done|in_progress|waiting_on_you|ongoing|dropped" }, "waiting_on_you": [{ "title": "…", "unlocks": "…" }], "recipients_confirmed": true }`.
1314
+ - `inputs.json` shape: `{ "changes": { "<update_id>": { "hidden": false, "tag": "client_raised|flowapt_shipped", "title": "…", "description": "…" } }, "changes_reviewed": true, "action_points": { "<title>": "done|in_progress|waiting_on_you|ongoing|dropped" }, "waiting_on_you": [{ "title": "…", "unlocks": "…" }], "recipients_confirmed": true, "hidden": { "tile:tail": true, "pill:new_contacts": true } }`.
1315
+ `hidden` (29 Sep 2026) takes any tile, pill, card or block off the slides and the PDF for that month only: the keys are what the deck page's rail lists (`tile:…`, `pill:<metric>`, `card:…`, `block:…`, `box:…`, `strip:…`); a metric's pill hides everywhere that metric shows. The copy is not rewritten when something is hidden.
1277
1316
  Free text (milestone, the four plan fields) is edited in the app; it is stored as overrides that survive `narrate`.
1278
1317
  - `send --client` needs an approved deck AND `report.email.recipients` in the org's reporting config (Control center → Report delivery); it marks the deck `sent`. `--test-to` never changes status. Fees on the slides are the org's actual Meta billing when the token can read it, otherwise the rate-card estimate, and the footnote says which.
1279
1318
  - Non-store orgs need `config.deck.outcome` (`source: handover | ticket_status | tag | keyword`, labels) — the revenue slides become outcome slides. Every verb except `status` and `pull` is audited.
1319
+ - A month with a Customer Insights report gets the "What your customers asked" slide after slide 7 (29 Sep 2026): `build` takes the one `org_insights` export-insights row whose window covers the most of the month (top questions, trending topics, complaints or product mentions, sentiment, each as "N customers"), stores it under `deck.insights`, and the copy carries `insights_title` + `insights_read`. `status --json` shows the window under `insights` (null = no report, no slide). The timing slide's next-step card is now written by the narrator (`next_step_title` / `next_step_body`) against next month's South African retail calendar (public holidays, Mother's / Father's Day, Black Friday, payday, month-end) and edited in place like every other block.
1320
+ - Cart funnel and buckets (29 Sep 2026): "messaged" counts reminders WhatsApp reports delivered (the old send-request count rides along as `messaged_requested`); a Woo cart reminder now matches its order, so Woo recoveries are no longer zero; our own cart link is a recovery, another tool's abandoned-cart email is not; on Woo an agent-built order is a conversation, not a campaign; chat-to-order counts buyers who chatted, any bucket. `status` refuses approval when the agent replied to nobody in the month (no live channel).
1280
1321
  - Store orgs get slide 8, "Store health": cart abandonment (online checkouts started, bought, abandoned), first-time vs returning buyers and opt-in growth, each with a twelve-month strip and measured on the online store checkout only (till and other channels are named in the footer). `status --json` carries the headline figures under `store`; `config.deck.channel_labels {"<source_name>": "Label"}` renames a sales channel on the slide.
1281
1322
 
1282
1323
  ### Insights — `flowiq insights status|enable|disable|run` (v0.7.0)
@@ -1384,7 +1425,8 @@ create call, including the ones that failed and why.
1384
1425
 
1385
1426
  ```bash
1386
1427
  flowiq templates pull <organization_id> # → ./.flowiq/templates/<slug>.json (Meta-side truth)
1387
- flowiq templates status <organization_id> --name booking # poll approval
1428
+ flowiq templates status <organization_id> --name booking # FlowIQ's record (webhook-kept status)
1429
+ flowiq templates status <organization_id> --live --name booking --watch # straight from Meta, re-checked every 15 s until decided (0.9.8)
1388
1430
  flowiq templates show <organization_id> heritage_day_v2 # render ONE row, drafts included
1389
1431
  flowiq templates create <organization_id> --request-file req.json
1390
1432
  flowiq templates attempts <organization_id> --failed # why a submit was refused (v0.8.1)
@@ -1537,6 +1579,14 @@ Looking up stock for "1 litre Oil Pourer" — the calls being made:
1537
1579
  `stock` prints **"not tracked"** where Shopify returns `available: null` — that
1538
1580
  means no inventory record exists at that location, which is not the same as zero.
1539
1581
 
1582
+ **No exact title? It asks Shopify's product search (0.9.8).** The first call is an
1583
+ exact `products.json?title=`; when that finds nothing, the recipe runs Shopify's
1584
+ own product search over the WHOLE catalogue (it used to scan only the first 250
1585
+ products and then say "Nothing matched" on a 3,594-product store). Titles holding
1586
+ every word you typed are kept; if none do, Shopify's closest matches are shown and
1587
+ labelled as such. Search syntax passes through: `"sku:5702018068984"`,
1588
+ `"handle:up-scaled-darth-vader"`. Per-location levels cover the first 50 variants.
1589
+
1540
1590
  #### Counting things — read `precision` before you trust a number
1541
1591
 
1542
1592
  Shopify's `*Count` fields return `{count, precision}`. **`precision: "AT_LEAST"`
@@ -1709,6 +1759,18 @@ flowiq agent config <organization_id> --model gpt-6-luna --reasoning-effort high
1709
1759
  flowiq agent config <organization_id> --model claude-sonnet-5 --reasoning-effort medium # Claude (needs the org's Claude key; warns if missing)
1710
1760
  flowiq agent config <organization_id> --disable-base-tool get_product_info
1711
1761
  flowiq agent config <organization_id> --enable-base-tool get_product_info
1762
+ # 0.9.8: the fields that used to need SQL
1763
+ flowiq agent config <org> --tool-instruction send_whatsapp_message=@rules.txt # appended to that tool's description ("tool=" removes)
1764
+ flowiq agent config <org> --shorten-links web=on --shorten-links whatsapp=default
1765
+ flowiq agent config <org> --email-from-name "Litehouse Support" --email-signature-name "Laia (AI)" --email-signature-title "Customer care"
1766
+ flowiq agent config <org> --email-request-to a@shop.co.za,b@shop.co.za --tool email_request_tool=true
1767
+ flowiq agent config <org> --send-success-rule "image=Stay silent after your last image." --send-success-rule "document="
1768
+ flowiq agent config <org> --after-escalation "Tell the customer the team replies here; never repeat what you already sent."
1769
+ flowiq agent config <org> --knowledge enabled=true,match_count=8,min_similarity=0.3
1770
+ flowiq agent config <org> --human-notify @human-notify.json # merged into human_notify; null deletes a key
1771
+ flowiq agent config <org> --order-notify '{"enabled":false}'
1772
+ flowiq agent config <org> --tool silent_option=true
1773
+ flowiq agent config <org> --full # show mode without truncating long values (also --json)
1712
1774
  ```
1713
1775
 
1714
1776
  Settable: `settings.use_settings_prompt`, `settings.model`,
@@ -1717,10 +1779,26 @@ pairs with reasoning models like `gpt-5.6-luna`; `max` is GPT-6 and Claude only)
1717
1779
  the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
1718
1780
  `view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
1719
1781
  `shopify_products_web_chat`, `ticket_tool_status`, `product_lookup`,
1720
- `collapse_product_variants`, `email_request_tool`),
1782
+ `collapse_product_variants`, `email_request_tool`, `silent_option`),
1721
1783
  `discount.enabled`, and the `flowiq test` contact
1722
- (`settings.test_contact_number` / `settings.test_contact_name`). Anything else is
1723
- rejected; every change is reported before → after.
1784
+ (`settings.test_contact_number` / `settings.test_contact_name`). Since 0.9.8 also:
1785
+
1786
+ | Flag | Stored in | Runtime reader |
1787
+ |---|---|---|
1788
+ | `--tool-instruction <tool>=<text\|@file>` (repeatable; `tool=` removes) | `settings.tool_instructions` | appended to that tool's description (`tool-instructions.ts`); a name with no such tool is skipped and logged |
1789
+ | `--shorten-links <channel>=on\|off\|default` (repeatable) | `disable_url_shortening` | channels whatsapp / web / messenger / instagram / tiktok / email / call; `default` = on for WhatsApp only |
1790
+ | `--email-from-name` / `--email-signature-name` / `--email-signature-title` (`""` clears) | `settings.email_*` | the From name and signature on the agent's email replies |
1791
+ | `--email-request-to a,b` (`""` clears) / `--email-request-subject-prefix` | `settings.email_request` | `email_request_to_team`; warns while `email_request_tool` is off |
1792
+ | `--send-success-rule <when>=<text\|@file>` (repeatable; `when=` removes) / `--send-success <bool>` | `send_message_success_instructions` | one rule per `when` (media / image / video / document / audio / text / always), appended to the send tool's "sent" reply |
1793
+ | `--after-escalation <text\|@file>` (`""` clears) | `human_notify.success_instructions` | the NEXT STEP appended to the handover tool's reply |
1794
+ | `--knowledge enabled=…,match_count=…,min_similarity=…` (`default` clears a key) | `knowledge_config` | `search_knowledge` (match_count 1-20, min_similarity 0-1) |
1795
+ | `--human-notify` / `--order-notify <json\|@file.json>` | `human_notify` / `order_notify` | top-level merge, `null` deletes a key; unknown keys and wrong types are refused |
1796
+
1797
+ Show mode (no write flags) prints every one of these, collapses long values to one
1798
+ line (`--full` prints them whole, `--json` gives the raw response) and lists what
1799
+ is settable. Anything else is rejected; every change is reported before → after.
1800
+ Not settable here: `additional_config` (use `flowiq knowledge`) and
1801
+ `additional_tools` (use `flowiq ct`).
1724
1802
 
1725
1803
  `--disable-base-tool` / `--enable-base-tool` can be repeated. They manage only the
1726
1804
  allowlisted discovery/commerce built-ins; opt-out, human handover and channel-send
@@ -1745,7 +1823,7 @@ addresses in `agents.settings.email_request.to`, from flowiq@flowapt.com with Re
1745
1823
  set to the customer, and leaves an internal note on the thread. No ticket, no bot-off,
1746
1824
  no staff WhatsApp — built for businesses that want requests in an inbox rather than a
1747
1825
  human escalation (GIB Financial Services). The recipient list is set in the Tool
1748
- Library (Agents → Tool Library → Email Request to Team); the CLI cannot set it yet.
1826
+ Library (Agents → Tool Library → Email Request to Team) or with `--email-request-to` (0.9.8).
1749
1827
 
1750
1828
  **`--tool collapse_product_variants=true` (added 4 Aug 2026).** Not a tool toggle —
1751
1829
  it changes what `get_product_info` RETURNS. OFF (the default) the result cap counts
@@ -1795,8 +1873,14 @@ flowiq au pull <organization_id> --before 20 --after 10 --no-files
1795
1873
 
1796
1874
  flowiq au resolve <update_id> --note "We updated the agent so it no longer …"
1797
1875
  flowiq au resolve <update_id> --status declined --internal "duplicate of …"
1876
+ flowiq au resolve <update_id> --status in_progress --note "Fixed; waiting to see it on a real order" # 0.9.8
1798
1877
  ```
1799
1878
 
1879
+ - **`--status in_progress`** puts a ticket in progress without closing it: no
1880
+ resolution stamp (an old one is cleared, as the app's reopen does), the note is
1881
+ optional and an earlier client note is kept when you pass none. Use it when the
1882
+ fix shipped but cannot be proven yet.
1883
+
1800
1884
  - **`--note` is what the client reads** (rendered as `FlowIQ: <note>` in their
1801
1885
  console) and is **required** when resolving; `--internal` is staff-only.
1802
1886
  - The typical flow is `prompts push → flowiq test → au resolve` — resolve is
@@ -1859,6 +1943,54 @@ flowiq plans status <plan_id> --to sent --commit
1859
1943
  - Editing a plan's copy, buttons or links is not in the CLI yet; do that in
1860
1944
  the app.
1861
1945
 
1946
+ ### CLI gaps + requests — `flowiq gaps report|list|show|hit|note|status` (alias `requests`) (v0.9.7)
1947
+
1948
+ Found something the CLI cannot do, does wrong, or should do? File it here, from
1949
+ the terminal, the moment you hit it. This is the shared list of what the CLI
1950
+ is missing: anyone with a staff key can add to it, and so can a Claude session
1951
+ driving the CLI for you (it is marked `claude` automatically).
1952
+
1953
+ ```bash
1954
+ flowiq gaps report "agent config cannot set tool_instructions" --command "agent config" \
1955
+ --error "error: unknown option '--tool-instruction'" --workaround "SQL on agents.settings" \
1956
+ --detail "Needed to add a per-tool instruction for Clara's get_product_info" --org <uuid>
1957
+ flowiq gaps report "links shorten should take a readable --slug" --kind idea --command "links shorten"
1958
+ flowiq gaps report "…" --dry-run # only check whether it is already on the list
1959
+ flowiq gaps list # open requests, most-hit first
1960
+ flowiq gaps list --command "bc" --status all --search slug
1961
+ flowiq gaps list --mine
1962
+ flowiq gaps show 12 # detail, verbatim error, every hit / note / status change
1963
+ flowiq gaps hit 12 --note "same on Barkyn IT while copying the escalation config"
1964
+ flowiq gaps note 12 "also needs --agent"
1965
+ flowiq gaps status 12 --to done --version 0.9.8 --note "agent config --tool-instruction name=@file.txt" # maintainers
1966
+ flowiq gaps status 14 --to duplicate --of 12 # hits move to #12
1967
+ ```
1968
+
1969
+ - **`report`** takes a one-line title plus whatever context you have:
1970
+ `--command` (the command it concerns, or the one you expected to exist),
1971
+ `--kind gap|bug|idea` (default gap), `--error` (paste it verbatim),
1972
+ `--workaround` (what you did instead), `--detail` / `--detail-file`, `--org`,
1973
+ `--priority low|normal|high` (high = it blocked the job).
1974
+ - **Duplicates are caught before they are filed.** A report that closely
1975
+ matches an OPEN request is refused and names it; add yourself to that one
1976
+ with `hit` instead, or pass `--new` if yours is genuinely different.
1977
+ `--dry-run` shows the similar requests and files nothing.
1978
+ - **`hit`** is "this bit me too": it adds your case and counts it. `list` is
1979
+ ordered by hits, so the requests that cost the team most get built first.
1980
+ `note` adds context without counting.
1981
+ - **`status`** (open / planned / in_progress / done / declined / duplicate) is
1982
+ for the CLI maintainers (Matt and Gidon). You can withdraw your OWN request
1983
+ with `--to declined` or `--to duplicate --of <n>`. `done` and `declined` need
1984
+ `--note`: closing a request **emails everyone who reported or hit it** (from
1985
+ FlowIQ (CLI), reply-to the person who closed it), with the version to update
1986
+ to. `--no-notify` closes quietly.
1987
+ - **A command or flag that does not exist** now ends its error with the exact
1988
+ `flowiq gaps report …` line to file it, `--command` and `--error` filled in.
1989
+ - **In the app:** the Changelog dialog has a **CLI requests** tab (super admins) showing the same list, filterable by status and kind, with each request's detail and history. It is read only; reporting and closing stay in the CLI.
1990
+ - Every report / hit / note / status change is audited
1991
+ (`flowiq audit --endpoint gaps`). A report or hit from anyone but Matt also
1992
+ emails him through the standing CLI-write alert.
1993
+
1862
1994
  ### Chat export — `flowiq export chats <organization_id> [--out <path>]`
1863
1995
 
1864
1996
  Full chat history → TXT, byte-identical to the in-app "Export Settings TXT"
@@ -1903,6 +2035,16 @@ messages (`memory_cutoff`) AND sets aside the agent's remembered tool results
1903
2035
  (`more_data.tool_usage_history`, which the agent reads as TOOL USAGE HISTORY) under
1904
2036
  `more_data.tool_usage_history_cleared`, so a cleared test really starts fresh. Since 0.9.6;
1905
2037
  before that earlier tool results and escalation reasons leaked into "cleared" tests.
2038
+ `test clear` prints the PREVIOUS `memory_cutoff` so a clear can be undone (0.9.8).
2039
+
2040
+ **Real-customer guard (0.9.8).** The configured test contact can be a real person
2041
+ (a client's reviewer, a dormant customer). When it had real (non-web) inbound in
2042
+ the last 6 h, `send`, `scenario`, `qa` and `clear` are refused with the latest
2043
+ message time; `--force` overrides. A test contact that has ever messaged on a real
2044
+ channel gets a warning on every run. Point the agent at a fake number with
2045
+ `flowiq agent config <org> --test-contact-number 27000000001`. A test contact the
2046
+ CLI creates is born with `allow_broadcast=false` and the `test-contact` tag, so it
2047
+ never lands on a broadcast list (stress contacts too).
1906
2048
 
1907
2049
  ### Guide — `flowiq guide`
1908
2050
 
package/TEAM-GUIDE.md CHANGED
@@ -70,19 +70,25 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
70
70
  goes back to the agent you pulled — never "whatever is active now".
71
71
  5. Run the CLI from the repo root when you can — `./.flowiq/` is gitignored
72
72
  there.
73
+ 6. **Hit a wall? File it.** Whenever the CLI cannot do something and you (or
74
+ your Claude session) fell back to the dashboard, SQL or a script, file it:
75
+ `flowiq gaps report "<what you needed>" --command "<topic>" --error "<the
76
+ exact error>"`. Already on the list? `flowiq gaps hit <number> --note "…"`
77
+ instead. The most-hit requests get built first, and you are emailed when
78
+ yours ships. Tell your Claude session to do this too.
73
79
 
74
80
  ## Everyday tasks
75
81
 
76
82
  | I want to… | Run |
77
83
  |---|---|
78
- | Edit a client agent's system prompt | `flowiq prompts pull <org_id>` → edit the JSON → `flowiq prompts push <slug>` |
84
+ | Edit a client agent's system prompt | `flowiq prompts pull <org_id>` → edit the JSON → `flowiq prompts push <slug>` (a new section needs no id; the push adds one) |
79
85
  | Edit the questionnaire answers | `flowiq q pull <org_id>` → edit → `flowiq q push <slug>` |
80
86
  | Edit fine-tuning instructions / Q&A | `flowiq ft pull <org_id>` → edit → `flowiq ft push <slug>` |
81
87
  | Edit the text knowledge sources (get_more_answers playbooks) | `flowiq kn pull <org_id>` → edit `sources[]` → `flowiq kn push <slug>` |
82
88
  | Edit the custom tools (API-call tools) | `flowiq ct pull <org_id>` → edit `tools[]` → `flowiq ct push <slug> --dry-run` → `flowiq ct push <slug>` |
83
89
  | Turn one custom tool on/off | `flowiq ct enable\|disable <org_id> <tool_name>` |
84
90
  | Give a custom tool a friendly name on the inbox tool-activity card | add `"display_name": "Checked loyalty points"` to that tool in `tools[]` → `flowiq ct push` (≤80 chars, never shown to the AI; empty = derived from the tool name) |
85
- | Edit keyword auto-replies (incl. competition entry keywords, add/remove-tag, set-agent and delay actions) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug> --dry-run` → `flowiq kw push <slug>` |
91
+ | Edit keyword auto-replies (incl. competition entry keywords, add/remove-tag, set-agent and delay actions) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug> --dry-run` → `flowiq kw push <slug>`. `kw pull` flags two active keywords answering the same word (and which one wins) and a keyword that is live before its start date |
86
92
  | Send a **different auto-reply depending on the contact** (e.g. "we already have your email" vs "send us your email") | add `"when": {"field":"email","op":"is_not_empty"}` to one action and `is_empty` to the other — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
87
93
  | Make a follow-up button **close the customer's ticket** ("Query solved") or **re-alert the team** ("I still need help") | keyword action `{"type":"update_ticket_status","status":"resolved"}` / `{"type":"renotify_ticket"}` — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
88
94
  | Make a keyword/button **hand the chat to a team or person** (assign in the inbox + email/WhatsApp them) | keyword action `{"type":"assign_chat","team_id":"…","assignee_user_id":"…","notify_member":true}` — see *Keywords* in `flowiq guide --reference`. Also in the dashboard (action type "Assign Chat") |
@@ -98,7 +104,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
98
104
  | See one client's broadcast plans | `flowiq plans list <org_id>` (open plans) or `flowiq plans list <org_id> --status all --since 2026-09-01` |
99
105
  | "What did clients submit today?" | `flowiq plans list --created-since 2026-09-22` (v0.9.1) — `--since` filters the SEND date, `--created-since` / `--touched-since` filter when the plan itself was created or last changed |
100
106
  | Mark a broadcast plan approved, scheduled or sent | `flowiq plans status <plan_id> --to sent` (a dry run), then add `--commit`. Rejecting needs `--comment "..."`, which the client reads |
101
- | What's the stock on a product, per branch? | `flowiq shopify stock <org_id> "olive oil"` — shows each location by NAME, and says "not tracked" rather than a confusing 0 |
107
+ | What's the stock on a product, per branch? | `flowiq shopify stock <org_id> "olive oil"` — shows each location by NAME, and says "not tracked" rather than a confusing 0. Part of the title is enough (it searches the whole catalogue); `"sku:…"` works too |
102
108
  | Has this order shipped? What's the tracking? | `flowiq shopify order <org_id> '#14728'` — status, courier, tracking number + link |
103
109
  | **Size a customer cohort** | Use `customers(first:250, query:…)` and paginate — **NOT `customersCount(query:…)`, which Shopify ignores the filter on** and answers 10,000 every time. Any count showing `precision: AT_LEAST` is a cap, not a total; the CLI warns you. |
104
110
  | A big `--all` failed with a timeout | It's transient, not a limit — retry, or narrow it with `--max-pages` / a smaller `--q limit`. The message now says so. |
@@ -114,13 +120,22 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
114
120
  | Remove a conflicting built-in tool from one agent | `flowiq agent config <org_id> --disable-base-tool get_product_info` (repeatable; safety/delivery tools cannot be disabled) |
115
121
  | Agent says an in-stock product "isn't showing" | `flowiq agent config <org_id> --tool collapse_product_variants=true` — the search cap counts VARIANT rows until this is on |
116
122
  | Agent can't quote ANY price / "I can't pull the live menu" | `flowiq agent config <org_id> --tool product_lookup=true` — name-based fuzzy lookup that works without embeddings or a live OpenAI key (semantic `get_product_info` needs both) |
117
- | Client wants leads/requests emailed to their team instead of a human escalation | `flowiq agent config <org_id> --tool email_request_tool=true`, then set the recipient list in Agents → Tool Library → Email Request to Team (no CLI path for the addresses yet) |
123
+ | Client wants leads/requests emailed to their team instead of a human escalation | `flowiq agent config <org_id> --tool email_request_tool=true --email-request-to a@shop.co.za,b@shop.co.za` |
124
+ | Add extra rules to one tool (appended to its description) | `flowiq agent config <org_id> --tool-instruction send_whatsapp_message=@rules.txt` (`tool=` removes it) |
125
+ | Turn link shortening on/off per channel | `flowiq agent config <org_id> --shorten-links web=on` (`off`, or `default` = on for WhatsApp only) |
126
+ | Set the agent's email From name / signature | `flowiq agent config <org_id> --email-from-name "Shop Support" --email-signature-name "Laia (AI)" --email-signature-title "Customer care"` |
127
+ | Tell the agent what to do right after it sends an image / after a handover | `flowiq agent config <org_id> --send-success-rule "image=…"` / `--after-escalation "…"` |
128
+ | Knowledge search settings, human_notify / order_notify keys | `flowiq agent config <org_id> --knowledge enabled=true,match_count=8` · `--human-notify @file.json` · `--order-notify '{"enabled":false}'` (merged; `null` deletes a key) |
129
+ | See EVERYTHING an agent is configured with | `flowiq agent config <org_id>` (`--full` for long values, `--json` raw) |
118
130
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
119
- | Start a test chat from scratch (agent forgets the earlier test messages AND its earlier tool results) | `flowiq test clear <org_id>` (or `flowiq test send <org_id> "…" --clear`) |
131
+ | Start a test chat from scratch (agent forgets the earlier test messages AND its earlier tool results) | `flowiq test clear <org_id>` (or `flowiq test send <org_id> "…" --clear`). It prints the previous `memory_cutoff`, and refuses when the test contact is a real customer who wrote in the last 6 h (`--force` overrides; better: use a fake test number) |
120
132
  | **Tell the team what shipped** (the FlowIQ team update email) | `flowiq updates draft` → enrich the JSON (screenshots via `flowiq updates asset`, a *For the team* note per headline) → `flowiq updates preview <file> --open` → `flowiq updates send <file> --test` → `flowiq updates send <file> --yes` |
121
133
  | Can I use `flowiq updates`? | Yes: `status`, `uncovered`, `draft`, `preview` and `send --test` (a copy to yourself) work for everyone. Only the real `send --yes` to the whole team is Matt's; anyone else gets a 403 there |
122
134
  | Which changelog rows has nobody emailed the team about yet? | `flowiq updates status` / `flowiq updates uncovered` — anything left 20h+ goes out automatically at 08:30 SAST as a plain digest |
123
135
  | Set which contact `flowiq test` uses (use a FAKE number!) | `flowiq agent config <org_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"` |
136
+ | **The CLI can't do something I need** (or does it wrong, or should do more) | `flowiq gaps report "<command> cannot <thing>" --command "<command>" --error "<exact error>" --workaround "<what you did instead>"`. If it is already on the list you are told its number: `flowiq gaps hit <number> --note "…"` |
137
+ | See what the team has asked the CLI to do | `flowiq gaps list` (open, most-hit first) · `flowiq gaps list --mine` · `flowiq gaps show <number>` · in the app: Changelog → **CLI requests** |
138
+ | Close a request (Matt / Gidon) | `flowiq gaps status <number> --to done --version 0.9.8 --note "what shipped"` — emails everyone who reported or hit it |
124
139
  > **Publishing the CLI (maintainers only):** publish from a clean clone, never
125
140
  > from your working tree — `npm publish` packs whatever is on disk. A
126
141
  > `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
@@ -156,7 +171,7 @@ Getting this right is what makes a campaign's clicks and revenue group together
156
171
  in reporting — a tag typed a different way each time splits one campaign into
157
172
  several.
158
173
 
159
- | **Make short links for a campaign** (with UTM tracking) | `flowiq links shorten <org_id> --url "https://shop.co.za/product/x" --campaign "Spring Promotion"` (dry run) → `… --commit`. **Just type the campaign in plain words** — the CLI names it for you as `9Sep_SpringPromotion` and prints it before minting. `utm_source`/`utm_medium` are set for you |
174
+ | **Make short links for a campaign** (with UTM tracking) | `flowiq links shorten <org_id> --url "https://shop.co.za/product/x" --campaign "Spring Promotion"` (dry run) → `… --commit`. **Just type the campaign in plain words** — the CLI names it for you as `9Sep_SpringPromotion` and prints it before minting. `utm_source`/`utm_medium` are set for you. If the campaign already runs under another spelling (`5Sept_…` vs `5Sep_…`) it refuses and tells you to reuse the running tag (`--raw-campaign`) or confirm with `--new-campaign` |
160
175
  | A campaign going out on a later date | Add `--date`: `--campaign "Heritage Day" --date 24/9/2026` → `24Sep_HeritageDay`. Without it you get today's date |
161
176
  | A second link in the same send (e.g. a VIEW MORE button) | Same `--campaign`, different `--content`: `--content ViewMore`. Content is the per-link tag and gets no date |
162
177
  | Short links for every URL in a message | `flowiq links shorten <org_id> --file ./message.txt --campaign "Spring Promotion" --commit` |
@@ -183,19 +198,21 @@ several.
183
198
  | Send a broadcast whose link button should open a page on the store, tracked | Paste the real link: `flowiq bc send <org_id> --tag <batch-tag> --template <name> --button param1="https://shop.co.za/collections/spring?ref=ig"` (dry run shows `→ linklnk.io/<code>`, the campaign tag and anything replaced) → `… --commit` mints the code and sends. `--link-campaign "Spring Promotion"` names the tag (default: the template name); `--keep-link` sends your link untouched with no FlowIQ tags (clicks counted, the sale will not show as WhatsApp) |
184
199
  | Send a template with TWO link buttons (e.g. one per product) and track clicks per batch | Mint two short links per batch with the same campaign and a different `--content` (e.g. `--content CycleHarmony_b01` and `--content NeuroShyft_b01`), then `flowiq bc send <org_id> --tag <batch-tag> --template <name> --button param1=<first-code> --button param2=<second-code>`. `param1` is the first link button on the template, `param2` the second. The dry run prints both final URLs and stops if either is missing. Tag mode only (v0.7.5). |
185
200
  | Send via the SAME engine as the dashboard's "Python" toggle | add `--python` to a `bc send --tag …` (fire-and-forget; python resolves the tag + sends + tracks; no CLI resume for this engine). **Any tag send over 10 recipients uses python automatically.** |
186
- | Find a broadcast's id (don't have the `broadcastId`?) | `flowiq bc list-remote <org_id>` — the org's broadcasts newest-first with full ids (`--template <substr>` / `--since <date>` / `--limit <n>` to narrow) |
187
- | Check how a broadcast is landing (accepted → delivered → read, plus failed/pending) | `flowiq bc status <org_id> <broadcastId>` (from the send output, or `bc list-remote`) |
201
+ | Find a broadcast's id (don't have the `broadcastId`?) | `flowiq bc list-remote <org_id>` — the org's broadcasts newest-first with full ids (`--template <substr>` / `--since <date>` / `--limit <n>` to narrow; `--min-recipients 2` hides welcomes and cart reminders) |
202
+ | Check how a broadcast is landing (accepted → delivered → read, plus failed/pending) and what it said | `flowiq bc status <org_id> <broadcastId>` — also prints the slot values, the button code and the link's destination (UTM tag) |
188
203
  | See WHO failed on a broadcast + why (Meta error) | `flowiq bc status <org_id> <broadcastId> --failures` |
189
204
  | Re-send a broadcast to only the ones that failed | `flowiq bc retry <org_id> <broadcastId>` (dry-run) → `… --commit` |
190
205
  | Make EVERY reply to a broadcast land with a team or a person (client asks "all replies to the Spa team") | Add `--route-team "Spa"` (and/or `--route-member kim@client.com`) to the `bc send`. The first reply from each recipient within 72 hours (`--route-window 48h` to change) assigns the chat and notifies them, typed replies and button taps alike. The AI agent and keywords still answer as normal, so nothing is muted. The dry run shows `Replies → team Spa …`; a wrong team name stops the run and lists the org's teams. Works with `--csv` and `--at` too. |
191
206
  | Add, check or remove that routing on a broadcast that already went out | `flowiq bc route <org_id> <broadcastId>` shows it and how many chats it has routed · `… --team "Spa" --commit` sets it · `… --clear --commit` removes it. Only replies from now on are affected. |
192
207
  | **Get an OLD version of a prompt back** | `flowiq prompts history <org_id>` (pick the version) → `flowiq prompts restore <org_id> <audit_id>` (dry-run) → `… --commit` |
193
- | **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
208
+ | **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2d` (or a date) to narrow; `N of M` in the header means there is more (`--before` pages back) |
194
209
  | See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
195
210
  | Know what the log does and doesn't keep | Anything that CHANGES something is logged — including `flowiq test` (it creates the test contact and clears conversations) and `flowiq auth refresh`. Reads and dry-runs are not. The log never stores the content itself: chats, store data and agent replies are recorded as "who read what", never copied. |
196
211
  | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON. Ask for more than exists (`--count 100`) and you get the entire history — it says "that is ALL of them" when there is nothing older |
197
212
  | Export an org's full chat history | `flowiq export chats <org_id>`. If it stops mid-way it says which page failed and nothing is written, so just re-run it (a 502/503/504 page retries on its own) |
198
213
  | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
214
+ | Is my new template approved yet? | `flowiq tpl status <org_id> --live --name <name> --watch` — straight from Meta every 15 s until decided, with the rejection reason. If a create timed out, run this before retrying (it may already exist) |
215
+ | A command says "Vercel is challenging this network" | Not your key: wait a few minutes or switch network (hotspot). Don't log in again |
199
216
  | **Read a template you have not submitted yet (a DRAFT)** | `flowiq tpl show <org_id> <template_name>` — the ONLY way to see a draft from the terminal (`pull` reads Meta, and a draft never gets there). Renders the message as the customer will read it, every carousel card, and a **Checks** list of the things Meta would bounce it for |
200
217
  | Check a carousel before submitting it | `flowiq tpl show <org_id> <name>` and read **Checks**. It catches a card link using the wrong `{{n}}`, a missing greeting example, a bold/italic marker against a space (WhatsApp shows the literal `_`), wrong card counts and over-length text. It cannot know a card names a product you do not stock — read the card text against the shop yourself |
201
218
  | **A template would not submit and you want to know why** | `flowiq tpl attempts <org_id> --failed` — the submission ledger. Every create call since 21 Sep 2026, from the dashboard or the CLI, with the reason a refused one was refused (e.g. `HEADER format is DOCUMENT but the media at file_url is video/mp4`). A failed submit writes no template row and never reaches Meta, so this is the ONLY place it shows |
@@ -205,6 +222,7 @@ several.
205
222
  | See EVERY client's open change requests at once | `flowiq au all` (grouped by org; `--since 14d`, `--priority urgent,high`, `--search <text>`, `--status all`, `--summary`, `--json`) |
206
223
  | See one client's pending change requests, with the chat around each | `flowiq au pull <org_id>` |
207
224
  | Close a client's change request (after verifying the fix!) | `flowiq au resolve <update_id> --note "what changed"` — the client reads the note |
225
+ | Fix shipped but not provable yet | `flowiq au resolve <update_id> --status in_progress --note "…"` (stays open, no resolution stamp) |
208
226
  | Check an org's platform + active agent | `flowiq org info <org_id>` |
209
227
  | **See every Settings → Profile switch on an org** | `flowiq org flags show <org_id>` (credentials are never shown) |
210
228
  | **Which orgs have a flag on / off** (insights, MCP member access, a debounce…) | `flowiq org flags list --key export_insights.enabled --off` · `--key mcp_member_access.enabled --on` |
@@ -221,6 +239,7 @@ several.
221
239
  | Generate a client's monthly deck (metrics + copy) after the month has frozen | `flowiq report deck generate <org_id> 2026-08` — then review it at /reporting/deck |
222
240
  | See whether a client deck can be approved, and what is still missing | `flowiq report deck status <org_id> 2026-08` |
223
241
  | Send yourself a test copy of a client deck (PDF from flowiq@flowapt.com) | `flowiq report deck send <org_id> 2026-08 --test-to you@flowapt.com` — status untouched |
242
+ | Take a tile, pill or card off a client deck (and its PDF) for one month | On `/reporting/deck` open **Inputs** (the rail beside the slides) → Show or hide; or `flowiq report deck inputs <org_id> 2026-08 --file inputs.json` with `{ "hidden": { "tile:tail": true } }` |
224
243
  | Approve a client deck / send it to the client | `flowiq report deck approve <org_id> 2026-08` then `… send … --client` (or let the 09:00 SAST schedule send it on the 5th) |
225
244
  | Re-read this guide / the full command reference | `flowiq guide` / `flowiq guide --reference` |
226
245
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,39 @@
1
+ // node --test src/agent-config.test.mjs
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { writeFileSync, mkdtempSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { splitPair, parseShorten, parseKnowledge, parseJsonObject, readValue } from "./commands/agent-config.js";
8
+
9
+ test("splitPair keeps '=' inside the value", () => {
10
+ assert.deepEqual(splitPair("send_whatsapp_message=use a=b style", "x"), ["send_whatsapp_message", "use a=b style"]);
11
+ assert.deepEqual(splitPair("document=", "x"), ["document", ""]);
12
+ });
13
+
14
+ test("parseShorten maps on/off/default", () => {
15
+ assert.equal(parseShorten("on", "x"), true);
16
+ assert.equal(parseShorten("OFF", "x"), false);
17
+ assert.equal(parseShorten("default", "x"), null);
18
+ });
19
+
20
+ test("parseKnowledge types each pair and clears on default", () => {
21
+ assert.deepEqual(parseKnowledge("enabled=true, match_count=8,min_similarity=0.35"), { enabled: true, match_count: 8, min_similarity: 0.35 });
22
+ assert.deepEqual(parseKnowledge("min_similarity=default"), { min_similarity: null });
23
+ });
24
+
25
+ test("readValue reads @file and drops one trailing newline", () => {
26
+ const dir = mkdtempSync(join(tmpdir(), "flowiq-ac-"));
27
+ const f = join(dir, "t.txt");
28
+ writeFileSync(f, "line one\nline two\n");
29
+ assert.equal(readValue(`@${f}`, "x"), "line one\nline two");
30
+ assert.equal(readValue("plain text", "x"), "plain text");
31
+ });
32
+
33
+ test("parseJsonObject accepts inline JSON and @file", () => {
34
+ assert.deepEqual(parseJsonObject('{"enabled":false,"template_name":null}', "x"), { enabled: false, template_name: null });
35
+ const dir = mkdtempSync(join(tmpdir(), "flowiq-ac-"));
36
+ const f = join(dir, "hn.json");
37
+ writeFileSync(f, '{"whatsapp_ids":["27000000000"]}');
38
+ assert.deepEqual(parseJsonObject(`@${f}`, "x"), { whatsapp_ids: ["27000000000"] });
39
+ });
@@ -0,0 +1,36 @@
1
+ // node --test src/batch-a.test.mjs
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { mintSectionIds } from "./commands/prompts.js";
5
+ import { scheduleLabel } from "./commands/keywords.js";
6
+ import { describeError, isVercelChallenge } from "./http.js";
7
+
8
+ test("mintSectionIds gives only id-less sections a uuid and names them", () => {
9
+ const secs = [{ id: "keep-me", title: "A", content: "" }, { title: "New one", content: "" }, { id: " ", title: "Blank", content: "" }];
10
+ const touched = mintSectionIds(secs);
11
+ assert.deepEqual(touched, ["New one", "Blank"]);
12
+ assert.equal(secs[0].id, "keep-me");
13
+ assert.match(secs[1].id, /^[0-9a-f-]{36}$/);
14
+ assert.notEqual(secs[1].id, secs[2].id);
15
+ });
16
+
17
+ test("scheduleLabel flags an active row before its start and past its end", () => {
18
+ const now = Date.parse("2026-09-30T20:00:00Z");
19
+ assert.match(scheduleLabel({ active: true, start_date: "2026-09-30T22:00:00Z" }, now), /LIVE BEFORE ITS START/);
20
+ assert.match(scheduleLabel({ active: false, start_date: "2026-09-30T22:00:00Z" }, now), /starts 2026-10-01 00:00 SAST/);
21
+ assert.match(scheduleLabel({ active: true, end_date: "2026-09-29T10:00:00Z" }, now), /PAST ITS END/);
22
+ assert.equal(scheduleLabel({ active: true }, now), "");
23
+ });
24
+
25
+ test("describeError: HTML 'timeout' text is not a timeout; a POST timeout says check first", () => {
26
+ assert.doesNotMatch(describeError({ _raw: "<html>setTimeout(…) timed out</html>" }, "GET", "hours", 403, { json: false }), /TIMEOUT/);
27
+ assert.match(describeError({ error: "x" }, "POST", "meta-templates", 504), /may still have finished the write/);
28
+ assert.match(describeError({ error: "x" }, "GET", "store-api", 504), /--max-pages/);
29
+ });
30
+
31
+ test("isVercelChallenge reads the header or the checkpoint page", () => {
32
+ const h = (v) => ({ get: (k) => (k === "x-vercel-mitigated" ? v : null) });
33
+ assert.equal(isVercelChallenge(403, h("challenge"), ""), true);
34
+ assert.equal(isVercelChallenge(403, h(null), "<title>Vercel Security Checkpoint</title>"), true);
35
+ assert.equal(isVercelChallenge(403, h(null), '{"error":"Forbidden"}'), false);
36
+ });