@flowapt/flowiq-cli 0.6.0 → 0.6.2

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
@@ -238,12 +238,20 @@ flowiq bc scheduled approve <org_id> <queue_id> # 'request' → 'pending' (-
238
238
  flowiq bc scheduled cancel <org_id> <queue_id> --confirm
239
239
  ```
240
240
 
241
- - **Scheduling (v0.4.4)** — `--at "YYYY-MM-DD HH:MM"` on a `--tag` send queues it
242
- instead of sending. It writes the same `api_request_queue` row the dashboard
243
- writes, replayed by the `process-api-queue` cron (every 3 min, so it fires
244
- 0–3 min after the stated minute). Runs **every** pre-send check first
241
+ - **Scheduling (v0.4.4 tag / v0.6.1 CSV)** — `--at "YYYY-MM-DD HH:MM"` queues the
242
+ send instead of firing it. It writes the same `api_request_queue` row the
243
+ dashboard writes, replayed by the `process-api-queue` cron (every 3 min, so it
244
+ fires 0–3 min after the stated minute). Runs **every** pre-send check first
245
245
  (APPROVED, positional, param arithmetic, header-media type) — a scheduled
246
246
  send can't skip a guard an immediate one enforces.
247
+ - **CSV mode (v0.6.1):** the rows are imported + tagged **NOW** (same as an
248
+ immediate send); only the python call is queued. Scheduled CSV campaigns
249
+ therefore show on the dashboard's Scheduled sends page and can be
250
+ approved/cancelled from there or via `bc scheduled` — nothing depends on
251
+ anyone's laptop being awake at send time. Python re-resolves the tag when
252
+ it fires, so anyone tagged `bc-<campaign>` in the meantime is included.
253
+ A scheduled campaign counts as fired for the re-run guard — cancel the
254
+ queued row first, then re-run with `--resend`.
247
255
  - `--at` is always read as **SAST**, never the machine's local timezone, so
248
256
  the same command schedules the same instant from anywhere.
249
257
  - The **audience resolves when it FIRES**, not when you schedule — someone
@@ -1168,9 +1176,12 @@ flowiq agent config <organization_id> # show current
1168
1176
  flowiq agent config <organization_id> --use-settings-prompt --model gpt-5.4-mini \
1169
1177
  --rename Zara --tool woo_order_build=true --tool view_cart_tool=true --discount true
1170
1178
  flowiq agent config <organization_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"
1179
+ flowiq agent config <organization_id> --model gpt-5.6-luna --reasoning-effort high
1171
1180
  ```
1172
1181
 
1173
- Settable: `settings.use_settings_prompt`, `settings.model`, agent `--rename`,
1182
+ Settable: `settings.use_settings_prompt`, `settings.model`,
1183
+ `settings.reasoning_effort` (`--reasoning-effort low|medium|high`, `""` clears it —
1184
+ pairs with reasoning models like `gpt-5.6-luna`), agent `--rename`,
1174
1185
  the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
1175
1186
  `view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
1176
1187
  `shopify_products_web_chat`, `ticket_tool_status`, `product_lookup`,
package/TEAM-GUIDE.md CHANGED
@@ -97,6 +97,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
97
97
  | Feed store data into jq / a file | stdout is the payload only, banner goes to stderr: `flowiq shopify get <org> orders.json \| jq -r '.orders[].name'` · or `--out ./orders.json` |
98
98
  | See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
99
99
  | Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
100
+ | Set the house model + reasoning tier | `flowiq agent config <org_id> --model gpt-5.6-luna --reasoning-effort high` |
100
101
  | 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 |
101
102
  | 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) |
102
103
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
@@ -114,7 +115,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
114
115
  | 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/…`) |
115
116
  | Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
116
117
  | Split a whole tagged audience into batches of N (e.g. 90k → 7000s) | `flowiq seg plan <org_id> --tag-prefix 3-aug-bc --from-tag "3-aug-bc" --batch-size 7000` → `flowiq seg apply <org_id> 3-aug-bc --commit --yes` (makes `…-batch-01…13`; works at any size — big applies are chunked internally) |
