@flowapt/flowiq-cli 0.3.5 → 0.3.7

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
@@ -160,7 +160,7 @@ flowiq ct list
160
160
  - **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`, …).
161
161
  - `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
162
162
 
163
- ### Broadcast — `flowiq broadcast map|preview|send|resume|status|list` (alias `bc`)
163
+ ### Broadcast — `flowiq broadcast map|preview|send|resume|status|retry|list` (alias `bc`)
164
164
 
165
165
  Send an **APPROVED** WhatsApp template to every row of a CSV, filling the
166
166
  template's variables **per row** from the CSV's own columns. The
@@ -198,7 +198,19 @@ flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
198
198
  by its id — `read` / `delivered` / `sent` / `failed` (+ % reached) from
199
199
  `helpdesk_messages`. Built for the `--python` fire-and-forget engine (which
200
200
  returns a `broadcastId` but has no CLI status log), but works for any broadcast
201
- id (dashboard sends included). `flowiq bc status <org> <broadcastId>`.
201
+ id (dashboard sends included). **`--failures` (v0.3.7)** additionally lists each
202
+ failed recipient + the Meta error reason (`error_code` / `error_title`) with a
203
+ by-reason rollup — makes a fire-and-forget send fully auditable.
204
+ - **`retry <org> <broadcastId>` (v0.3.7)**: re-send a broadcast to **only its
205
+ failed recipients** — reconstructs the send from the `broadcasts` row (template +
206
+ params + header) and re-fires via the python engine, creating a NEW broadcast.
207
+ DRY-RUN by default (shows the failed count + reason breakdown); `--commit` fires.
208
+ Note: permanent failures (e.g. `131026` undeliverable, opt-outs) just fail again —
209
+ retry earns its keep on transient (throttle/throughput) failures. Capped at 500.
210
+ - **Engine auto-routing (v0.3.6):** any `send --tag` of **more than 10** eligible
211
+ recipients **always uses the python engine** (a `>2000` tag routes there too).
212
+ Only a ≤10 send stays on the resumable per-row Node engine. `--python` forces
213
+ python at any size. (CSV per-row sends stay on Node — python takes uniform params.)
202
214
  - **`--python` engine (v0.3.4, `send --tag` only)**: hand the whole send to the
203
215
  **python `/meta-broadcast` endpoint** — the *same* sender the dashboard's "Python
204
216
  endpoint" toggle uses — via a staff-gated proxy (the master key stays server-side
