@oxygen-agent/cli 1.209.6 → 1.218.5

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
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.209.6
37
+ Version: 1.218.5
package/dist/index.js CHANGED
@@ -1296,6 +1296,35 @@ export function createProgram() {
1296
1296
  .action(async (sessionId, options) => {
1297
1297
  await handleAsyncAction("notetaker status", options, () => requestOxygen(`/api/cli/notetaker/sessions/${encodeURIComponent(sessionId)}`));
1298
1298
  }));
1299
+ program
1300
+ .command("dashboards")
1301
+ .description("Default GTM dashboards: the stitched Command Center funnel across the sequencer, unibox, and CRM.")
1302
+ .addCommand(new Command("summary")
1303
+ .description("Show the GTM funnel summary: touches → replies → positive → meetings → deals → won, with this-period revenue, channel split, and needs-action triage counts.")
1304
+ .option("--range <range>", "Preset range: 7d, 14d, 28d, or 30d. Defaults to 14d.")
1305
+ .option("--from <date>", "Custom start date (YYYY-MM-DD).")
1306
+ .option("--to <date>", "Custom end date (YYYY-MM-DD).")
1307
+ .option("--cold-deal-days <n>", "Days of no activity before an open deal counts as cold. Defaults to 14.")
1308
+ .option("--json", "Print a JSON envelope.")
1309
+ .action(async (options) => {
1310
+ await handleAsyncAction("dashboards summary", options, () => {
1311
+ const params = new URLSearchParams();
1312
+ const range = readOption(options.range);
1313
+ const from = readOption(options.from);
1314
+ const to = readOption(options.to);
1315
+ const coldDealDays = readOption(options.coldDealDays);
1316
+ if (range)
1317
+ params.set("range", range);
1318
+ if (from)
1319
+ params.set("from", from);
1320
+ if (to)
1321
+ params.set("to", to);
1322
+ if (coldDealDays)
1323
+ params.set("cold_deal_days", coldDealDays);
1324
+ const qs = params.toString();
1325
+ return requestOxygen(`/api/cli/dashboards/summary${qs ? `?${qs}` : ""}`);
1326
+ });
1327
+ }));
1299
1328
  program
1300
1329
  .command("crm")
1301
1330
  .description("Agent-native CRM object setup and metadata commands.")
@@ -4460,6 +4489,9 @@ export function createProgram() {
4460
4489
  .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
4461
4490
  .option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
4462
4491
  .option("--timezone <tz>", "IANA timezone the daily action counters reset in, e.g. America/New_York.")
4492
+ .option("--warmup-restart", "Start (or restart) the warm-up ramp now — gradually raises this account's invite + message caps to full over ~2 weeks.")
4493
+ .option("--warmup-disable", "Turn off warm-up for this account (treat it as already warm and use its full configured caps).")
4494
+ .option("--warmup-start-date <date>", "Set the warm-up start date (ISO, e.g. 2026-01-31). A past date credits prior warming and advances the ramp.")
4463
4495
  .option("--json", "Print a JSON envelope.")
4464
4496
  .action(async (id, options) => {
4465
4497
  await handleAsyncAction("senders limits set", options, () => {
@@ -4470,11 +4502,229 @@ export function createProgram() {
4470
4502
  });
4471
4503
  });
4472
4504
  }))));
