@rubytech/taskmaster 1.29.1 → 1.30.0

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.
@@ -6,8 +6,8 @@
6
6
  <title>Taskmaster Control</title>
7
7
  <meta name="color-scheme" content="dark light" />
8
8
  <link rel="icon" type="image/png" href="./favicon.png" />
9
- <script type="module" crossorigin src="./assets/index-B_QfEVs7.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-CAE6wsJy.css">
9
+ <script type="module" crossorigin src="./assets/index-CkGA_2Sq.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-CxVLKv6r.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -7,15 +7,19 @@ const DEFAULT_OPTIONS = {
7
7
  function isRecord(value) {
8
8
  return typeof value === "object" && value !== null && !Array.isArray(value);
9
9
  }
10
- function coerceSchedule(schedule) {
10
+ function coerceSchedule(schedule, opts) {
11
11
  const next = { ...schedule };
12
12
  const kind = typeof schedule.kind === "string" ? schedule.kind : undefined;
13
13
  const atMsRaw = schedule.atMs;
14
14
  const atRaw = schedule.at;
15
+ // When the agent passes a timezone-naive ISO string (e.g. "2026-03-11T09:00:00"),
16
+ // interpret it in the user's configured timezone — not UTC. A schedule-level
17
+ // `tz` field takes priority over the account default.
18
+ const tz = (typeof schedule.tz === "string" ? schedule.tz.trim() : undefined) || opts?.userTimezone;
15
19
  const parsedAtMs = typeof atMsRaw === "string"
16
- ? parseAbsoluteTimeMs(atMsRaw)
20
+ ? parseAbsoluteTimeMs(atMsRaw, tz)
17
21
  : typeof atRaw === "string"
18
- ? parseAbsoluteTimeMs(atRaw)
22
+ ? parseAbsoluteTimeMs(atRaw, tz)
19
23
  : null;
20
24
  if (!kind) {
21
25
  if (typeof schedule.atMs === "number" ||
@@ -92,7 +96,7 @@ export function normalizeCronJobInput(raw, options = DEFAULT_OPTIONS) {
92
96
  }
93
97
  }
94
98
  if (isRecord(base.schedule)) {
95
- next.schedule = coerceSchedule(base.schedule);
99
+ next.schedule = coerceSchedule(base.schedule, { userTimezone: options.userTimezone });
96
100
  }
97
101
  if (isRecord(base.payload)) {
98
102
  next.payload = coercePayload(base.payload);
@@ -1,16 +1,57 @@
1
1
  const ISO_TZ_RE = /(Z|[+-]\d{2}:?\d{2})$/i;
2
2
  const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
3
3
  const ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T/;
4
- function normalizeUtcIso(raw) {
4
+ /**
5
+ * Resolve the UTC offset string (e.g. "+01:00") for a given IANA timezone at
6
+ * a specific instant. Returns undefined if the timezone is invalid.
7
+ */
8
+ function offsetForTimezone(tz, date) {
9
+ try {
10
+ // Format in en-US with timeZoneName to extract the UTC offset.
11
+ const parts = new Intl.DateTimeFormat("en-US", {
12
+ timeZone: tz,
13
+ timeZoneName: "longOffset",
14
+ }).formatToParts(date);
15
+ const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "";
16
+ // Intl returns "GMT" for UTC, "GMT+1:00", "GMT-5:00", etc.
17
+ if (tzPart === "GMT")
18
+ return "+00:00";
19
+ const match = tzPart.match(/GMT([+-]\d{1,2}):?(\d{2})?/);
20
+ if (!match)
21
+ return undefined;
22
+ const hours = match[1].padStart(3, match[1][0] === "-" ? "-" : "+");
23
+ const minutes = match[2] ?? "00";
24
+ return `${hours}:${minutes}`;
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ function normalizeIso(raw, tz) {
5
31
  if (ISO_TZ_RE.test(raw))
6
32
  return raw;
7
- if (ISO_DATE_RE.test(raw))
8
- return `${raw}T00:00:00Z`;
9
- if (ISO_DATE_TIME_RE.test(raw))
10
- return `${raw}Z`;
33
+ if (ISO_DATE_RE.test(raw)) {
34
+ const suffix = tz ? resolveOffsetSuffix(`${raw}T00:00:00`, tz) : "Z";
35
+ return `${raw}T00:00:00${suffix}`;
36
+ }
37
+ if (ISO_DATE_TIME_RE.test(raw)) {
38
+ const suffix = tz ? resolveOffsetSuffix(raw, tz) : "Z";
39
+ return `${raw}${suffix}`;
40
+ }
11
41
  return raw;
12
42
  }
13
- export function parseAbsoluteTimeMs(input) {
43
+ /**
44
+ * Compute the UTC offset suffix for a timezone-naive ISO string interpreted in
45
+ * the given IANA timezone. We do a two-pass approach: first parse as UTC to
46
+ * get a rough Date, then look up the real offset for that instant.
47
+ */
48
+ function resolveOffsetSuffix(naiveIso, tz) {
49
+ const rough = new Date(`${naiveIso}Z`);
50
+ if (Number.isNaN(rough.getTime()))
51
+ return "Z";
52
+ return offsetForTimezone(tz, rough) ?? "Z";
53
+ }
54
+ export function parseAbsoluteTimeMs(input, tz) {
14
55
  const raw = input.trim();
15
56
  if (!raw)
16
57
  return null;
@@ -19,6 +60,6 @@ export function parseAbsoluteTimeMs(input) {
19
60
  if (Number.isFinite(n) && n > 0)
20
61
  return Math.floor(n);
21
62
  }
22
- const parsed = Date.parse(normalizeUtcIso(raw));
63
+ const parsed = Date.parse(normalizeIso(raw, tz));
23
64
  return Number.isFinite(parsed) ? parsed : null;
24
65
  }
@@ -23,7 +23,10 @@ export function computeJobNextRunAtMs(job, nowMs) {
23
23
  // One-shot jobs stay due until they successfully finish.
24
24
  if (job.state.lastStatus === "ok" && job.state.lastRunAtMs)
25
25
  return undefined;
26
- return job.schedule.atMs;
26
+ // Guard: if atMs is in the past, treat as expired — never fire immediately.
27
+ // A past atMs typically means the agent computed the timestamp incorrectly
28
+ // (e.g. timezone-naive ISO string interpreted as UTC).
29
+ return job.schedule.atMs > nowMs ? job.schedule.atMs : undefined;
27
30
  }
28
31
  // For "every" schedules without an explicit anchor, use the job's creation
29
32
  // time so the schedule stays grid-aligned across daemon restarts. Without
@@ -65,6 +65,22 @@ export async function add(state, input) {
65
65
  warnIfDisabled(state, "add");
66
66
  await ensureLoaded(state);
67
67
  const job = createJob(state, input);
68
+ // If a one-shot "at" job resolved to no nextRunAtMs, the atMs was in the
69
+ // past. Reject it outright so the agent gets a clear error instead of a
70
+ // silently disabled job that never fires.
71
+ if (job.schedule.kind === "at" &&
72
+ job.enabled &&
73
+ job.state.nextRunAtMs == null &&
74
+ !job.state.lastRunAtMs) {
75
+ const nowMs = state.deps.nowMs();
76
+ const delta = nowMs - job.schedule.atMs;
77
+ const nowIso = new Date(nowMs).toISOString();
78
+ const atIso = new Date(job.schedule.atMs).toISOString();
79
+ state.deps.log.warn({ atMs: job.schedule.atMs, atIso, nowIso, deltaMs: delta }, "cron.add rejected: atMs is in the past — reminder would fire immediately");
80
+ throw new Error(`schedule.atMs resolved to ${atIso} which is ${Math.round(delta / 1000)}s in the past ` +
81
+ `(current time: ${nowIso}). The reminder would fire immediately. ` +
82
+ "Call current_time to confirm the current date and year, then retry with a future timestamp.");
83
+ }
68
84
  state.store?.jobs.push(job);
69
85
  await persist(state);
70
86
  armTimer(state);
@@ -4,7 +4,7 @@ import { updateSessionStore } from "../../config/sessions.js";
4
4
  import { listRecords } from "../../records/records-manager.js";
5
5
  import { normalizeAccountId } from "../../routing/session-key.js";
6
6
  import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
7
- import { requireActiveWebListener } from "../../web/active-listener.js";
7
+ import { getActiveWebListener, requireActiveWebListener } from "../../web/active-listener.js";
8
8
  import { stripEnvelope } from "../chat-sanitize.js";
9
9
  import { ErrorCodes, errorShape, formatValidationErrors, validateWhatsAppConversationsParams, validateWhatsAppGroupInfoParams, validateWhatsAppMessagesParams, validateWhatsAppSendMessageParams, validateWhatsAppSetActivationParams, } from "../protocol/index.js";
10
10
  import { loadCombinedSessionStoreForGateway, parseGroupKey } from "../session-utils.js";
@@ -239,9 +239,9 @@ export const whatsappConversationsHandlers = {
239
239
  respond(true, { messages: [] });
240
240
  return;
241
241
  }
242
- // Read transcript and transform to WhatsApp message format
242
+ // --- Session transcript messages (agent-processed only) ---
243
243
  const rawMessages = readSessionMessages(matchedSessionId, storePath, matchedSessionFile);
244
- const messages = [];
244
+ const transcriptMessages = [];
245
245
  for (const raw of rawMessages) {
246
246
  const msg = raw;
247
247
  if (!msg.role || (msg.role !== "user" && msg.role !== "assistant"))
@@ -263,8 +263,8 @@ export const whatsappConversationsHandlers = {
263
263
  }
264
264
  if (!text.trim())
265
265
  continue;
266
- messages.push({
267
- id: `${matchedSessionId}-${messages.length}`,
266
+ transcriptMessages.push({
267
+ id: `transcript-${matchedSessionId}-${transcriptMessages.length}`,
268
268
  sender,
269
269
  senderName,
270
270
  body: text,
@@ -272,8 +272,42 @@ export const whatsappConversationsHandlers = {
272
272
  fromMe,
273
273
  });
274
274
  }
275
- // Return the most recent N messages
276
- const limited = messages.slice(-limit);
275
+ // --- Baileys in-memory store (all messages seen since gateway started) ---
276
+ const listener = getActiveWebListener(accountId);
277
+ const baileysMessages = listener?.getMessages
278
+ ? await listener.getMessages(jid, limit * 4)
279
+ : [];
280
+ // Merge: use Baileys as the primary source for inbound messages.
281
+ // Keep transcript assistant (fromMe) messages that Baileys doesn't have,
282
+ // and deduplicate inbound messages that appear in both sources.
283
+ let merged;
284
+ if (baileysMessages.length > 0) {
285
+ // Build a dedup set from Baileys entries: timestamp (±5s window) + body prefix
286
+ const baileysSignatures = new Set();
287
+ for (const m of baileysMessages) {
288
+ // Use 5-second timestamp buckets so minor clock drift doesn't cause duplicates
289
+ const bucket = Math.floor(m.timestamp / 5);
290
+ baileysSignatures.add(`${bucket}:${m.body.slice(0, 60)}`);
291
+ }
292
+ // From transcript, keep only assistant messages not already in Baileys
293
+ const transcriptOnly = transcriptMessages.filter((m) => {
294
+ if (!m.fromMe) {
295
+ const bucket = Math.floor(m.timestamp / 5);
296
+ return !baileysSignatures.has(`${bucket}:${m.body.slice(0, 60)}`);
297
+ }
298
+ // Always include assistant messages — Baileys store has no agent responses
299
+ return true;
300
+ });
301
+ merged = [...baileysMessages, ...transcriptOnly];
302
+ }
303
+ else {
304
+ // Baileys store is empty (gateway just started or account not connected) —
305
+ // fall back to session transcript only.
306
+ merged = transcriptMessages;
307
+ }
308
+ // Sort by timestamp, then return the most recent N
309
+ merged.sort((a, b) => a.timestamp - b.timestamp);
310
+ const limited = merged.slice(-limit);
277
311
  respond(true, { messages: limited });
278
312
  }
279
313
  catch (err) {
@@ -35,5 +35,5 @@ export function resolveGroupActivationFor(params) {
35
35
  channel: "whatsapp",
36
36
  groupId: groupId ?? params.conversationId,
37
37
  });
38
- return configActivation ?? "mention";
38
+ return configActivation ?? "off";
39
39
  }
@@ -88,6 +88,18 @@ export async function monitorWebInbox(options) {
88
88
  inboundConsoleLog.error(`Failed handling inbound web message: ${String(err)}`);
89
89
  },
90
90
  });
91
+ // In-memory message store: captures all inbound messages per JID (including
92
+ // history catch-up / append type) so the UI can show a complete conversation view.
93
+ const messageStore = new Map();
94
+ const MESSAGE_STORE_MAX = 500; // max entries per JID
95
+ const storeMessageEntry = (jid, entry) => {
96
+ const entries = messageStore.get(jid) ?? [];
97
+ entries.push(entry);
98
+ if (entries.length > MESSAGE_STORE_MAX) {
99
+ entries.splice(0, entries.length - MESSAGE_STORE_MAX);
100
+ }
101
+ messageStore.set(jid, entries);
102
+ };
91
103
  const groupMetaCache = new Map();
92
104
  const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes
93
105
  const lidLookup = sock.signalRepository?.lidMapping;
@@ -177,6 +189,25 @@ export async function monitorWebInbox(options) {
177
189
  const messageTimestampMs = msg.messageTimestamp
178
190
  ? Number(msg.messageTimestamp) * 1000
179
191
  : undefined;
192
+ // Store in the in-memory conversation store so the UI sees all messages,
193
+ // including history catch-up (append type) and messages from participants
194
+ // that don't trigger the agent. Runs before access control.
195
+ const storeBody = extractText(msg.message ?? undefined);
196
+ if (storeBody) {
197
+ const fromMe = Boolean(msg.key?.fromMe);
198
+ const replyCtx = describeReplyContext(msg.message);
199
+ storeMessageEntry(remoteJid, {
200
+ id: id ?? `${remoteJid}-${messageTimestampMs ?? 0}`,
201
+ sender: senderE164 ?? participantJid ?? (fromMe ? (selfE164 ?? "") : ""),
202
+ senderName: msg.pushName ?? undefined,
203
+ body: storeBody,
204
+ timestamp: messageTimestampMs ? Math.floor(messageTimestampMs / 1000) : 0,
205
+ fromMe,
206
+ quoted: replyCtx
207
+ ? { id: replyCtx.id ?? "", body: replyCtx.body, sender: replyCtx.sender }
208
+ : undefined,
209
+ });
210
+ }
180
211
  const access = await checkInboundAccessControl({
181
212
  accountId: options.accountId,
182
213
  from,
@@ -414,9 +445,9 @@ export async function monitorWebInbox(options) {
414
445
  }
415
446
  return entries;
416
447
  },
417
- getMessages: async (_jid, _limit) => {
418
- // No in-memory message store — Baileys makeInMemoryStore is not used.
419
- return [];
448
+ getMessages: async (jid, limit) => {
449
+ const entries = messageStore.get(jid) ?? [];
450
+ return limit ? entries.slice(-limit) : entries;
420
451
  },
421
452
  getGroupMetadata: async (jid) => {
422
453
  const meta = await sock.groupMetadata(jid);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.29.1",
3
+ "version": "1.30.0",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -105,14 +105,24 @@ The reminder's payload text should reference the event clearly so the recipient
105
105
 
106
106
  ### Computing the cron timestamp safely
107
107
 
108
- The `cron` tool takes `atMs` a Unix timestamp in **milliseconds**. Errors here cause the job to fire immediately (if the time is in the past) with no warning.
108
+ The `cron` tool accepts `atMs` as either a numeric Unix timestamp in milliseconds or an **ISO 8601 datetime string**. The ISO string approach is strongly preferred because it avoids epoch arithmetic errors.
109
109
 
110
- **Always call `current_time` before scheduling a cron job.** Use the returned `unix` value (seconds since epoch) as your anchor:
110
+ **Always call `current_time` before scheduling a cron job** to confirm the current date and year.
111
111
 
112
- 1. Get `unix` from `current_time` this is the current time in seconds.
113
- 2. Calculate how many seconds until the reminder should fire (days × 86400 + hours × 3600 + minutes × 60).
114
- 3. Add to `unix`, then multiply by 1000 to get `atMs`.
115
- 4. Before submitting: verify your computed `atMs` is strictly greater than `unix × 1000`. If it is not, the job will fire immediately recalculate.
112
+ **Preferred approachISO string:**
113
+
114
+ 1. Call `current_time` to confirm today's date and year.
115
+ 2. Pass `atMs` as an ISO 8601 string: `"2026-03-11T09:00:00"` (no timezone suffix needed the system uses the user's configured timezone automatically).
116
+ 3. Double-check that the year in your string matches the year from `current_time`.
117
+
118
+ The system rejects timestamps that resolve to the past — you will get a clear error with the current time if this happens.
119
+
120
+ **Fallback approach — numeric timestamp:**
121
+
122
+ 1. Get `unix` from `current_time`.
123
+ 2. Calculate offset in seconds (days × 86400 + hours × 3600 + minutes × 60).
124
+ 3. Compute: `atMs = (unix + offset) × 1000`.
125
+ 4. Verify `atMs > unix × 1000` before submitting.
116
126
 
117
127
  Never compute a Unix timestamp from a fixed epoch date (e.g. "January 1, 2026 = X"). Epoch arithmetic done from scratch is the most common source of year-off errors.
118
128
 
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: whatsapp-business
3
+ description: WhatsApp Business Cloud API setup — guides the user through creating a Meta Business Portfolio, registering a phone number, generating a permanent System User access token, configuring the webhook, and entering credentials into Taskmaster. Use when a user wants to connect WhatsApp via Meta's official Cloud API (verified badge, no QR dependency).
4
+ metadata: {"taskmaster":{"emoji":"📱"}}
5
+ ---
6
+
7
+ # WhatsApp Business Cloud API
8
+
9
+ Handles the full Meta Cloud API setup. This is the official WhatsApp Business Platform — messages arrive via Meta webhooks instead of a QR-linked device. It unlocks a verified green checkmark, message templates for proactive outreach, and eliminates the phone dependency entirely.
10
+
11
+ ## When to activate
12
+
13
+ - User asks about connecting WhatsApp via Meta's official API or Business Platform
14
+ - User mentions "Cloud API", "WABA", "Meta Business Manager", "verified badge", or "green checkmark"
15
+ - User wants WhatsApp without needing a physical phone linked
16
+ - User has a dedicated number for a business WhatsApp account
17
+ - User asks why they need a separate number for Cloud API
18
+
19
+ ## What it unlocks
20
+
21
+ - **Verified business profile** — Meta's green checkmark badge builds customer trust
22
+ - **No phone dependency** — no linked device, no QR scan every 14 days
23
+ - **Message templates** — send proactive outreach messages to opted-in customers
24
+ - **Higher reliability** — webhooks from Meta's infrastructure, not a linked device session
25
+
26
+ ## Important: Number exclusivity
27
+
28
+ A phone number can only be on **one** WhatsApp connection at a time. A number registered with Meta Cloud API cannot also be used with Baileys (QR). The two providers can coexist on one Taskmaster instance but must use different phone numbers.
29
+
30
+ ## Key Storage
31
+
32
+ Cloud API credentials are stored directly in the Taskmaster config, not in API key slots. Use the Setup page (WhatsApp → gear icon → Add Cloud API Account) to enter them. The four required values are:
33
+
34
+ | Field | What it is | Where to find it |
35
+ |-------|-----------|-----------------|
36
+ | Phone Number ID | Numeric ID for the registered number | WhatsApp Manager → Phone Numbers tab |
37
+ | Business Account ID | Your WABA (WhatsApp Business Account) ID | WhatsApp Manager → Business Account section |
38
+ | Access Token | Permanent System User token | Meta Business Settings → System Users |
39
+ | Webhook Verify Token | A string you define (shared secret) | You generate this; enter same value in Taskmaster and Meta |
40
+
41
+ ## References
42
+
43
+ | Task | When to use | Reference |
44
+ |------|-------------|-----------|
45
+ | Full Meta Cloud API setup | Credentials not yet entered or user starting from scratch | `references/setup-guide.md` |
46
+
47
+ Load the reference and follow it step by step.
@@ -0,0 +1,258 @@
1
+ # WhatsApp Business Cloud API — Setup Guide
2
+
3
+ Walk the user through the full Meta Cloud API setup from scratch: Business Portfolio → WABA → number registration → System User token → webhook → Taskmaster config.
4
+
5
+ ## Before You Start
6
+
7
+ **Remote Access must be enabled first.** Cloud API requires a publicly reachable webhook URL so Meta can deliver messages. Check that Remote Access (Tailscale Funnel) is active in Setup. If it isn't, the "Cloud API" option in Setup will be greyed out. Guide the user to enable it before proceeding — the `taskmaster` skill has a Remote Access setup guide.
8
+
9
+ **Only one QR-linked (Baileys) account is supported per workspace.** If the workspace already has a Baileys account, the Baileys option will be greyed out in Setup. Cloud API accounts have no Taskmaster-enforced limit (Meta allows up to 2 phone numbers per WABA on the free tier).
10
+
11
+ **Sessions are shared across numbers.** Taskmaster sessions are keyed by customer phone number, not by which WhatsApp number they messaged. A customer who texts both the Baileys number and a Cloud API number lands in the same conversation thread. This is usually desirable (one agent, continuous history), but means the agent cannot distinguish which number the customer used.
12
+
13
+ 1. Check memory (e.g. `memory/admin/infrastructure.md`) for the public URL — do NOT ask the user for it. Construct the webhook URL as `https://{public-host}/webhook/whatsapp`. The Setup page shows this URL automatically once Remote Access is active.
14
+ 2. The user needs a phone number that has never been registered with WhatsApp — or one that has been fully deleted from any previous WhatsApp account. A fresh SIM or a VoIP number works. They said they already have one — confirm this.
15
+ 3. Have the user ready at a desktop browser.
16
+
17
+ ## What to tell the user upfront
18
+
19
+ > "To connect via Meta's official WhatsApp Business API, we need to set up four things in Meta:
20
+ >
21
+ > 1. A Meta Business Portfolio (if you don't have one)
22
+ > 2. A WhatsApp Business Account with your new number registered
23
+ > 3. A permanent access token from a System User
24
+ > 4. A webhook so Meta can send messages to Taskmaster
25
+ >
26
+ > This takes about 15–20 minutes. I'll guide you through each step."
27
+
28
+ ---
29
+
30
+ ## Step 1: Meta Business Portfolio
31
+
32
+ **URL: business.facebook.com**
33
+
34
+ > "Go to **business.facebook.com** and sign in with your Facebook/Meta account.
35
+ >
36
+ > If you see a Business Portfolio dashboard, you're good. If you're prompted to create one, click **Create account**, enter your business name, and follow the prompts."
37
+
38
+ Wait for confirmation.
39
+
40
+ ---
41
+
42
+ ## Step 2: Create a WhatsApp Business Account (WABA) and register the phone number
43
+
44
+ This is a single 3-step wizard in Meta Business Suite that covers both WABA creation and phone number registration.
45
+
46
+ **Before starting the wizard**, make sure the Business Portfolio has complete contact information — Meta will block the wizard if it doesn't:
47
+
48
+ > "First, click the gear icon (⚙) at the bottom-left to open Settings → **Business info**.
49
+ >
50
+ > Check that the following are filled in: business address (street, city, postcode, country), phone number, and email. If any are missing, add them and save before continuing."
51
+
52
+ Then start the wizard:
53
+
54
+ > "Still in Settings, click the gear icon (⚙) at the bottom-left of business.facebook.com to open Settings.
55
+ >
56
+ > In the left sidebar, click **Accounts** → **WhatsApp accounts**.
57
+ >
58
+ > Click the **+ Add** button (top-right), then choose **Create a new WhatsApp business account** (not 'Link')."
59
+
60
+ **Step 2a — Details:**
61
+ > "Fill in:
62
+ > - **WhatsApp business display name** — your business name (e.g. Beagle Taxi)
63
+ > - **Category** — choose the most appropriate (e.g. Travel and transportation)
64
+ > - Optionally expand **Show more options** to set timezone, description, and website
65
+ >
66
+ > Click **Continue**."
67
+
68
+ **Step 2b — Phone number:**
69
+ > "Enter your dedicated phone number. Click **Continue**."
70
+
71
+ **Step 2c — Phone verification:**
72
+ > "Meta will call or SMS the number with a verification code. Enter the code to confirm ownership."
73
+
74
+ Wait for confirmation the WABA and phone number are verified and the wizard has completed.
75
+
76
+ ---
77
+
78
+ ## Step 3: Get the Business Account ID and Phone Number ID
79
+
80
+ Now open WhatsApp Manager (the WABA now exists, so access is granted):
81
+
82
+ > "In your browser, go to: **https://business.facebook.com/latest/whatsapp_manager**"
83
+
84
+ **Phone Number ID:**
85
+ > "In the left sidebar, click **Phone numbers**. Click on your number to open its details.
86
+ >
87
+ > Look for **Phone number ID** — a long string of digits. Copy it and send it to me."
88
+
89
+ Store as `phoneNumberId` in config.
90
+
91
+ **Business Account ID (WABA ID):**
92
+ > "At the top of WhatsApp Manager, near your account name, you'll see the **WhatsApp Business Account ID** (a long number). Copy it and send it to me."
93
+
94
+ Store as `businessAccountId` in config.
95
+
96
+ ---
97
+
98
+ ## Step 5: Create a Meta App (if needed)
99
+
100
+ To use the Cloud API, you need a Meta Developer App linked to your WABA.
101
+
102
+ **URL: developers.facebook.com/apps**
103
+
104
+ > "Go to **developers.facebook.com/apps** and sign in.
105
+ >
106
+ > Click **Create App**. Choose **Business** as the app type. Enter a name (e.g. 'Taskmaster') and click **Create app**."
107
+
108
+ Once the app is created:
109
+
110
+ > "Inside the app dashboard, click **Add Product** → find **WhatsApp** → click **Set up**.
111
+ >
112
+ > You'll be asked to connect a WhatsApp Business Account. Select the WABA you just created."
113
+
114
+ Wait for confirmation.
115
+
116
+ ---
117
+
118
+ ## Step 6: Create a System User with a permanent access token
119
+
120
+ User access tokens expire. A System User token is permanent.
121
+
122
+ **Navigation: business.facebook.com → Business Settings → System Users**
123
+
124
+ > "Go back to **business.facebook.com**. In the left sidebar, click **Business Settings** → **Users** → **System Users**.
125
+ >
126
+ > Click **Add** and create a new system user:
127
+ > - **Name:** something like 'Taskmaster Bot'
128
+ > - **Role:** Admin (needed to send messages)
129
+ >
130
+ > Click **Add system user**."
131
+
132
+ Now grant the system user access to the WhatsApp asset:
133
+
134
+ > "With the system user selected, click **Add Assets**.
135
+ >
136
+ > Choose **Apps**, find your Taskmaster app, tick **Manage app**, and click **Save changes**.
137
+ >
138
+ > Then choose **WhatsApp Accounts**, select your WABA, tick **Full control**, and click **Save changes**."
139
+
140
+ Now generate the token:
141
+
142
+ > "Click **Generate token** at the top of the system user page.
143
+ >
144
+ > Select your Taskmaster app from the dropdown.
145
+ >
146
+ > Tick these permissions: `whatsapp_business_messaging` and `whatsapp_business_management`.
147
+ >
148
+ > Set expiry to **Never**.
149
+ >
150
+ > Click **Generate token** and copy the token — it starts with `EAA...`. Send it to me."
151
+
152
+ Validate: must start with `EAA`, 100+ characters.
153
+
154
+ Store: enter as Access Token in Taskmaster UI (next step).
155
+
156
+ ---
157
+
158
+ ## Step 7: Generate a Webhook Verify Token
159
+
160
+ The verify token is a shared secret you define — Meta sends it to Taskmaster when verifying the webhook.
161
+
162
+ > "We need a short random string for the webhook verify token. I'll suggest one — or you can make up your own (letters and numbers, no spaces):
163
+ >
164
+ > `tm-{random 8 chars}` — for example: `tm-xk29mq7r`
165
+ >
166
+ > This goes into both Taskmaster and Meta. Note it down."
167
+
168
+ ---
169
+
170
+ ## Step 8: Enter credentials in Taskmaster
171
+
172
+ > "Now open the Taskmaster control panel at `{control_panel_url}`.
173
+ >
174
+ > Go to **Setup** → find the WhatsApp row → click the gear icon → scroll to **Add Cloud API Account**.
175
+ >
176
+ > Enter:
177
+ > - **Account name:** something like 'Business' or 'Meta'
178
+ > - **Phone Number ID:** (from Step 4)
179
+ > - **Business Account ID:** (from Step 4)
180
+ > - **Access Token:** (from Step 6)
181
+ > - **Webhook Verify Token:** (from Step 7)
182
+ >
183
+ > Click **Add Account**. Note the **Webhook URL** shown — you'll need it in the next step."
184
+
185
+ Wait for confirmation. The webhook URL will be shown in the UI after saving.
186
+
187
+ ---
188
+
189
+ ## Step 9: Configure the webhook in Meta
190
+
191
+ **Navigation: developers.facebook.com → Your App → WhatsApp → Configuration → Webhooks**
192
+
193
+ > "Back in the Meta Developer App dashboard:
194
+ >
195
+ > Click **WhatsApp** in the left sidebar → **Configuration**.
196
+ >
197
+ > Under **Webhooks**, click **Edit** (or **Configure webhooks**).
198
+ >
199
+ > Enter:
200
+ > - **Callback URL:** `{webhook_url}` (from the Taskmaster UI in the previous step)
201
+ > - **Verify token:** `{verify_token}` (the string from Step 7)
202
+ >
203
+ > Click **Verify and save**."
204
+
205
+ Meta will send a GET request to Taskmaster to verify. If it succeeds, the webhook status changes to Verified.
206
+
207
+ > "After verification, subscribe to webhook fields. Tick **messages** under the WhatsApp Business Account section. Click **Save**."
208
+
209
+ Wait for confirmation.
210
+
211
+ ---
212
+
213
+ ## Step 10: Confirm
214
+
215
+ > "WhatsApp Business Cloud API is now connected:
216
+ >
217
+ > - **Number:** {phone number} — registered with Meta
218
+ > - **Provider:** Meta Cloud API (webhook-based, no phone dependency)
219
+ > - **Webhook:** verified and subscribed
220
+ >
221
+ > Send a test message to the number from a customer's phone. Taskmaster should respond within a few seconds."
222
+
223
+ Store setup details in memory (WABA ID, phone number ID, app name, webhook URL, system user name) for future reference.
224
+
225
+ ---
226
+
227
+ ## Webhook URL
228
+
229
+ The webhook URL is always: `https://{public-host}/webhook/whatsapp`
230
+
231
+ Where `{public-host}` is the device's public URL (Tailscale Funnel or other tunnel). This must be accessible from the public internet — Meta's servers must be able to reach it.
232
+
233
+ ---
234
+
235
+ ## Troubleshooting
236
+
237
+ | Problem | Solution |
238
+ |---------|----------|
239
+ | "Setup can't be completed — business has not met WhatsApp's policy requirements" | The Business Portfolio is missing contact information. Go to Settings → **Business info** and fill in: business address (street, city, postcode, country), phone number, and email. Save, then retry the WABA creation wizard. |
240
+ | Webhook verification fails | Check the webhook URL is publicly accessible (not just on Tailscale). Tailscale Funnel must be enabled. Check the verify token matches exactly. |
241
+ | Phone number already in use | The number must be removed from any existing WhatsApp account first. Go to WhatsApp app → Settings → Linked Devices and remove if it's there. Or contact Meta support if it's stuck. |
242
+ | Token expires | System User tokens with "Never" expiry don't expire. If it does, regenerate in Business Settings → System Users → Generate token. |
243
+ | Messages not arriving | Check webhook subscription: App Dashboard → WhatsApp → Configuration → confirm "messages" field is subscribed. |
244
+ | "Permission denied" errors | System user needs Admin role AND asset access to both the App and the WABA. Recheck Step 6. |
245
+ | 401 from Meta API | Access token invalid or revoked. Regenerate in Business Settings → System Users. |
246
+ | Number not eligible | Some VoIP numbers are rejected by Meta. Use a real mobile number or try a different VoIP provider. |
247
+
248
+ ---
249
+
250
+ ## Test vs Live
251
+
252
+ | | Test number | Registered number |
253
+ |---|---|---|
254
+ | Provided by | Meta (in Developer app sandbox) | You register your own number |
255
+ | Recipients | Only pre-approved test numbers | Any WhatsApp user |
256
+ | For | Development and testing | Production |
257
+
258
+ The Developer App sandbox provides a free test number and pre-approved recipients. Use it to verify the integration works before registering your real business number.