117
- | Schedule a broadcast for later instead of sending now | `flowiq bc send <org_id> --tag <batch-tag> --template <name> --at "2026-08-05 09:00" --commit` (time is SAST; fires on its own; audience resolved at send time) |
118
+ | Schedule a broadcast for later instead of sending now | `flowiq bc send <org_id> --tag <batch-tag> --template <name> --at "2026-08-05 09:00" --commit` (time is SAST; fires on its own; audience resolved at send time). **Works with --csv too (v0.6.1):** rows import+tag immediately, the send queues server-side, and it shows on the dashboard's Scheduled sends page. Nothing depends on your laptop being on at send time. |
118
119
  | 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` |
119
120
  | 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). |
120
121
  | 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. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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": {
@@ -41,6 +41,7 @@ export async function config(orgId, opts = {}) {
41
41
  const settings = {};
42
42
  if (opts.useSettingsPrompt !== undefined) settings.use_settings_prompt = parseBool(opts.useSettingsPrompt, "--use-settings-prompt");
43
43
  if (opts.model !== undefined) settings.model = opts.model;
44
+ if (opts.reasoningEffort !== undefined) settings.reasoning_effort = opts.reasoningEffort;
44
45
  if (opts.testContactNumber !== undefined) settings.test_contact_number = opts.testContactNumber;
45
46
  if (opts.testContactName !== undefined) settings.test_contact_name = opts.testContactName;
46
47
  if (Object.keys(settings).length) body.settings = settings;
@@ -75,7 +76,7 @@ export async function config(orgId, opts = {}) {
75
76
  }
76
77
  console.log(`${resp.organization_name} → agent ${resp.agent_id}${resp.is_active === false ? " [NON-active]" : ""}`);
77
78
  printSnapshot("current", resp.current);
78
- console.log("\n(pass --use-settings-prompt / --model / --rename / --tool / --discount / --test-contact-number / --test-contact-name to change)");
79
+ console.log("\n(pass --use-settings-prompt / --model / --reasoning-effort / --rename / --tool / --discount / --test-contact-number / --test-contact-name to change)");
79
80
  return;
80
81
  }
81
82
 
@@ -804,10 +804,17 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
804
804
  const { header: logHeader, map: statusMap, handoff } = await loadStatusLog(campaign);
805
805
  if (isResume) {
806
806
  if (handoff) {
807
- console.error(`Campaign "${campaign}" already fanned out via python on ${handoff.at}${handoff.broadcast_id ? ` (broadcastId ${handoff.broadcast_id})` : ""}.`);
808
- console.error("There is no per-row resume on the python engine — python owns the send. Audit / re-fire failures with:");
809
- console.error(` flowiq bc status ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} [--failures]`);
810
- console.error(` flowiq bc retry ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} --commit`);
807
+ if (handoff.queue_id) {
808
+ console.error(`Campaign "${campaign}" is QUEUED via python (queue id ${handoff.queue_id}, fires ${handoff.scheduled_for || "?"}).`);
809
+ console.error("There is nothing to resume — manage the queued send instead:");
810
+ console.error(` flowiq bc scheduled list ${orgId}`);
811
+ console.error(` flowiq bc scheduled cancel ${orgId} ${handoff.queue_id} --confirm`);
812
+ } else {
813
+ console.error(`Campaign "${campaign}" already fanned out via python on ${handoff.at}${handoff.broadcast_id ? ` (broadcastId ${handoff.broadcast_id})` : ""}.`);
814
+ console.error("There is no per-row resume on the python engine — python owns the send. Audit / re-fire failures with:");
815
+ console.error(` flowiq bc status ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} [--failures]`);
816
+ console.error(` flowiq bc retry ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} --commit`);
817
+ }
811
818
  process.exit(1);
812
819
  }
813
820
  if (!logHeader) { console.error(`No status log for campaign "${campaign}" — nothing to resume.`); process.exit(1); }
@@ -868,23 +875,55 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
868
875
  const tokens = buildCsvTokenParams(template, campaign);
869
876
  const batchCap = Math.min(Number(opts.upsertBatch ?? CONTACT_BATCH_CAP) || CONTACT_BATCH_CAP, CONTACT_BATCH_CAP);
870
877
  if (opts.skipUpsert) console.log("⚠ --skip-upsert is ignored on the python CSV path — the upsert IS how per-row values reach python.");
878
+ // --at (v0.6.1): upsert+tag NOW, then QUEUE the python call instead of firing
879
+ // it — the same api_request_queue row tag sends and the dashboard write,
880
+ // replayed by the process-api-queue cron. Parsed EARLY so a garbage/past
881
+ // value aborts before anything is written.
882
+ let scheduledAt = null;
883
+ if (opts.at) {
884
+ scheduledAt = parseSastAt(opts.at);
885
+ if (scheduledAt.error) { console.error(`Error: --at ${scheduledAt.error}`); process.exit(1); }
886
+ if (new Date(scheduledAt.iso).getTime() <= Date.now()) {
887
+ console.error(`ABORT — --at is in the past (${fmtSast(scheduledAt.iso)} SAST). Nothing would ever fire.`);
888
+ process.exit(1);
889
+ }
890
+ }
871
891
  console.log(`Engine: PYTHON server-side fan-out (yapi.store/meta-broadcast) — fire-and-forget, python-paced`);
872
892
  console.log(`Tag: "${tag}" · per-row values → attributes bc_${campaign}_param1..${template.body_var_count}${tokens.button ? " + _button" : ""}`);
873
- console.log(`Plan: upsert+tag ${workSet.length} contact(s) in ${Math.ceil(Math.max(1, workSet.length) / batchCap)} batch(es) → one python broadcast`);
893
+ console.log(`Plan: upsert+tag ${workSet.length} contact(s) in ${Math.ceil(Math.max(1, workSet.length) / batchCap)} batch(es) → ${scheduledAt ? "QUEUE one python broadcast" : "one python broadcast"}`);
894
+ if (scheduledAt) {
895
+ console.log(`Schedule: fires ${fmtSast(scheduledAt.iso)} SAST${scheduledAt.explicitOffset ? "" : " (--at read as SAST)"} — server-side queue row, replayed by the process-api-queue cron (0–3 min after the minute); shows on the dashboard's Scheduled sends page`);
896
+ console.log(opts.needsApproval
897
+ ? `Approval: REQUIRED — parks as 'request'; run "flowiq bc scheduled approve" before it can fire`
898
+ : `Approval: none — fires automatically at the scheduled time`);
899
+ }
874
900
 