4505
+ program.addCommand(new Command("connections")
4506
+ .description("Read a LinkedIn account's 1st-degree connections (your network) into a Clay-like workspace table you can enrich, qualify, and enroll from. Imports run as a slow, durable background drip under a dedicated conservative read budget — they never burst and never starve the sequencer's own reads.")
4507
+ .addCommand(new Command("import")
4508
+ .description("Start (or re-arm) a connections import. Connections drip into a workspace table over many ticks; poll `connections status` to watch them accrue. The import keeps the table fresh by periodically re-walking the network in the background (new connections dedupe in). No messages are sent and no Oxygen credits are charged.")
4509
+ .option("--account <ref>", "Sender account to import (sender id, connection id, or Unipile account id). Omit to import every active LinkedIn account.")
4510
+ .option("--table <ref>", "Existing workspace table (id or slug) to import into. Omit to create or reuse a 'LinkedIn Connections' table.")
4511
+ .option("--json", "Print a JSON envelope.")
4512
+ .action(async (options) => {
4513
+ await handleAsyncAction("connections import", options, () => {
4514
+ const account = readOption(options.account);
4515
+ const table = readOption(options.table);
4516
+ return requestOxygen("/api/cli/linkedin/connections/import", {
4517
+ method: "POST",
4518
+ body: {
4519
+ ...(account ? { account } : {}),
4520
+ ...(table ? { table } : {}),
4521
+ },
4522
+ });
4523
+ });
4524
+ }))
4525
+ .addCommand(new Command("status")
4526
+ .description("Show connections-import progress (per-sender drip status, connections ingested, last error) plus a preview of the connections table and a deep-link to open it.")
4527
+ .option("--account <ref>", "Scope to one sender account (sender id, connection id, or Unipile account id).")
4528
+ .option("--json", "Print a JSON envelope.")
4529
+ .action(async (options) => {
4530
+ await handleAsyncAction("connections status", options, () => {
4531
+ const account = readOption(options.account);
4532
+ const params = new URLSearchParams();
4533
+ if (account)
4534
+ params.set("account", account);
4535
+ const suffix = params.toString();
4536
+ return requestOxygen(`/api/cli/linkedin/connections/status${suffix ? `?${suffix}` : ""}`);
4537
+ });
4538
+ })));
4539
+ program.addCommand(new Command("engagement")
4540
+ .description("Harvest a LinkedIn post's engagers (reactors + commenters) into an enrollable CRM static list. The harvest runs as a slow, durable background drip under a dedicated conservative read budget — it never bursts and never starves the sequencer's own reads.")
4541
+ .addCommand(new Command("harvest")
4542
+ .description("Start (or re-arm) a harvest of a post's engagers into a CRM static list you can enroll into a sequence. Engagers drip into the list over many ticks; poll `engagement status` to watch it fill. No messages are sent and no Oxygen credits are charged.")
4543
+ .requiredOption("--post <social_id>", "Composite post social_id from `oxygen posts get` (NOT the activity URN).")
4544
+ .requiredOption("--list <slug>", "CRM static list slug to fill (created or reused over the people object).")
4545
+ .option("--recurring", "Keep re-polling the newest pages for new engagers (default: one walk).")
4546
+ .option("--account <ref>", "Sender account that reads (sender id, connection id, or Unipile account id). Omit for the org default.")
4547
+ .option("--no-reactions", "Skip reactors.")
4548
+ .option("--no-comments", "Skip commenters.")
4549
+ .option("--json", "Print a JSON envelope.")
4550
+ .action(async (options) => {
4551
+ await handleAsyncAction("engagement harvest", options, () => {
4552
+ const post = readOption(options.post);
4553
+ const list = readOption(options.list);
4554
+ const account = readOption(options.account);
4555
+ return requestOxygen("/api/cli/linkedin/engagement/harvest", {
4556
+ method: "POST",
4557
+ body: {
4558
+ ...(post ? { post } : {}),
4559
+ ...(list ? { list } : {}),
4560
+ ...(options.recurring ? { recurring: true } : {}),
4561
+ ...(account ? { account } : {}),
4562
+ // commander sets reactions/comments=false for --no-* flags.
4563
+ ...(options.reactions === false ? { include_reactions: false } : {}),
4564
+ ...(options.comments === false ? { include_comments: false } : {}),
4565
+ },
4566
+ });
4567
+ });
4568
+ }))
4569
+ .addCommand(new Command("status")
4570
+ .description("Show an engagement harvest's progress (drip status, engagers ingested, last error) plus the CRM list it fills and a preview of harvested engagers.")
4571
+ .option("--account <ref>", "Scope to one sender account (sender id, connection id, or Unipile account id).")
4572
+ .option("--post <social_id>", "Scope to one harvested post.")
4573
+ .option("--json", "Print a JSON envelope.")
4574
+ .action(async (options) => {
4575
+ await handleAsyncAction("engagement status", options, () => {
4576
+ const account = readOption(options.account);
4577
+ const post = readOption(options.post);
4578
+ const params = new URLSearchParams();
4579
+ if (account)
4580
+ params.set("account", account);
4581
+ if (post)
4582
+ params.set("post", post);
4583
+ const suffix = params.toString();
4584
+ return requestOxygen(`/api/cli/linkedin/engagement/harvest${suffix ? `?${suffix}` : ""}`);
4585
+ });
4586
+ })));
4587
+ program.addCommand(new Command("linkedin")
4588
+ .description("LinkedIn read-into-workspace ingestion controls (Goal-3): the unified status of the connections / engagement / message-history drips and each account's remaining ingest read budget.")
4589
+ .addCommand(new Command("ingestion")
4590
+ .description("Inspect the LinkedIn ingestion drips that read your network, post engagers, and message history into the workspace.")
4591
+ .addCommand(new Command("status")
4592
+ .description("Unified status of every LinkedIn ingestion drip (connections import, engagement harvest, message-history backfill) plus each account's remaining ingest read budget for the day.")
4593
+ .option("--account <ref>", "Scope to one sender account (sender id, connection id, or Unipile account id).")
4594
+ .option("--json", "Print a JSON envelope.")
4595
+ .action(async (options) => {
4596
+ await handleAsyncAction("linkedin ingestion status", options, () => {
4597
+ const account = readOption(options.account);
4598
+ const params = new URLSearchParams();
4599
+ if (account)
4600
+ params.set("account", account);
4601
+ const suffix = params.toString();
4602
+ return requestOxygen(`/api/cli/linkedin/ingestion/status${suffix ? `?${suffix}` : ""}`);
4603
+ });
4604
+ }))));
4605
+ program.addCommand(new Command("whatsapp")
4606
+ .description("Manage the org's connected WhatsApp accounts for Sequencer: list, connect, sync, inspect, disconnect, tune daily limits, and warm-send into an existing conversation. Cold outbound runs through a WhatsApp sequence (sequences create --channels whatsapp).")
4607
+ .addCommand(new Command("accounts")
4608
+ .description("List connected WhatsApp accounts with health status, warm-up ramp state, limits, and today's usage.")
4609
+ .option("--status <status>", "Filter by account status: active, paused, disconnected, restricted, or credentials_required.")
4610
+ .option("--no-usage", "Skip today's per-account usage counts.")
4611
+ .option("--json", "Print a JSON envelope.")
4612
+ .action(async (options) => {
4613
+ await handleAsyncAction("whatsapp accounts", options, () => {
4614
+ const params = new URLSearchParams();
4615
+ if (options.usage !== false)
4616
+ params.set("include_usage", "true");
4617
+ const status = readOption(options.status);
4618
+ if (status)
4619
+ params.set("status", status);
4620
+ const suffix = params.toString();
4621
+ return requestOxygen(`/api/cli/whatsapp/accounts${suffix ? `?${suffix}` : ""}`);
4622
+ });
4623
+ }))
4624
+ .addCommand(new Command("connect")
4625
+ .description("Get a Unipile hosted-auth URL to connect a new WhatsApp account by scanning a QR in WhatsApp → Linked Devices (or reconnect with --reconnect). Newly connected accounts start a mandatory warm-up ramp.")
4626
+ .option("--reconnect <connection_id>", "Reconnect an existing connection id instead of linking a new account.")
4627
+ .option("--json", "Print a JSON envelope.")
4628
+ .action(async (options) => {
4629
+ await handleAsyncAction("whatsapp connect", options, () => {
4630
+ const reconnect = readOption(options.reconnect);
4631
+ return requestOxygen("/api/cli/whatsapp/connect", {
4632
+ method: "POST",
4633
+ body: { ...(reconnect ? { reconnect_connection_id: reconnect } : {}) },
4634
+ });
4635
+ });
4636
+ }))
4637
+ .addCommand(new Command("get")
4638
+ .description("Get one WhatsApp account with limits, warm-up ramp state (account age + today's effective send floor), daily-reset timezone, and usage. <id> accepts an account id, connection id, or Unipile account id.")
4639
+ .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
4640
+ .option("--json", "Print a JSON envelope.")
4641
+ .action(async (id, options) => {
4642
+ await handleAsyncAction("whatsapp get", options, () => requestOxygen(`/api/cli/whatsapp/accounts/${encodeURIComponent(id)}`));
4643
+ }))
4644
+ .addCommand(new Command("sync")
4645
+ .description("Refresh WhatsApp account state (status) from Unipile. Pass --connection-id to sync one account, or omit to sync all.")
4646
+ .option("--connection-id <id>", "Sync a specific connection id. Defaults to syncing all connected accounts.")
4647
+ .option("--json", "Print a JSON envelope.")
4648
+ .action(async (options) => {
4649
+ await handleAsyncAction("whatsapp sync", options, () => {
4650
+ const connectionId = readOption(options.connectionId);
4651
+ return requestOxygen("/api/cli/whatsapp/accounts/sync", {
4652
+ method: "POST",
4653
+ body: { ...(connectionId ? { connection_id: connectionId } : {}) },
4654
+ });
4655
+ });
4656
+ }))
4657
+ .addCommand(new Command("disconnect")
4658
+ .description("Disconnect a WhatsApp account so it stops sending. <id> accepts an account id, connection id, or Unipile account id.")
4659
+ .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
4660
+ .option("--json", "Print a JSON envelope.")
4661
+ .action(async (id, options) => {
4662
+ await handleAsyncAction("whatsapp disconnect", options, () => requestOxygen("/api/integrations/whatsapp/disconnect", { method: "POST", body: { id } }));
4663
+ }))
4664
+ .addCommand(new Command("limits")
4665
+ .description("View and adjust per-account WhatsApp daily limits and the daily-reset timezone. The warm-up ramp is a non-bypassable floor on the message cap regardless of these values.")
4666
+ .addCommand(new Command("get")
4667
+ .description("Show current WhatsApp limits, the warm-up ramp state, daily-reset timezone, defaults, and safe maximums. <id> accepts an account id, connection id, or Unipile account id.")
4668
+ .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
4669
+ .option("--json", "Print a JSON envelope.")
4670
+ .action(async (id, options) => {
4671
+ await handleAsyncAction("whatsapp limits get", options, () => requestOxygen(`/api/cli/whatsapp/accounts/${encodeURIComponent(id)}/limits`));
4672
+ }))
4673
+ .addCommand(new Command("set")
4674
+ .description("Adjust per-account WhatsApp daily limits and the daily-reset timezone. Values are clamped to safe maximums; the warm-up ramp still floors the message cap. <id> accepts an account id, connection id, or Unipile account id.")
4675
+ .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
4676
+ .option("--messages-per-day <n>", "Daily WhatsApp messages cap (subject to the warm-up ramp floor).")
4677
+ .option("--total-actions-per-day <n>", "Daily cap across all WhatsApp action types.")
4678
+ .option("--messages-reads-per-day <n>", "Daily cap on chat and message-history reads.")
4679
+ .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
4680
+ .option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
4681
+ .option("--timezone <tz>", "IANA timezone the daily action counters reset in, e.g. America/New_York.")
4682
+ .option("--json", "Print a JSON envelope.")
4683
+ .action(async (id, options) => {
4684
+ await handleAsyncAction("whatsapp limits set", options, () => {
4685
+ const body = buildLinkedinLimitsBody(options);
4686
+ return requestOxygen(`/api/cli/whatsapp/accounts/${encodeURIComponent(id)}/limits`, {
4687
+ method: "PATCH",
4688
+ body,
4689
+ });
4690
+ });
4691
+ })))
4692
+ .addCommand(new Command("warm-send")
4693
+ .description("Send ONE warm WhatsApp message into an EXISTING conversation (warm-only; a cold phone number with no prior conversation is rejected). Approval-gated: omit --approved for a preview, then re-run with --approved to send. For cold outbound at scale, use a WhatsApp sequence instead.")
4694
+ .requiredOption("--account <ref>", "Connected WhatsApp account id, connection id, Unipile account id, or display-name ref that owns the conversation.")
4695
+ .requiredOption("--text <text>", "Message text to send.")
4696
+ .option("--conversation <id>", "Existing WhatsApp conversation id or Unipile chat id to send into.")
4697
+ .option("--approved", "Actually send. Omit for a preview only.")
4698
+ .option("--json", "Print a JSON envelope.")
4699
+ .action(async (options) => {
4700
+ await handleAsyncAction("whatsapp warm-send", options, () => {
4701
+ const account = readOption(options.account);
4702
+ const text = readOption(options.text);
4703
+ if (!account)
4704
+ throw new Error("--account is required.");
4705
+ if (!text)
4706
+ throw new Error("--text is required.");
4707
+ const conversation = readOption(options.conversation);
4708
+ if (!conversation) {
4709
+ throw new Error("--conversation is required — warm-send targets an existing WhatsApp conversation, not a cold phone number.");
4710
+ }
4711
+ return requestOxygen("/api/cli/whatsapp/warm-send", {
4712
+ method: "POST",
4713
+ body: {
4714
+ account,
4715
+ text,
4716
+ conversation_id: conversation,
4717
+ ...(options.approved ? { approved: true } : {}),
4718
+ },
4719
+ });
4720
+ });
4721
+ })));
4473
4722
  program.addCommand(new Command("inbox")
4474
- .description("Unified inbox (unibox): LinkedIn conversations and (--channel email) the fleet's email conversations synced from Zapmail Zapbox. Scan, read threads, and reply.")
4723
+ .description("Unified inbox (unibox): email + LinkedIn + WhatsApp conversations in one stream (--channel all), or a single channel. Scan, read threads, and reply.")
4475
4724
  .addCommand(new Command("list")
4476
- .description("List conversations across all connected accounts, newest first. --channel email lists the Zapbox-synced email inbox; filter it by status, campaign, mailbox provider/domain, and Primary/Others bucket.")
4477
- .option("--channel <channel>", "Inbox channel: linkedin (default) or email.")
4725
+ .description("List conversations newest first. --channel all merges email + LinkedIn + WhatsApp into one stream (narrow it with --channels); --channel email/linkedin/whatsapp lists a single channel. Filter email by status, campaign, mailbox provider/domain, and Primary/Others segment.")
4726
+ .option("--channel <channel>", "Inbox channel: all (merged), linkedin (default), whatsapp, or email.")
4727
+ .option("--channels <list>", "channel=all only: comma-separated channel groups to include (email,linkedin,whatsapp). Empty = all three.")
4478
4728
  .option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
4479
4729
  .option("--unread", "Only show conversations with unread messages.")
4480
4730
  .option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
@@ -4506,6 +4756,7 @@ export function createProgram() {
4506
4756
  for (const [flag, key] of [
4507
4757
  ["bucket", "bucket"],
4508
4758
  ["segment", "segment"],
4759
+ ["channels", "channels"],
4509
4760
  ["status", "status"],
4510
4761
  ["sequenceId", "sequence_id"],
4511
4762
  ["provider", "provider"],
@@ -4532,7 +4783,7 @@ export function createProgram() {
4532
4783
  .addCommand(new Command("get")
4533
4784
  .description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id.")
4534
4785
  .argument("<conversation>", "Conversation id, Unipile chat id, or Zapbox thread id.")
4535
- .option("--channel <channel>", "Inbox channel: linkedin (default) or email.")
4786
+ .option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp, or email.")
4536
4787
  .option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
4537
4788
  .option("--json", "Print a JSON envelope.")
4538
4789
  .action(async (conversation, options) => {
@@ -4549,10 +4800,10 @@ export function createProgram() {
4549
4800
  });
4550
4801
  }))
4551
4802
  .addCommand(new Command("send")
4552
- .description("Reply into a conversation. --channel email replies from the conversation's own mailbox (Zapbox/native Gmail/Graph, threaded); default replies into the LinkedIn conversation. Sends a real message — requires --approved. Without it, returns a preview.")
4803
+ .description("Reply into a conversation. --channel email replies from the conversation's own mailbox (Zapbox/native Gmail/Graph, threaded); --channel whatsapp warm-replies into an existing WhatsApp conversation; default replies into the LinkedIn conversation. Sends a real message — requires --approved. Without it, returns a preview.")
4553
4804
  .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
4554
4805
  .requiredOption("--text <message>", "Reply text to send.")
4555
- .option("--channel <channel>", "Inbox channel: linkedin (default) or email.")
4806
+ .option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp (warm reply), or email.")
4556
4807
  .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
4557
4808
  .option("--draft-id <id>", "Email only: when approving an AI reply-agent draft, its id (marks it sent on success).")
4558
4809
  .option("--json", "Print a JSON envelope.")
@@ -4574,7 +4825,7 @@ export function createProgram() {
4574
4825
  .addCommand(new Command("mark-read")
4575
4826
  .description("Mark a conversation and all its messages as read.")
4576
4827
  .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
4577
- .option("--channel <channel>", "Inbox channel: linkedin (default) or email.")
4828
+ .option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp, or email.")
4578
4829
  .option("--json", "Print a JSON envelope.")
4579
4830
  .action(async (conversation, options) => {
4580
4831
  await handleAsyncAction("inbox mark-read", options, () => {
@@ -4586,14 +4837,18 @@ export function createProgram() {
4586
4837
  });
4587
4838
  }))
4588
4839
  .addCommand(new Command("analyze")
4589
- .description("Run the default analysis on an email conversation now: sentiment + interest category + a drafted reply (Oxygen's default model). The worker also does this automatically on inbound mail.")
4590
- .argument("<conversation>", "Conversation id or Zapbox thread id.")
4840
+ .description("Run analysis on a conversation now: status + sentiment + a drafted reply (Oxygen's default model). The worker also does this automatically on inbound conversations.")
4841
+ .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
4842
+ .option("--channel <channel>", "Inbox channel: email (default), linkedin, or whatsapp.")
4591
4843
  .option("--json", "Print a JSON envelope.")
4592
4844
  .action(async (conversation, options) => {
4593
- await handleAsyncAction("inbox analyze", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/analyze`, {
4594
- method: "POST",
4595
- body: { channel: "email" },
4596
- }));
4845
+ await handleAsyncAction("inbox analyze", options, () => {
4846
+ const channel = readOption(options.channel) ?? "email";
4847
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/analyze`, {
4848
+ method: "POST",
4849
+ body: { channel },
4850
+ });
4851
+ });
4597
4852
  }))
4598
4853
  .addCommand(new Command("archive")
4599
4854
  .description("Archive an email conversation (triage it out of the inbox). --unarchive restores it.")
@@ -4626,17 +4881,53 @@ export function createProgram() {
4626
4881
  body.message_limit = Number(messageLimit);
4627
4882
  return requestOxygen("/api/cli/inbox/sync", { method: "POST", body });
4628
4883
  });
4884
+ }))
4885
+ .addCommand(new Command("backfill")
4886
+ .description("Opt-in: pull a LinkedIn account's OLDER message history into the unibox. A SLOW, durable background drip walks each conversation's thread strictly backward — one page per worker tick under a dedicated conservative read budget — so a long history fills in over many days and never burns the account's limits. New messages keep arriving in real time via webhooks; this only backfills old history. No messages are sent and no Oxygen credits are charged.")
4887
+ .option("--account <ref>", "Sender account to backfill (sender id, connection id, or Unipile account id). Omit to backfill every active LinkedIn account.")
4888
+ .option("--conversation <ref>", "Scope to a single conversation (conversation id or Unipile chat id).")
4889
+ .option("--json", "Print a JSON envelope.")
4890
+ .action(async (options) => {
4891
+ await handleAsyncAction("inbox backfill", options, () => {
4892
+ const account = readOption(options.account);
4893
+ const conversation = readOption(options.conversation);
4894
+ return requestOxygen("/api/cli/linkedin/inbox/backfill", {
4895
+ method: "POST",
4896
+ body: {
4897
+ ...(account ? { account } : {}),
4898
+ ...(conversation ? { conversation } : {}),
4899
+ },
4900
+ });
4901
+ });
4629
4902
  }))
4630
4903
  .addCommand(new Command("status")
4631
- .description("Set an email conversation's status (the Instantly-style tier). A manual override that locks out AI re-classification.")
4632
- .argument("<conversation>", "Conversation id or Zapbox thread id.")
4904
+ .description("Set a conversation's status (the Instantly-style tier). A manual override that locks out AI re-classification. --channel linkedin/whatsapp sets a DM's status.")
4905
+ .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
4633
4906
  .argument("<status>", "A status label key (e.g. interested, meeting_booked, won, not_interested).")
4907
+ .option("--channel <channel>", "Inbox channel: email (default), linkedin, or whatsapp.")
4634
4908
  .option("--json", "Print a JSON envelope.")
4635
4909
  .action(async (conversation, status, options) => {
4636
- await handleAsyncAction("inbox status", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/status`, {
4637
- method: "POST",
4638
- body: { status },
4639
- }));
4910
+ await handleAsyncAction("inbox status", options, () => {
4911
+ const channel = readOption(options.channel) ?? "email";
4912
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/status`, {
4913
+ method: "POST",
4914
+ body: { status, channel },
4915
+ });
4916
+ });
4917
+ }))
4918
+ .addCommand(new Command("rescan")
4919
+ .description("Re-queue conversations for AI re-classification (status + sentiment + drafted reply). Without --yes, previews the counts that would be re-flipped. With --yes, the worker re-classifies the pending rows (free for opt-outs, low-tier model otherwise, credit-capped).")
4920
+ .option("--channel <channel>", "Inbox channel: all (default), email, linkedin, or whatsapp.")
4921
+ .option("--yes", "Execute the re-queue. Without this flag, returns a preview of the counts only.")
4922
+ .option("--json", "Print a JSON envelope.")
4923
+ .action(async (options) => {
4924
+ await handleAsyncAction("inbox rescan", options, () => {
4925
+ const channel = readOption(options.channel) ?? "all";
4926
+ return requestOxygen("/api/cli/inbox/rescan", {
4927
+ method: "POST",
4928
+ body: { channel, confirm: Boolean(options.yes) },
4929
+ });
4930
+ });
4640
4931
  }))
4641
4932
  .addCommand(new Command("labels")
4642
4933
  .description("Manage inbox status labels (the Instantly-style system set + custom labels).")
@@ -4834,7 +5125,9 @@ export function createProgram() {
4834
5125
  .requiredOption("--name <name>", "Human-readable sequence name.")
4835
5126
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
4836
5127
  .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (invite | message | wait_for_connection | inmail), email steps (email_send | email_reply | email_enroll | email_move | email_stop), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected sugar (then_id/else_id).")
4837
- .option("--channels <list>", "Comma-separated channels: linkedin,email. Defaults to the channels the journey touches.")
5128
+ .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp. Defaults to the channels the journey touches.")
5129
+ .option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live — WhatsApp via Unipile is unofficial WhatsApp Web, so cold-initiating is an explicit ban-risk opt-in.")
5130
+ .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
4838
5131
  .option("--senders <ids>", "Comma-separated LinkedIn sender account ids (or connection / Unipile ids). Required when the journey has LinkedIn steps.")
4839
5132
  .option("--table <id>", "Source table id whose rows supply {{column}} template values.")
4840
5133
  .option("--url-column <key>", "Column key holding each lead's LinkedIn URL/provider id.")
@@ -4881,7 +5174,9 @@ export function createProgram() {
4881
5174
  .argument("<sequence>", "Sequence id or slug.")
4882
5175
  .option("--name <name>", "New human-readable sequence name.")
4883
5176
  .option("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] } replacing the journey.")
4884
- .option("--channels <list>", "Comma-separated channels: linkedin,email.")
5177
+ .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp.")
5178
+ .option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live (draft-editable).")
5179
+ .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
4885
5180
  .option("--senders <ids>", "Comma-separated LinkedIn sender account ids (or connection / Unipile ids).")
4886
5181
  .option("--email-provider <provider>", "Email provider for the email track. Only 'instantly' is supported.")
4887
5182
  .option("--email-connection <id>", "Instantly connection id for the email track.")
@@ -9655,14 +9950,29 @@ options) {
9655
9950
  const timezone = readOption(options.timezone);
9656
9951
  if (timezone)
9657
9952
  workingHours.timezone = timezone;
9953
+ // Warm-up override (LinkedIn only). A start-date wins over the booleans; the
9954
+ // WhatsApp `set` command doesn't expose these flags, so this stays empty there.
9955
+ let warmup;
9956
+ const warmupStartDate = readOption(options.warmupStartDate);
9957
+ if (warmupStartDate) {
9958
+ warmup = { start_date: warmupStartDate };
9959
+ }
9960
+ else if (options.warmupDisable) {
9961
+ warmup = { enabled: false };
9962
+ }
9963
+ else if (options.warmupRestart) {
9964
+ warmup = { enabled: true };
9965
+ }
9658
9966
  const hasLimits = Object.keys(limits).length > 0;
9659
9967
  const hasWorkingHours = Object.keys(workingHours).length > 0;
9660
- if (!hasLimits && !hasWorkingHours) {
9661
- throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day) or --timezone.", { exitCode: 1 });
9968
+ const hasWarmup = warmup !== undefined;
9969
+ if (!hasLimits && !hasWorkingHours && !hasWarmup) {
9970
+ throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day), --timezone, or a warm-up flag (--warmup-restart / --warmup-disable / --warmup-start-date).", { exitCode: 1 });
9662
9971
  }
9663
9972
  return {
9664
9973
  ...(hasLimits ? { limits } : {}),
9665
9974
  ...(hasWorkingHours ? { working_hours: workingHours } : {}),
9975
+ ...(hasWarmup ? { warmup } : {}),
9666
9976
  };
9667
9977
  }
9668
9978
  function readPositiveInt(value) {
@@ -9733,6 +10043,13 @@ function readSequenceSettings(options) {
9733
10043
  if (windowPath) {
9734
10044
  settings.email_send_window = readJsonFileValue(resolve(windowPath), "--send-window-file");
9735
10045
  }
10046
+ if (options.whatsappColdInitiate === true) {
10047
+ settings.whatsapp_cold_initiate = true;
10048
+ }
10049
+ const phoneColumnKey = readOption(options.phoneColumnKey);
10050
+ if (phoneColumnKey) {
10051
+ settings.phone_column_key = phoneColumnKey;
10052
+ }
9736
10053
  return Object.keys(settings).length > 0 ? settings : undefined;
9737
10054
  }
9738
10055
  function muteTokenEcho() {
@@ -15,6 +15,7 @@ export * from "./provider-request-outcomes.js";
15
15
  export * from "./signup-lead-deliveries.js";
16
16
  export * from "./sql-error.js";
17
17
  export * from "./telemetry.js";
18
+ export * from "./tenant-database-secret.js";
18
19
  export * from "./worker-failures-queue.js";
19
20
  export declare const MAX_ROW_LOOP_WRITE_ROWS = 500;
20
21
  export type SemanticVersion = {
@@ -15,6 +15,7 @@ export * from "./provider-request-outcomes.js";
15
15
  export * from "./signup-lead-deliveries.js";
16
16
  export * from "./sql-error.js";
17
17
  export * from "./telemetry.js";
18
+ export * from "./tenant-database-secret.js";
18
19
  export * from "./worker-failures-queue.js";
19
20
  // Maximum rows a single row-loop write (insert/upsert/preview) may process. The
20
21
  // row-loop engine issues one DB round-trip per row, so a 500-row write already
@@ -26,7 +26,7 @@
26
26
  * and inbox rotation while Oxygen owns the cross-channel timeline + reply
27
27
  * suppression.
28
28
  */
29
- export declare const SEQUENCE_CHANNELS: readonly ["linkedin", "email"];
29
+ export declare const SEQUENCE_CHANNELS: readonly ["linkedin", "email", "whatsapp"];
30
30
  export type SequenceChannel = (typeof SEQUENCE_CHANNELS)[number];
31
31
  /**
32
32
  * Signals an enrollment accumulates from provider webhooks. wait_for_signal gates
@@ -48,6 +48,32 @@ export declare function isExternalSequenceSignal(value: string): value is Sequen
48
48
  * Single source of truth so the send path and the lookup path can't drift.
49
49
  */
50
50
  export declare const SEQUENCE_EMAIL_COLUMN_KEYS: readonly ["email", "email_address", "work_email", "primary_email", "Email"];
51
+ /**
52
+ * row_values keys an enrollment's phone number may live under, in
53
+ * send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST
54
+ * present key to a Unipile WhatsApp attendee id (E.164). Mirrors
55
+ * SEQUENCE_EMAIL_COLUMN_KEYS so the WhatsApp send + lookup paths can't drift.
56
+ */
57
+ export declare const SEQUENCE_PHONE_COLUMN_KEYS: readonly ["phone", "phone_number", "mobile", "mobile_phone", "Phone"];
58
+ /**
59
+ * Normalize a raw phone number to the attendee id Unipile's WhatsApp
60
+ * `chats_create` accepts (E.164 digits — no '+', spaces, or separators). Returns
61
+ * null for an implausibly short/long number.
62
+ *
63
+ * NOTE (R1 — the one unconfirmed Unipile contract): the exact attendee format
64
+ * Unipile wants to open a COLD WhatsApp chat is the single thing to confirm
65
+ * against a live WhatsApp account. If Unipile needs a different shape (a resolved
66
+ * attendee id, or a "<digits>@s.whatsapp.net" jid), THIS function is the only
67
+ * seam to change — every caller routes through it.
68
+ */
69
+ export declare function whatsAppAttendeeIdForPhone(phone: string): string | null;
70
+ /**
71
+ * Resolve a lead's WhatsApp attendee id from its row_values: read the configured
72
+ * phone column (or fall back to SEQUENCE_PHONE_COLUMN_KEYS in precedence order),
73
+ * then normalize via whatsAppAttendeeIdForPhone. Single source of truth so the
74
+ * enroll/plan path and any preview count can't drift.
75
+ */
76
+ export declare function whatsAppAttendeeIdFromRow(rowValues: Record<string, unknown> | null | undefined, phoneColumnKey?: string | null): string | null;
51
77
  /**
52
78
  * A per-step / per-sequence send window. `days` are ISO weekdays (1=Mon … 7=Sun)
53
79
  * on which sends are allowed; `start`/`end` are "HH:MM" local times. The window
@@ -73,7 +99,7 @@ export type SequenceSendWindow = {
73
99
  };
74
100
  /** Base content + up to this many alternates per A/B step (base counts as variant "a"). */
75
101
  export declare const MAX_STEP_VARIANTS = 5;
76
- export declare const SEQUENCE_STEP_KINDS: readonly ["visit_profile", "invite", "wait_for_connection", "message", "inmail", "email_send", "email_reply", "email_enroll", "email_move", "email_stop", "wait", "wait_for_signal", "branch", "stop"];
102
+ export declare const SEQUENCE_STEP_KINDS: readonly ["visit_profile", "invite", "wait_for_connection", "message", "inmail", "email_send", "email_reply", "email_enroll", "email_move", "email_stop", "whatsapp_message", "wait", "wait_for_signal", "branch", "stop"];
77
103
  export type SequenceStepKind = (typeof SEQUENCE_STEP_KINDS)[number];
78
104
  export type SequenceLinkedInVisitProfileStep = {
79
105
  id: string;
@@ -169,6 +195,23 @@ export type SequenceEmailReplyStep = {
169
195
  /** Per-step send window (overrides the sequence-level email_send_window). */
170
196
  send_window?: SequenceSendWindow;
171
197
  };
198
+ /**
199
+ * Native WhatsApp message send through one of the sequence's WhatsApp senders
200
+ * (Unipile chats_messages_send into an existing chat, else chats_create to open
201
+ * one). Oxygen owns the send; the WhatsApp warm-up ramp + per-account daily caps
202
+ * bound throughput. Cold first-touch (no existing chat with the lead) requires
203
+ * the sequence's whatsapp_cold_initiate opt-in, enforced at start + dispatch.
204
+ */
205
+ export type SequenceWhatsAppMessageStep = {
206
+ id: string;
207
+ channel: "whatsapp";
208
+ kind: "whatsapp_message";
209
+ template: string;
210
+ /** A/B alternates. Each overrides the base `template`; the base is variant "a". */
211
+ variants?: {
212
+ template?: string;
213
+ }[];
214
+ };
172
215
  export type SequenceWaitStep = {
173
216
  id: string;
174
217
  kind: "wait";
@@ -251,7 +294,7 @@ export type SequenceSignalCondition = {
251
294
  } | {
252
295
  not: SequenceSignalCondition;
253
296
  };
254
- export type SequenceStep = SequenceLinkedInVisitProfileStep | SequenceLinkedInInviteStep | SequenceLinkedInWaitForConnectionStep | SequenceLinkedInMessageStep | SequenceLinkedInInMailStep | SequenceEmailSendStep | SequenceEmailReplyStep | SequenceEmailEnrollStep | SequenceEmailMoveStep | SequenceEmailStopStep | SequenceWaitStep | SequenceWaitForSignalStep | SequenceBranchStep | SequenceStopStep;
297
+ export type SequenceStep = SequenceLinkedInVisitProfileStep | SequenceLinkedInInviteStep | SequenceLinkedInWaitForConnectionStep | SequenceLinkedInMessageStep | SequenceLinkedInInMailStep | SequenceEmailSendStep | SequenceEmailReplyStep | SequenceEmailEnrollStep | SequenceEmailMoveStep | SequenceEmailStopStep | SequenceWhatsAppMessageStep | SequenceWaitStep | SequenceWaitForSignalStep | SequenceBranchStep | SequenceStopStep;
255
298
  export type SequenceDefinition = {
256
299
  steps: SequenceStep[];
257
300
  };
@@ -28,7 +28,7 @@ import { renderLinkedInTemplate } from "./linkedin-sequences.js";
28
28
  * and inbox rotation while Oxygen owns the cross-channel timeline + reply
29
29
  * suppression.
30
30
  */
31
- export const SEQUENCE_CHANNELS = ["linkedin", "email"];
31
+ export const SEQUENCE_CHANNELS = ["linkedin", "email", "whatsapp"];
32
32
  /**
33
33
  * Signals an enrollment accumulates from provider webhooks. wait_for_signal gates
34
34
  * and signal-branch conditions read these. linkedin_connected also advances the
@@ -77,6 +77,50 @@ export function isExternalSequenceSignal(value) {
77
77
  * Single source of truth so the send path and the lookup path can't drift.
78
78
  */
79
79
  export const SEQUENCE_EMAIL_COLUMN_KEYS = ["email", "email_address", "work_email", "primary_email", "Email"];
80
+ /**
81
+ * row_values keys an enrollment's phone number may live under, in
82
+ * send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST
83
+ * present key to a Unipile WhatsApp attendee id (E.164). Mirrors
84
+ * SEQUENCE_EMAIL_COLUMN_KEYS so the WhatsApp send + lookup paths can't drift.
85
+ */
86
+ export const SEQUENCE_PHONE_COLUMN_KEYS = ["phone", "phone_number", "mobile", "mobile_phone", "Phone"];
87
+ /**
88
+ * Normalize a raw phone number to the attendee id Unipile's WhatsApp
89
+ * `chats_create` accepts (E.164 digits — no '+', spaces, or separators). Returns
90
+ * null for an implausibly short/long number.
91
+ *
92
+ * NOTE (R1 — the one unconfirmed Unipile contract): the exact attendee format
93
+ * Unipile wants to open a COLD WhatsApp chat is the single thing to confirm
94
+ * against a live WhatsApp account. If Unipile needs a different shape (a resolved
95
+ * attendee id, or a "<digits>@s.whatsapp.net" jid), THIS function is the only
96
+ * seam to change — every caller routes through it.
97
+ */
98
+ export function whatsAppAttendeeIdForPhone(phone) {
99
+ const digits = phone.trim().replace(/[^\d]/g, "");
100
+ if (digits.length < 7 || digits.length > 15)
101
+ return null;
102
+ return digits;
103
+ }
104
+ /**
105
+ * Resolve a lead's WhatsApp attendee id from its row_values: read the configured
106
+ * phone column (or fall back to SEQUENCE_PHONE_COLUMN_KEYS in precedence order),
107
+ * then normalize via whatsAppAttendeeIdForPhone. Single source of truth so the
108
+ * enroll/plan path and any preview count can't drift.
109
+ */
110
+ export function whatsAppAttendeeIdFromRow(rowValues, phoneColumnKey) {
111
+ if (!rowValues)
112
+ return null;
113
+ const keys = phoneColumnKey ? [phoneColumnKey, ...SEQUENCE_PHONE_COLUMN_KEYS] : [...SEQUENCE_PHONE_COLUMN_KEYS];
114
+ for (const key of keys) {
115
+ const value = rowValues[key];
116
+ if (typeof value === "string" && value.trim()) {
117
+ const attendee = whatsAppAttendeeIdForPhone(value);
118
+ if (attendee)
119
+ return attendee;
120
+ }
121
+ }
122
+ return null;
123
+ }
80
124
  /** Base content + up to this many alternates per A/B step (base counts as variant "a"). */
81
125
  export const MAX_STEP_VARIANTS = 5;
82
126
  export const SEQUENCE_STEP_KINDS = [
@@ -92,6 +136,8 @@ export const SEQUENCE_STEP_KINDS = [
92
136
  "email_enroll",
93
137
  "email_move",
94
138
  "email_stop",
139
+ // whatsapp channel (native dispatch via Unipile)
140
+ "whatsapp_message",
95
141
  // control (channel-agnostic)
96
142
  "wait",
97
143
  "wait_for_signal",
@@ -294,6 +340,14 @@ raw, index, options, issues) {
294
340
  }
295
341
  case "email_stop":
296
342
  return { id, channel: "email", kind: "email_stop" };
343
+ case "whatsapp_message": {
344
+ const template = requiredTemplate(raw.template, `${path}.template`, issues);
345
+ const variants = normalizeVariants(raw.variants, `${path}.variants`, ["template"], issues);
346
+ return {
347
+ id, channel: "whatsapp", kind: "whatsapp_message", template: template ?? "",
348
+ ...(variants ? { variants: variants } : {}),
349
+ };
350
+ }
297
351
  case "wait": {
298
352
  const days = optionalNonNegativeInt(raw.days, `${path}.days`, issues);
299
353
  const hours = optionalNonNegativeInt(raw.hours, `${path}.hours`, issues);
@@ -370,6 +424,8 @@ function channelForKind(kind) {
370
424
  if (kind === "email_send" || kind === "email_reply" ||
371
425
  kind === "email_enroll" || kind === "email_move" || kind === "email_stop")
372
426
  return "email";
427
+ if (kind === "whatsapp_message")
428
+ return "whatsapp";
373
429
  return "linkedin";
374
430
  }
375
431
  function normalizeId(value, index, path, issues) {
@@ -557,6 +613,7 @@ function baseVariantContent(step) {
557
613
  case "email_send": return { subject_template: step.subject_template, body_template: step.body_template };
558
614
  case "email_reply": return { body_template: step.body_template };
559
615
  case "message": return { template: step.template };
616
+ case "whatsapp_message": return { template: step.template };
560
617
  case "inmail": return { subject_template: step.subject_template, template: step.template };
561
618
  default: return null;
562
619
  }
@@ -0,0 +1 @@
1
+ export declare function resolveTenantDatabaseSecretKey(): string;
@@ -0,0 +1,12 @@
1
+ import { OxygenError } from "./cli-result.js";
2
+ export function resolveTenantDatabaseSecretKey() {
3
+ const key = process.env.TENANT_DATABASE_SECRET_KEY ??
4
+ process.env.INTEGRATION_SECRET_KEY;
5
+ if (!key) {
6
+ throw new OxygenError("tenant_database_secret_key_missing", "TENANT_DATABASE_SECRET_KEY is not set.", {
7
+ details: { env: "TENANT_DATABASE_SECRET_KEY" },
8
+ exitCode: 1,
9
+ });
10
+ }
11
+ return key;
12
+ }
@@ -1,2 +1,2 @@
1
- export declare const OXYGEN_VERSION = "1.209.6";
1
+ export declare const OXYGEN_VERSION = "1.218.5";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.209.6";
1
+ export const OXYGEN_VERSION = "1.218.5";
2
2
  // Bump this only when deployed CLI/API contracts require a newer CLI.
3
3
  // 1.181.0: paid table action runs and background columns run require
4
4
  // approved=true in addition to max_credits; older CLIs cannot send the flag.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.209.6",
3
+ "version": "1.218.5",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",