@@ -238,8 +250,12 @@ flowiq bc send <org_id> --tag july-promo-batch-01 --template fresh_drop_v1 \
238
250
  ```
239
251
 
240
252
  Values are shared across the tag (use `{{first_name}}` etc. for per-contact
241
- personalization — resolved server-side). Opted-out / archived / blocked
242
- contacts under the tag are excluded automatically. Tags with >2000 contacts
253
+ personalization — resolved server-side). Supported per-contact tokens:
254
+ `{{first_name}}` `{{full_name}}` `{{email}}` `{{phone_number}}`
255
+ `{{whatsapp_id}}` `{{contact_id}}`, plus `{{attributes.<key>}}` for any
256
+ top-level key of the contact's `attributes` JSON (missing / object-valued
257
+ keys render as empty — both engines, since 21 Jul 2026). Opted-out /
258
+ archived / blocked contacts under the tag are excluded automatically. Tags with >2000 contacts
243
259
  are refused — slice them with `flowiq segments` first.
244
260
 
245
261
  ### Segments — `flowiq segments plan|apply|list|untag` (alias `seg`)
package/TEAM-GUIDE.md CHANGED
@@ -90,10 +90,12 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
90
90
  | Split your whole broadcast list into N even cohorts (e.g. 3 for A/B/C or waves) | `flowiq seg plan <org_id> --tag-prefix bcast --from-attribute allow_broadcast_true --split 3` → `flowiq seg apply <org_id> bcast --commit` (makes `bcast-batch-01/02/03`, ~even, shuffled) |
91
91
  | Combine existing tags → a batched send list (include some tags, drop others, split into batches of N) | `flowiq seg plan <org_id> --tag-prefix clearance-bc --from-tag "loyalty-list" --exclude "recent-campaign" --batch-size 1000` → `flowiq seg apply <org_id> clearance-bc --commit` (makes `clearance-bc-batch-01/02/…`) |
92
92
  | Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
93
- | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` |
93
+ | 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>}}`) |
94
94
  | Send a broadcast whose template has an IMAGE header | Same as above — the image is automatic (the template's own header image). Override with `--header-media <public-image-url>` if needed. |
95
- | 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) |
95
+ | 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.** |
96
96
  | Check how a broadcast is landing (read/delivered/sent/failed) | `flowiq bc status <org_id> <broadcastId>` (the `broadcastId` a `--python` send prints) |
97
+ | See WHO failed on a broadcast + why (Meta error) | `flowiq bc status <org_id> <broadcastId> --failures` |
98
+ | Re-send a broadcast to only the ones that failed | `flowiq bc retry <org_id> <broadcastId>` (dry-run) → `… --commit` |
97
99
  | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
98
100
  | Export an org's full chat history | `flowiq export chats <org_id>` |
99
101
  | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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": {
@@ -812,17 +812,29 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
812
812
  if (template.url_button?.present && !buttonLiteral) aborts.push("template has a dynamic URL button — pass --button param1=<code>");
813
813
  if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
814
814
 
815
- // --python: hand the whole send to the python /meta-broadcast engine (the same
816
- // sender the dashboard's "Python endpoint" toggle uses) via the staff-gated
817
- // proxy. Python resolves the tag + eligibility + sends fire-and-forget.
815
+ // ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
816
+ // python /meta-broadcast engine (the proven bulk sender). --python forces it at
817
+ // any size; only a ≤10 send stays on the resumable per-row Node engine.
818
+ const PYTHON_MIN = 10;
818
819
  if (opts.python) {
819
820
  return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
820
821
  }
821
822
 
822
- // resolve the tag server-side (broadcast-safe recipients only)
823
+ // resolve the tag server-side (broadcast-safe recipients only). A >2000 tag
824
+ // 413s the Node resolver — that's by definition >10, so route straight to python.
823
825
  let resolved;
824
826
  try { resolved = await http.post("broadcast", { action: "resolve-tag", organization_id: orgId, tag }); }
825
- catch (e) { console.error(`Tag resolution failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
827
+ catch (e) {
828
+ if (e.status === 413) {
829
+ console.log(`Tag "${tag}" has >2000 contacts → PYTHON engine (any send >${PYTHON_MIN} uses python).`);
830
+ return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
831
+ }
832
+ console.error(`Tag resolution failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1);
833
+ }
834
+ if (resolved.recipients.length > PYTHON_MIN) {
835
+ console.log(`Tag "${tag}": ${resolved.recipients.length} sendable (>${PYTHON_MIN}) → PYTHON engine (any send >${PYTHON_MIN} uses python).`);
836
+ return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
837
+ }
826
838
  console.log(`Tag "${tag}" on ${resolved.organization_name}: ${resolved.tagged_total} contact(s), ${resolved.recipients.length} sendable, ${resolved.excluded.length} excluded (opt-out/archived/blocked).`);
827
839
  if (!resolved.recipients.length) { console.log("Nothing sendable under this tag."); return; }
828
840
 
@@ -950,7 +962,7 @@ export async function status(orgId, broadcastId, opts = {}) {
950
962
  if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
951
963
  if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (the broadcastId a --python send printed)."); process.exit(1); }
952
964
  let resp;
953
- try { resp = await http.post("broadcast", { action: "status", organization_id: orgId, broadcast_id: broadcastId }); }
965
+ try { resp = await http.post("broadcast", { action: "status", organization_id: orgId, broadcast_id: broadcastId, include_failures: !!opts.failures }); }
954
966
  catch (e) { console.error(`Status failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
955
967
  if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
956
968
  const b = resp.broadcast, d = resp.delivery;
@@ -964,6 +976,47 @@ export async function status(orgId, broadcastId, opts = {}) {
964
976
  if (b.total_recipients) {
965
977
  console.log(` ${((reached / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients reached${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
966
978
  }
979
+ if (opts.failures && resp.failures) {
980
+ console.log("");
981
+ console.log(` Failures by reason:`);
982
+ for (const [reason, n] of Object.entries(resp.failure_reasons || {}).sort((a, b2) => b2[1] - a[1])) console.log(` ${String(n).padStart(5)} × ${reason}`);
983
+ const cap = opts.limit ? Number(opts.limit) : 40;
984
+ console.log("");
985
+ console.log(` Failed recipients${resp.failures.length > cap ? ` (showing ${cap} of ${resp.failures.length}; --json for all)` : ` (${resp.failures.length})`}:`);
986
+ for (const f of resp.failures.slice(0, cap)) {
987
+ console.log(` ${(f.number || "—").padEnd(14)} ${(f.name || "").slice(0, 22).padEnd(22)} ${f.error_code ?? "?"} ${f.error_title || ""}`);
988
+ }
989
+ }
990
+ }
991
+
992
+ /** Re-send a broadcast to ONLY its failed recipients (transient-failure recovery).
993
+ * Reconstructs the send from the broadcasts row + re-fires via python. The dry-run
994
+ * shows the failed count + reason breakdown; --commit creates a NEW broadcast. */
995
+ export async function retry(orgId, broadcastId, opts = {}) {
996
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
997
+ if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (the broadcastId to retry)."); process.exit(1); }
998
+ let dry;
999
+ try { dry = await http.post("broadcast", { action: "retry", organization_id: orgId, broadcast_id: broadcastId, dry_run: true }); }
1000
+ catch (e) { console.error(`Retry check failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1001
+ console.log(`Broadcast ${broadcastId} (${dry.broadcast?.template_name ?? "?"}) — ${dry.failed_count} failed recipient(s).`);
1002
+ for (const [reason, n] of Object.entries(dry.failure_reasons || {}).sort((a, b) => b[1] - a[1])) console.log(` ${String(n).padStart(5)} × ${reason}`);
1003
+ if (!dry.failed_count) { console.log("Nothing to retry."); return; }
1004
+ console.log("");
1005
+ console.log("Note: permanent failures (131026 undeliverable / opt-outs) just fail again — retry helps transient (throttle/throughput) failures.");
1006
+ if (!opts.commit) {
1007
+ console.log(`DRY RUN — would re-send to ${dry.failed_count} failed recipient(s) via the python engine. Add --commit to retry.`);
1008
+ return;
1009
+ }
1010
+ if (!opts.yes) {
1011
+ const a = await ask(`Type "retry" to re-send to ${dry.failed_count} failed recipient(s): `);
1012
+ if (a !== "retry") { console.log("Aborted — nothing re-sent."); process.exit(0); }
1013
+ }
1014
+ let out;
1015
+ try { out = await http.post("broadcast", { action: "retry", organization_id: orgId, broadcast_id: broadcastId, dry_run: false }); }
1016
+ catch (e) { console.error(`Retry failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1017
+ console.log("");
1018
+ console.log(`✅ ${out.message || `Retry fired for ${out.retried} recipient(s)`}${out.broadcastId ? ` · new broadcastId ${out.broadcastId}` : ""}`);
1019
+ if (out.broadcastId) console.log(` Watch it: flowiq bc status ${orgId} ${out.broadcastId} --failures`);
967
1020
  }
968
1021
 
969
1022
  export async function list() {
package/src/index.js CHANGED
@@ -391,7 +391,7 @@ export function run(argv) {
391
391
  .option("--button <k=v>", "with --tag: dynamic URL button param, e.g. --button param1=<short-code>", broadcastCmd.collectKV, {})
392
392
  .option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
393
393
  .option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
394
- .option("--python", "with --tag: send via the python /meta-broadcast engine (same as the dashboard's Python toggle; fire-and-forget, python-tracked)")
394
+ .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")
395
395
  .option("--commit", "actually send (omit to dry-run)")
396
396
  .option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
397
397
  .option("--force-remap", "ignore the saved mapping and rebuild interactively")
@@ -413,8 +413,15 @@ export function run(argv) {
413
413
  .action((orgId, opts) => broadcastCmd.resume(orgId, opts));
414
414
  broadcast.command("status <organization_id> <broadcast_id>")
415
415
  .description("Live delivery counts (read/delivered/sent/failed) for a broadcast by its broadcastId — e.g. from a --python fire-and-forget send")
416
+ .option("--failures", "also list each FAILED recipient + the Meta error reason (makes a python send auditable)")
417
+ .option("--limit <n>", "with --failures: how many failed rows to print", "40")
416
418
  .option("--json", "raw JSON")
417
419
  .action((orgId, broadcastId, opts) => broadcastCmd.status(orgId, broadcastId, opts));
420
+ broadcast.command("retry <organization_id> <broadcast_id>")
421
+ .description("Re-send a broadcast to ONLY its failed recipients (transient-failure recovery). DRY-RUN by default; --commit re-fires via python")
422
+ .option("--commit", "actually re-send (omit to see the failed count + reasons)")
423
+ .option("--yes", "skip the type-'retry' confirm gate")
424
+ .action((orgId, broadcastId, opts) => broadcastCmd.retry(orgId, broadcastId, opts));
418
425
  broadcast.command("list")
419
426
  .description("List local campaigns + sent counts")
420
427
  .action(() => broadcastCmd.list());