875
901
  // A prior fan-off means the tag already reached its audience — re-running
876
902
  // resends to EVERY tagged contact (python cannot skip per row).
877
903
  if (handoff && !opts.resend) {
878
904
  console.log("");
879
- console.log(`⚠ Campaign "${campaign}" ALREADY fanned out via python on ${handoff.at}${handoff.broadcast_id ? ` (broadcastId ${handoff.broadcast_id})` : ""}.`);
880
- if (!commitStage) {
881
- console.log(" A --commit re-run would resend to EVERY tagged contact (no per-row skip). Audit with bc status; re-fire failures with bc retry; pass --resend to deliberately resend to all.");
905
+ if (handoff.queue_id) {
906
+ console.log(`⚠ Campaign "${campaign}" is already QUEUED via python (queue id ${handoff.queue_id}, fires ${handoff.scheduled_for || "?"}).`);
907
+ if (!commitStage) {
908
+ console.log(" A --commit re-run would be refused — manage the queued send with bc scheduled list/cancel; pass --resend to run this campaign again (e.g. after cancelling the queued row).");
909
+ } else {
910
+ console.error(" Refusing to double-book the tag. Manage the queued send instead:");
911
+ console.error(` flowiq bc scheduled list ${orgId}`);
912
+ console.error(` flowiq bc scheduled cancel ${orgId} ${handoff.queue_id} --confirm`);
913
+ console.error(" To run this campaign again anyway (e.g. after cancelling), pass --resend.");
914
+ process.exit(1);
915
+ }
882
916
  } else {
883
- console.error(" Refusing to resend to the whole tag. Audit / re-fire failures with:");
884
- console.error(` flowiq bc status ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} [--failures]`);
885
- console.error(` flowiq bc retry ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} --commit`);
886
- console.error(" To deliberately resend to EVERYONE tagged, pass --resend.");
887
- process.exit(1);
917
+ console.log(`⚠ Campaign "${campaign}" ALREADY fanned out via python on ${handoff.at}${handoff.broadcast_id ? ` (broadcastId ${handoff.broadcast_id})` : ""}.`);
918
+ if (!commitStage) {
919
+ console.log(" A --commit re-run would resend to EVERY tagged contact (no per-row skip). Audit with bc status; re-fire failures with bc retry; pass --resend to deliberately resend to all.");
920
+ } else {
921
+ console.error(" Refusing to resend to the whole tag. Audit / re-fire failures with:");
922
+ console.error(` flowiq bc status ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} [--failures]`);
923
+ console.error(` flowiq bc retry ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} --commit`);
924
+ console.error(" To deliberately resend to EVERYONE tagged, pass --resend.");
925
+ process.exit(1);
926
+ }
888
927
  }
889
928
  }
890
929
  // A pre-v0.6.0 per-row log means part of the audience was already reached
@@ -907,7 +946,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
907
946
 
908
947
  if (!commitStage) {
909
948
  console.log("");
910
- console.log("DRY RUN — nothing was sent, no contacts written or tagged. Add --commit to send.");
949
+ console.log(`DRY RUN — nothing was ${scheduledAt ? "scheduled" : "sent"}, no contacts written or tagged. Add --commit to ${scheduledAt ? "queue it" : "send"}.`);
911
950
  return;
912
951
  }
913
952
  if (!workSet.length) { console.log("Nothing to send — all rows were skipped."); return; }
@@ -915,7 +954,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
915
954
  // 8. gate (BEFORE any write — the upsert itself tags real contacts)
916
955
  if (!opts.yes) {
917
956
  console.log("");
918
- const answer = await ask(`Type the campaign name ("${campaign}") to tag ${workSet.length} contact(s) and fire ONE python broadcast: `);
957
+ const answer = await ask(`Type the campaign name ("${campaign}") to tag ${workSet.length} contact(s) and ${scheduledAt ? `QUEUE one python broadcast for ${fmtSast(scheduledAt.iso)} SAST` : "fire ONE python broadcast"}: `);
919
958
  if (answer !== campaign) { console.log("Mismatch — aborted, nothing written, nothing sent."); process.exit(0); }
920
959
  }
921
960
 
@@ -942,27 +981,68 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
942
981
  }
