@flowapt/flowiq-cli 0.4.8 → 0.4.9

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
@@ -187,8 +187,9 @@ flowiq ct list
187
187
 
188
188
  - **Always `--dry-run` first** on a production agent — it prints added/removed/changed tool names before you commit.
189
189
  - **Destructive pushes are blocked by default**: a push that removes a tool, disables one, or sends an empty `tools[]` (wiping everything) writes nothing and shows you exactly what it would strip — re-run with `--confirm` if intentional. Additive/no-op pushes are unaffected.
190
- - Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, object-typed `parameters`/`headers`/`injected_parameters`, valid `auth_type`/`channels`. Bad payloads are rejected before anything writes.
191
- - **Warnings (non-blocking):** unknown keys (likely typos the runtime would silently ignore) and unknown `{{placeholders}}` (they will NOT be substituted at runtime — known: `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `supabase_anon_key`, `openai_api_key`, …).
190
+ - Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, object-typed `parameters`/`headers`/`injected_parameters`, valid `auth_type`/`channels`. Recursive JSON schemas are supported, including arrays of objects and integer/min/max constraints. Bad payloads are rejected before anything writes.
191
+ - **Warnings (non-blocking):** unknown keys (likely typos the runtime would silently ignore) and unknown `{{placeholders}}` (they will NOT be substituted at runtime; known values include `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `whatsapp_message_id`, `supabase_anon_key`, `openai_api_key`).
192
+ - **Retailer gateway credential:** `{{retailer_tools_internal_key}}` is super-admin-only and resolves only as the `x-api-key` value for `https://express.chatcart.io/retailer-tools/*` (or loopback in local tests). Validation and runtime both reject putting it in a body or sending it to any other host.
192
193
  - `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
193
194
 
194
195
  ### Broadcast — `flowiq broadcast map|preview|send|resume|list-remote|status|retry|list` (alias `bc`)
@@ -319,8 +320,29 @@ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
319
320
  python for the eligible count + a sample. Trade-off vs the default per-row engine:
320
321
  no CLI write-ahead-log / `resume` (python owns the broadcast record), and archived
321
322
  contacts aren't separately filtered. Media headers work on both engines.
322
- - **v1 scope**: Meta orgs, POSITIONAL templates, text / no header / media header.
323
- NAMED and carousel templates and WATI orgs are refused with a clear message.
323
+ - **Scope**: Meta orgs, POSITIONAL templates, text / no header / media header,
324
+ and **carousel templates (tag mode only, v0.4.9)**. NAMED templates and WATI
325
+ orgs are refused with a clear message; carousels in CSV mode are refused with
326
+ a pointer to tag mode.
327
+ - **Carousel templates (v0.4.9, `send --tag` only, always the python engine):**
328
+ the CLI introspects every card (header format, body variables, buttons) and
329
+ builds the per-card send payload python's `/meta-broadcast` expects. Card
330
+ media defaults to each card's **stored template image** (written by
331
+ `create-meta-template` at creation), overridable with repeatable
332
+ `--card-media <url>` (one per card, in card order); every resolved URL is
333
+ content-type probed against that card's own IMAGE/VIDEO format (mismatch
334
+ aborts, per card). Cards whose body carries `{{n}}` variables, or whose URL
335
+ buttons carry a variable, **require `--cards-file <path>`** — a JSON array
336
+ with one entry per card:
337
+ `[{"header_media":"<url>","body_params":{"param1":"…"},"button_payloads":{"btn0":"…"},"url_vars":{"btn1":"<value>"}}, …]`
338
+ (all keys optional per card; quick-reply payloads default to the button text;
339
+ `{{first_name}}`-style tokens inside card values are resolved per contact).
340
+ Carousel sends route to the **python engine at any size** — the per-row Node
341
+ engine cannot send carousels — so there is no CLI write-ahead log / `resume`
342
+ for them; audit via `bc status <broadcastId>`. `--at` scheduling works: the
343
+ card payload is frozen into the queued request. `bc retry` on a carousel
344
+ broadcast re-resolves card media from the template's stored defaults and does
345
+ NOT replay per-card body/url values — retry only carousels that need none.
324
346
  - **Validation before anything sends**: APPROVED-only, every slot mapped,
325
347
  contiguous params (the Meta `#132000` guard — a stray key is structurally