943
982
  if (pyTotal != null && pyTotal !== workSet.length) {
944
983
  if (pyTotal > workSet.length) {
945
- console.log(`⚠ Tag "${tag}" resolves to ${pyTotal} contact(s) — ${pyTotal - workSet.length} MORE than this CSV (tagged by an earlier run of this campaign?). They will ALSO receive the send.`);
984
+ console.log(`⚠ Tag "${tag}" resolves to ${pyTotal} contact(s) — ${pyTotal - workSet.length} MORE than this CSV (tagged by an earlier run of this campaign?). They will ALSO receive the send${scheduledAt ? " when it fires" : ""}.`);
946
985
  if (!opts.yes) {
947
- const extra = await ask(`Type YES to send to all ${pyTotal}: `);
948
- if (extra !== "YES") { console.log(`Aborted — nothing sent. The tag still carries ${pyTotal} contact(s); use a fresh --campaign for a clean audience.`); process.exit(0); }
986
+ const extra = await ask(`Type YES to ${scheduledAt ? "schedule for" : "send to"} all ${pyTotal}: `);
987
+ if (extra !== "YES") { console.log(`Aborted — nothing ${scheduledAt ? "scheduled" : "sent"}. The tag still carries ${pyTotal} contact(s); use a fresh --campaign for a clean audience.`); process.exit(0); }
949
988
  }
950
989
  } else {
951
990
  console.log(`Note: python resolves ${pyTotal} of ${workSet.length} tagged (the rest are opted-out/blocked server-side).`);
952
991
  }
953
992
  }
954
993
 