326
348
  impossible), phone validity, illegal characters (Meta `#100`; `reject` by
@@ -623,6 +645,16 @@ flowiq m pull <contact_id> --count 25
623
645
 
624
646
  `--count` defaults to 25, max 200.
625
647
 
648
+ **When you ask for more than exists, you get the WHOLE history (16 Aug 2026).**
649
+ If the contact has fewer `user-*` messages than `--count`, there is nothing
650
+ older to withhold, so the pull returns every row from the first message onward
651
+ and reports `reached_start: true` (`user msgs: 1/25 requested — that is ALL of
652
+ them; this pull is the entire history`). Before this, the anchor sat on the
653
+ oldest inbound message and silently dropped everything before it — so on a
654
+ contact whose conversation OPENS with an outbound (a delivery notification, a
655
+ broadcast), the message that STARTED the conversation was missing while the
656
+ pull still printed success.
657
+
626
658
  ### Audit log — `flowiq audit [org] | audit show <id>` (v0.3.8)
627
659
 
628
660
  **Who changed what, when — with the full before/after content.** Every
package/TEAM-GUIDE.md CHANGED
@@ -114,6 +114,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
114
114
  | See / approve / cancel what's scheduled | `flowiq bc scheduled list <org_id>` → `flowiq bc scheduled approve <org_id> <queue_id>` or `flowiq bc scheduled cancel <org_id> <queue_id> --confirm` |
115
115
  | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` (per-contact tokens: the 6 contact fields + `{{attributes.<key>}}`). Sending a **different template to the SAME tag**? Add a distinct `--campaign <name>` — otherwise the CLI aborts (a campaign belongs to one template; reusing it would send the first template's image + skip everyone it already reached). |
116
116
  | Send a broadcast whose template has an IMAGE/VIDEO/DOCUMENT header | Same as above — the media is automatic (the template's own stored header). Override with `--header-media <public-url>` if needed. The CLI verifies the resolved media's actual type against the header format — `header video (video ✓ video/mp4)` means verified; a mismatch (e.g. a video template whose stored default is secretly a png — templates made before 3 Aug 2026 can carry this) ABORTS and tells you to pass `--header-media` with the real file. |
117
+ | Send a **CAROUSEL** template (v0.4.9) | Tag mode only: `flowiq bc send <org_id> --tag <batch-tag> --template <carousel_name> --body param1="Hi {{first_name}}" --commit`. Card images are automatic (each card's stored template image, type-verified per card); override with repeatable `--card-media <url>` (one per card, in order). If the cards carry `{{n}}` body variables or URL-button variables, the CLI tells you exactly what to put in a `--cards-file <path>` JSON (one entry per card: `header_media` / `body_params` / `button_payloads` / `url_vars`). Always sends via the python engine (any size); no CSV mode for carousels. |
117
118
  | 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.** |
118
119
  | 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) |
119
120
  | 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`) |
@@ -123,7 +124,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
123
124
  | **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
124
125
  | See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
125
126
  | 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. |
126
- | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
127
+ | 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 |
127
128
  | Export an org's full chat history | `flowiq export chats <org_id>` |
128
129
  | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
129
130
  | Manage Shopify/Woo platform webhooks | `flowiq wh pull <org_id>` → edit → `flowiq wh push <slug>` |
@@ -168,6 +169,10 @@ flowiq ct push <slug>
168
169
  Custom tools define real HTTP calls the agent can execute, so the server
169
170
  validates hard (names, URLs, methods, parameter shapes) and warns about typo'd
170
171
  keys or `{{placeholders}}` it doesn't recognise. Take the warnings seriously.