955
- // 11. fire — ONE call; python paces + tracks the rest
956
- let out;
957
- try { out = await http.post("broadcast", reqBody(false)); }
958
- catch (e) { console.error(`Python broadcast failed: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`); process.exit(1); }
959
- if (!logHeader) {
994
+ const writeMetaLine = async () => {
995
+ if (logHeader) return;
960
996
  await appendLog(campaign, {
961
997
  type: "meta", campaign, organization_id: orgId, template_name: templateName, engine: "python",
962
998
  csv_fingerprint: csv.header_fingerprint, total_rows: csv.rows.length, valid_rows: sendable.length,
963
999
  started_at: new Date().toISOString(),
964
1000
  });
1001
+ };
1002
+
1003
+ // 11a. --at: QUEUE instead of firing — the same api_request_queue row a tag
1004
+ // send writes (type broadcast_send_python), so the dashboard's Scheduled
1005
+ // sends page + bc scheduled list/approve/cancel all see it. Python
1006
+ // re-resolves the tag when it FIRES, so late tag joiners are included.
1007
+ if (scheduledAt) {
1008
+ let resp;
1009
+ try {
1010
+ resp = await http.post("broadcast", {
1011
+ action: "schedule", organization_id: orgId, tag, template_name: templateName,
1012
+ scheduled_for: scheduledAt.iso, needs_approval: !!opts.needsApproval,
1013
+ body_parameters: tokens.body,
1014
+ ...(tokens.button ? { button_parameters: { param1: tokens.button } } : {}),
1015
+ ...(headerMedia ? { header_media: headerMedia } : {}),
1016
+ });
1017
+ } catch (e) {
1018
+ console.error(`Schedule failed: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`);
1019
+ console.error(`Nothing queued. The ${workSet.length} contact(s) ARE tagged "${tag}" — re-run to retry the schedule.`);
1020
+ process.exit(1);
1021
+ }
1022
+ await writeMetaLine();
1023
+ await appendLog(campaign, {
1024
+ type: "handoff", engine: "python-scheduled", tag, queue_id: resp.queue_id ?? null,
1025
+ scheduled_for: resp.scheduled_for ?? scheduledAt.iso, tagged: workSet.length, python_total: pyTotal,
1026
+ at: new Date().toISOString(),
1027
+ });
1028
+ console.log("");
1029
+ console.log(`✓ Scheduled for ${fmtSast(resp.scheduled_for ?? scheduledAt.iso)} SAST — queue id ${resp.queue_id}`);
1030
+ console.log(` ${workSet.length} contact(s) tagged "${tag}" now; python re-resolves the tag when it fires.`);
1031
+ if (resp.fires_automatically ?? !opts.needsApproval) {
1032
+ console.log(` It will fire on its own. Cancel with: flowiq bc scheduled cancel ${orgId} ${resp.queue_id}`);
1033
+ } else {
1034
+ console.log(` ⚠ PARKED awaiting approval — it will NOT fire until you run:`);
1035
+ console.log(` flowiq bc scheduled approve ${orgId} ${resp.queue_id}`);
1036
+ }
1037
+ console.log(` Also visible/manageable on the dashboard's Scheduled sends page.`);
1038
+ return;
965
1039
  }
1040
+
1041
+ // 11b. fire — ONE call; python paces + tracks the rest
1042
+ let out;
1043
+ try { out = await http.post("broadcast", reqBody(false)); }
1044
+ catch (e) { console.error(`Python broadcast failed: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`); process.exit(1); }
1045
+ await writeMetaLine();
966
1046
  await appendLog(campaign, {
967
1047
  type: "handoff", engine: "python", tag, broadcast_id: out.broadcastId ?? out.broadcast_id ?? null,
968
1048
  tagged: workSet.length, python_total: pyTotal, at: new Date().toISOString(),
package/src/index.js CHANGED
@@ -271,6 +271,7 @@ export function run(argv) {
271
271
  .option("--agent <id>", "target a specific agent instead of the org's active one")
272
272
  .option("--use-settings-prompt [bool]", "settings.use_settings_prompt (true if bare)")
273
273
  .option("--model <model>", "settings.model (e.g. gpt-5.4-mini)")
274
+ .option("--reasoning-effort <level>", "settings.reasoning_effort — low|medium|high (\"\" clears it); pairs with reasoning models (e.g. gpt-5.6-luna + high)")
274
275
  .option("--rename <name>", "rename the agent")
275
276
  .option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat/ticket_tool_status/collapse_product_variants", agentConfigCmd.collectTool, [])
276
277
  .option("--discount [bool]", "agent.discount.enabled (true if bare)")
@@ -459,7 +460,7 @@ export function run(argv) {
459
460
  .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]), [])
460
461
  .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")
461
462
  .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")
462
- .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")
463
+ .option("--at <when>", "SCHEDULE instead of sending now (tag AND CSV mode, v0.6.1) — \"YYYY-MM-DD HH:MM\" in SAST (e.g. --at \"2026-08-05 09:00\"). CSV: rows are imported+tagged NOW, the python call is queued server-side and shows on the dashboard's Scheduled sends page. Fires automatically; the tag is re-resolved at send time")
463
464
  .option("--needs-approval", "with --at: park it awaiting approval (flowiq bc scheduled approve) instead of firing automatically")
464
465
  .option("--commit", "actually send (omit to dry-run)")
465
466
  .option("--yes", "skip the type-the-campaign-name confirm gate (CI)")