172
+ Nested object/array schemas are supported. For ChatCart retailer tools, use
173
+ `{{retailer_tools_internal_key}}` only as the `x-api-key` auth value on the
174
+ trusted `express.chatcart.io/retailer-tools/*` gateway; org/contact identity is
175
+ injected server-side and mutations use `{{whatsapp_message_id}}` for idempotency.
171
176
 
172
177
  ### Example: tag a segment of contacts (Advanced Tagging)
173
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
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": {
@@ -380,11 +380,121 @@ function headerMismatchMessage(template, headerMedia, contentType, { explicit })
380
380
  : `${base} — the template's stored default header media is broken (created before the 3 Aug 2026 create-meta-template mime fix?); pass --header-media <url> with the real ${template.header_type}`;
381
381
  }
382
382
 
383
+ // ---------------------------------------------------------------------------
384
+ // carousel card resolution (18 Aug 2026 — tag mode, python engine only)
385
+ // ---------------------------------------------------------------------------
386
+ // Builds the card_overrides[] payload python's /meta-broadcast expects: one
387
+ // entry per card, in card order. Media precedence per card: --cards-file entry
388
+ // header_media → --card-media (positional) → the template's stored default
389
+ // (template_data.carousel.cards[i].header_url, surfaced by introspect). Every
390
+ // resolved URL is content-type probed against that CARD's own header format —
391
+ // a carousel can mix IMAGE and VIDEO cards, so this is per-card, not global.
392
+ // Cards with {{n}} body variables or URL-button variables REQUIRE a
393
+ // --cards-file entry (python emits '' for a missing url var → Meta rejects the
394
+ // whole message, so we refuse client-side). Exported for the test harness.
395
+
396
+ export async function resolveCarouselCards(template, { cardMediaFlags = [], cardsFileEntries = null } = {}, probe = probeContentType) {
397
+ const cards = template.carousel_cards || [];
398
+ const aborts = [], warnings = [], lines = [];
399
+ const overrides = [];
400
+ if (!cards.length) {
401
+ aborts.push("carousel template, but the server returned no card details — api.flowiq.live needs the carousel-aware deploy (api/cli/_introspect.js); until then use the dashboard");
402
+ return { aborts, warnings, overrides: null, lines };
403
+ }
404
+ if (cardMediaFlags.length && cardMediaFlags.length !== cards.length) {
405
+ aborts.push(`--card-media given ${cardMediaFlags.length} time(s) but the template has ${cards.length} cards — pass one per card, in card order`);
406
+ }
407
+ if (cardsFileEntries !== null && (!Array.isArray(cardsFileEntries) || cardsFileEntries.length !== cards.length)) {
408
+ aborts.push(`--cards-file must be a JSON array with exactly ${cards.length} entries (one per card, in card order)`);
409
+ }
410
+ if (aborts.length) return { aborts, warnings, overrides: null, lines };
411
+
412
+ for (let i = 0; i < cards.length; i++) {
413
+ const card = cards[i];
414
+ const fileEntry = (cardsFileEntries?.[i] && typeof cardsFileEntries[i] === "object") ? cardsFileEntries[i] : {};
415
+ const media = fileEntry.header_media || cardMediaFlags[i] || card.header_media_default || null;
416
+ if (!media) {
417
+ aborts.push(`card ${i + 1}: no media resolved — the template stores no default for this card; pass --card-media <url> (one per card, in order) or a --cards-file entry with header_media`);
418
+ continue;
419
+ }
420
+ const contentType = await probe(media);
421
+ let mediaLabel;
422
+ if (!contentType) {
423
+ warnings.push(`card ${i + 1}: media unverified — ${media} did not answer a type probe`);
424
+ mediaLabel = `${card.header_format} · unverified`;
425
+ } else if (contentType.split("/")[0] !== card.header_format) {
426
+ aborts.push(`card ${i + 1}: the template card header is ${card.header_format.toUpperCase()} but the resolved media (${media}) serves ${contentType} — point it at a real ${card.header_format}`);
427
+ continue;
428
+ } else {
429
+ mediaLabel = `${card.header_format} ✓ ${contentType}`;
430
+ }
431
+
432
+ // Per-card body variables must be supplied — python sends only what we pass.
433
+ let bodyParams = null;
434
+ if (card.body_positions.length) {
435
+ bodyParams = fileEntry.body_params && typeof fileEntry.body_params === "object" ? fileEntry.body_params : null;
436
+ const badKeys = bodyParams ? Object.keys(bodyParams).filter((k) => !/^param\d+$/.test(k)) : [];
437
+ if (badKeys.length) { aborts.push(`card ${i + 1}: body_params keys must be param1..N (got ${badKeys.join(", ")})`); continue; }
438
+ const missing = card.body_positions.filter((p) => !bodyParams?.[`param${p}`]);
439
+ if (missing.length) {
440
+ aborts.push(`card ${i + 1}: its body carries variable(s) ${missing.map((p) => `{{${p}}}`).join(", ")} — supply them via --cards-file (entry ${i}: {"body_params":{${card.body_positions.map((p) => `"param${p}":"…"`).join(",")}}}). {{first_name}}-style tokens inside the values are resolved per contact.`);
441
+ continue;
442
+ }
443
+ }
444
+
445
+ // URL buttons WITH a variable need a send-side value; static URL / QUICK_REPLY do not
446
+ // (quick-reply payloads default to the button text server-side).
447
+ const urlVarButtons = (card.buttons || []).filter((b) => b.type === "URL" && b.has_url_var);
448
+ let urlVars = null;
449
+ if (urlVarButtons.length) {
450
+ urlVars = fileEntry.url_vars && typeof fileEntry.url_vars === "object" ? fileEntry.url_vars : null;
451
+ const missing = urlVarButtons.filter((b) => {
452
+ const v = urlVars?.[`btn${b.index}`] ?? urlVars?.[String(b.index)];
453
+ return v === undefined || v === null || String(v) === "";
454
+ });
455
+ if (missing.length) {
456
+ aborts.push(`card ${i + 1}: button(s) ${missing.map((b) => `"${b.text ?? "URL"}"`).join(", ")} carry a URL variable — supply via --cards-file (entry ${i}: {"url_vars":{${missing.map((b) => `"btn${b.index}":"<value>"`).join(",")}}})`);
457
+ continue;
458
+ }
459
+ }
460
+ const buttonPayloads = fileEntry.button_payloads && typeof fileEntry.button_payloads === "object" ? fileEntry.button_payloads : null;
461
+
462
+ overrides.push({
463
+ file_url: media,
464
+ ...(bodyParams ? { body_parameters: bodyParams } : {}),
465
+ ...(buttonPayloads ? { button_payloads: buttonPayloads } : {}),
466
+ ...(urlVars ? { url_vars: urlVars } : {}),
467
+ });
468
+
469
+ // Preview lines for the dry-run.
470
+ let bodyPreview = card.body_text || "";
471
+ for (const [k, v] of Object.entries(bodyParams || {})) bodyPreview = bodyPreview.replaceAll(`{{${k.replace("param", "")}}}`, String(v));
472
+ const btnBits = (card.buttons || []).map((b) => {
473
+ if (b.type === "URL" && b.has_url_var) {
474
+ const v = urlVars?.[`btn${b.index}`] ?? urlVars?.[String(b.index)] ?? "";
475
+ return `[${b.text ?? "URL"} → ${String(b.url || "").replace(/\{\{[^}]+\}\}/, String(v))}]`;
476
+ }
477
+ if (b.type === "URL") return `[${b.text ?? "URL"} → ${b.url}]`;
478
+ return `[${b.text ?? b.type}]`;
479
+ }).join(" ");
480
+ lines.push(` Card ${i + 1} (${mediaLabel}): ${media}`);
481
+ if (bodyPreview) lines.push(` ${bodyPreview.replace(/\n/g, " ")}`);
482
+ if (btnBits) lines.push(` ${btnBits}`);
483
+ }
484
+ return { aborts, warnings, overrides: aborts.length ? null : overrides, lines };
485
+ }
486
+
487
+ function printCarouselPlan(cardOverrides, cardLines) {
488
+ if (!cardOverrides?.length) return;
489
+ console.log(` Carousel (${cardOverrides.length} cards — media re-uploaded to Meta once per broadcast):`);
490
+ for (const l of cardLines) console.log(l);
491
+ }
492
+
383
493
  function validateRows(template, mapping, headers, rows, illegalChars, headerMedia) {
384
494
  const aborts = [];
385
495
  if (template.status !== "APPROVED") aborts.push(`V-1: template status is ${template.status} — only APPROVED templates send`);
386
496
  if (template.parameter_format !== "POSITIONAL") aborts.push("V-2: NAMED templates are not supported in v1");
387
- if (template.is_carousel) aborts.push("V-3: carousel templates are not supported in v1");
497
+ if (template.is_carousel) aborts.push("V-3: carousel templates are TAG-MODE only — use: flowiq bc send <org> --tag <tag> --template <name> (the python engine sends carousels; per-row CSV carousel sends are not supported)");
388
498
  // Media headers (image/video/document) ARE supported — they just need an image
389
499
  // URL, exactly like the dashboard. Resolved as --header-media / saved mapping /
390
500
  // the template's stored default_url. Only block if a media header has none.
@@ -793,12 +903,13 @@ export function collectKV(pair, mapAcc) {
793
903
  * (allow_broadcast=true, not blocked) and sends in the background, returning a
794
904
  * broadcastId. No CLI write-ahead log / resume for this engine (python owns the
795
905
  * broadcast record). Dry-run (no --commit) asks python for the eligible count. */
796
- async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage }) {
906
+ async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [] }) {
797
907
  const reqBody = (dryRun) => ({
798
908
  action: "send-python", organization_id: orgId, tag, template_name: templateName,
799
909
  body_parameters: bodyLiterals,
800
910
  ...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
801
911
  ...(headerMedia ? { header_media: headerMedia } : {}),
912
+ ...(cardOverrides ? { card_overrides: cardOverrides } : {}),
802
913
  dry_run: dryRun,
803
914
  });
804
915
 
@@ -813,6 +924,7 @@ async function runPythonTagSend(orgId, { opts, tag, templateName, template, head
813
924
  console.log(`Tag "${tag}": ${total} eligible contact(s) (allow_broadcast + not blocked, resolved server-side).`);
814
925
  const sampleNum = dry.sample_contacts?.[0]?.whatsapp_id || dry.sample_contacts?.[0]?.phone_number || "<first eligible>";
815
926
  renderPreview(template, { rownum: 1, number: sampleNum, values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
927
+ printCarouselPlan(cardOverrides, cardLines);
816
928
  if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
817
929
  console.log(" ({{first_name}}-style tokens are resolved PER CONTACT by python)");
818
930
  }
@@ -867,12 +979,28 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
867
979
  ? (opts.headerMedia || cfg?.header_media || template.header_media_default || null)
868
980
  : null;
869
981
  const headerCheck = await verifyHeaderMedia(template, headerMedia);
870
- console.log(`Template ${template.name} [${template.status}] — ${template.parameter_format}, ${template.body_var_count} body var(s), header ${template.header_type}${headerCheck.label}${template.url_button?.present ? ", dynamic URL button" : ""}`);
982
+ console.log(`Template ${template.name} [${template.status}] — ${template.parameter_format}, ${template.body_var_count} body var(s), header ${template.header_type}${headerCheck.label}${template.url_button?.present ? ", dynamic URL button" : ""}${template.is_carousel ? `, CAROUSEL (${template.carousel_cards?.length ?? "?"} cards)` : ""}`);
871
983
  const aborts = [];
872
984
  if (headerCheck.status === "mismatch") aborts.push(headerMismatchMessage(template, headerMedia, headerCheck.contentType, { explicit: !!opts.headerMedia }));
873
985
  if (template.status !== "APPROVED") aborts.push(`template status is ${template.status} — only APPROVED templates send`);
874
986
  if (template.parameter_format !== "POSITIONAL") aborts.push("NAMED templates are not supported in v1");
875
- if (template.is_carousel) aborts.push("carousel templates are not supported in v1");
987
+ // Carousel templates (18 Aug 2026): supported in tag mode via the python
988
+ // engine. Resolve + verify every card BEFORE any other gate so a dry run
989
+ // reports the full card plan (or every card problem at once).
990
+ let carouselPlan = null;
991
+ if (template.is_carousel) {
992
+ let cardsFileEntries = null;
993
+ let cardsFileBroken = false;
994
+ if (opts.cardsFile) {
995
+ try { cardsFileEntries = JSON.parse(await fs.readFile(opts.cardsFile, "utf8")); }
996
+ catch (e) { aborts.push(`--cards-file ${opts.cardsFile}: ${e.message}`); cardsFileBroken = true; }
997
+ }
998
+ if (!cardsFileBroken) {
999
+ carouselPlan = await resolveCarouselCards(template, { cardMediaFlags: opts.cardMedia || [], cardsFileEntries });
1000
+ for (const w of carouselPlan.warnings) console.log(`⚠ ${w}`);
1001
+ aborts.push(...carouselPlan.aborts);
1002
+ }
1003
+ }
876
1004
  // Media headers supported — need an image (--header-media / saved / default_url).
877
1005
  if (isMediaHeader && !headerMedia) aborts.push(`template has a ${template.header_type} header but no image resolved — pass --header-media <url> (the template has no stored default header image)`);
878
1006
  const keys = Object.keys(bodyLiterals);
@@ -885,14 +1013,22 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
885
1013
  // gated by exactly the same checks as an immediate one (APPROVED, positional,
886
1014
  // param arithmetic, header-media type). Instead of sending we queue the send
887
1015
  // for later; python resolves the tag at FIRE time, so the audience is fresh.
888
- if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
1016
+ const cardOverrides = carouselPlan?.overrides ?? null;
1017
+ const cardLines = carouselPlan?.lines ?? [];
1018
+
1019
+ if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines });
889
1020
 
890
1021
  // ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
891
1022
  // python /meta-broadcast engine (the proven bulk sender). --python forces it at
892
1023
  // any size; only a ≤10 send stays on the resumable per-row Node engine.
1024
+ // Carousels are python at ANY size — the per-row Node engine
1025
+ // (/api/send-template) has no carousel support.
893
1026
  const PYTHON_MIN = 10;
894
- if (opts.python) {
895
- return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
1027
+ if (template.is_carousel && !opts.python) {
1028
+ console.log("Carousel template → PYTHON engine at any size (the per-row Node engine cannot send carousels).");
1029
+ }
1030
+ if (opts.python || template.is_carousel) {
1031
+ return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines });
896
1032
  }
897
1033
 
898
1034
  // resolve the tag server-side (broadcast-safe recipients only). A >2000 tag
@@ -985,6 +1121,10 @@ export async function map(orgId, opts = {}) {
985
1121
  let intro;
986
1122
  try { intro = await introspect(orgId, opts.template); }
987
1123
  catch (e) { console.error(`Template introspection failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1124
+ if (intro.template.is_carousel) {
1125
+ console.error("Carousel templates are TAG-MODE only (no CSV mapping) — use: flowiq bc send <org> --tag <tag> --template <name>.");
1126
+ process.exit(1);
1127
+ }
988
1128
  let csv;
989
1129
  try { csv = await loadCsv(opts.csv); }
990
1130
  catch (e) { console.error(`CSV error: ${e.message}`); process.exit(1); }
@@ -1050,7 +1190,7 @@ const fmtSast = (iso) =>
1050
1190
  new Date(iso).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg", dateStyle: "medium", timeStyle: "short" });
1051
1191
 
1052
1192
  /** Queue a tag broadcast to fire later (server writes the api_request_queue row). */
1053
- async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage }) {
1193
+ async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [] }) {
1054
1194
  const when = parseSastAt(opts.at);
1055
1195
  if (when.error) { console.error(`Error: --at ${when.error}`); process.exit(1); }
1056
1196
  if (new Date(when.iso).getTime() <= Date.now()) {
@@ -1061,13 +1201,14 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
1061
1201
 
1062
1202
  console.log("");
1063
1203
  renderPreview(template, { rownum: 1, number: "(resolved at send time)", values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
1204
+ printCarouselPlan(cardOverrides, cardLines);
1064
1205
  if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
1065
1206
  console.log(" ({{first_name}}-style tokens are resolved PER CONTACT at send time)");
1066
1207
  }
1067
1208
  console.log("");
1068
1209
  console.log(`Schedule: ${fmtSast(when.iso)} SAST${when.explicitOffset ? "" : " (--at read as SAST)"}`);
1069
1210
  console.log(`Audience: tag "${tag}" — resolved when it FIRES, not now (so late joiners are included)`);
1070
- console.log(`Engine: python /meta-broadcast`);
1211
+ console.log(`Engine: python /meta-broadcast${cardOverrides ? ` · carousel, ${cardOverrides.length} cards (frozen into the queued request)` : ""}`);
1071
1212
  console.log(needsApproval
1072
1213
  ? `Approval: REQUIRED — parks as 'request'; run "flowiq bc scheduled approve" before it can fire`
1073
1214
  : `Approval: none — fires automatically at the scheduled time`);
@@ -1090,6 +1231,7 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
1090
1231
  body_parameters: bodyLiterals,
1091
1232
  ...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
1092
1233
  ...(headerMedia ? { header_media: headerMedia } : {}),
1234
+ ...(cardOverrides ? { card_overrides: cardOverrides } : {}),
1093
1235
  });
1094
1236
  } catch (e) {
1095
1237
  console.error(`Schedule failed: ${e.message}`);
@@ -56,8 +56,11 @@ export async function pull(contactId, opts = {}) {
56
56
  if (resp.organization_name) {
57
57
  console.log(` organization: ${resp.organization_name} (${resp.organization_id})`);
58
58
  }
59
- console.log(` user msgs: ${resp.user_messages_found}/${resp.user_messages_requested} requested`);
60
- console.log(` anchor: ${resp.anchor_created_at}`);
59
+ console.log(
60
+ ` user msgs: ${resp.user_messages_found}/${resp.user_messages_requested} requested` +
61
+ (resp.reached_start ? ` — that is ALL of them; this pull is the entire history` : '')
62
+ );
63
+ console.log(` anchor: ${resp.anchor_created_at}${resp.reached_start ? ' (start of history)' : ''}`);
61
64
  console.log(` total rows: ${resp.total_rows}`);
62
65
  console.log(` by sender:`);
63
66
  for (const [k, v] of Object.entries(resp.sender_breakdown).sort()) {
package/src/index.js CHANGED
@@ -432,6 +432,8 @@ export function run(argv) {
432
432
  .option("--button <k=v>", "with --tag: dynamic URL button param, e.g. --button param1=<short-code>", broadcastCmd.collectKV, {})
433
433
  .option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
434
434
  .option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
435
+ .option("--card-media <url>", "with --tag, carousel templates: card image/video URL in card order (repeatable — one per card; default: each card's stored template image)", (v, acc) => (acc || []).concat([v]), [])
436
+ .option("--cards-file <path>", "with --tag, carousel templates: JSON file of per-card overrides [{header_media?, body_params?, button_payloads?, url_vars?}] — required when cards carry {{n}} body variables or URL-button variables")
435
437
  .option("--python", "with --tag: force the python /meta-broadcast engine (same as the dashboard's Python toggle). NOTE: any tag send >10 recipients ALWAYS uses python automatically")
436
438
  .option("--at <when>", "with --tag: SCHEDULE instead of sending now — \"YYYY-MM-DD HH:MM\" in SAST (e.g. --at \"2026-08-05 09:00\"). Fires automatically; audience is resolved at send time")
437
439
  .option("--needs-approval", "with --at: park it awaiting approval (flowiq bc scheduled approve) instead of firing automatically")