@desplega.ai/agent-swarm 1.83.2 → 1.84.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.
@@ -0,0 +1,94 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { sendKapsoText } from "../integrations/kapso/client";
3
+
4
+ const originalFetch = globalThis.fetch;
5
+
6
+ afterEach(() => {
7
+ globalThis.fetch = originalFetch;
8
+ });
9
+
10
+ function mockFetch(status: number, body: unknown) {
11
+ globalThis.fetch = (async () =>
12
+ new Response(JSON.stringify(body), {
13
+ status,
14
+ headers: { "Content-Type": "application/json" },
15
+ })) as typeof fetch;
16
+ }
17
+
18
+ describe("sendKapsoText", () => {
19
+ test("success → returns outbound wamid", async () => {
20
+ let captured: { url: string; body: unknown } | null = null;
21
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
22
+ captured = { url, body: JSON.parse(init.body as string) };
23
+ return new Response(JSON.stringify({ messages: [{ id: "wamid.OUT123" }] }), { status: 200 });
24
+ }) as typeof fetch;
25
+
26
+ const result = await sendKapsoText({
27
+ apiBaseUrl: "https://api.kapso.ai",
28
+ apiKey: "k",
29
+ phoneNumberId: "1035039933036854",
30
+ to: "34679077777",
31
+ body: "hola",
32
+ });
33
+
34
+ expect(result.ok).toBe(true);
35
+ expect(result.messageId).toBe("wamid.OUT123");
36
+ expect(captured!.url).toBe(
37
+ "https://api.kapso.ai/meta/whatsapp/v24.0/1035039933036854/messages",
38
+ );
39
+ expect(captured!.body).toMatchObject({
40
+ messaging_product: "whatsapp",
41
+ to: "34679077777",
42
+ type: "text",
43
+ text: { body: "hola", preview_url: false },
44
+ });
45
+ });
46
+
47
+ test("quote-reply sets context.message_id", async () => {
48
+ let body: Record<string, unknown> | null = null;
49
+ globalThis.fetch = (async (_url: string, init: RequestInit) => {
50
+ body = JSON.parse(init.body as string);
51
+ return new Response(JSON.stringify({ messages: [{ id: "wamid.R" }] }), { status: 200 });
52
+ }) as typeof fetch;
53
+
54
+ await sendKapsoText({
55
+ apiBaseUrl: "https://api.kapso.ai",
56
+ apiKey: "k",
57
+ phoneNumberId: "p",
58
+ to: "34679077777",
59
+ body: "re",
60
+ contextMessageId: "wamid.IN999",
61
+ });
62
+
63
+ expect(body!.context).toEqual({ message_id: "wamid.IN999" });
64
+ });
65
+
66
+ test("24h-window error (code 131047) → sessionWindowExpired", async () => {
67
+ mockFetch(400, {
68
+ error: { code: 131047, message: "Message failed: more than 24 hours since last reply" },
69
+ });
70
+ const result = await sendKapsoText({
71
+ apiBaseUrl: "https://api.kapso.ai",
72
+ apiKey: "k",
73
+ phoneNumberId: "p",
74
+ to: "34679077777",
75
+ body: "late",
76
+ });
77
+ expect(result.ok).toBe(false);
78
+ expect(result.sessionWindowExpired).toBe(true);
79
+ });
80
+
81
+ test("generic error → not flagged as session-window", async () => {
82
+ mockFetch(401, { error: { code: 0, message: "Invalid API key" } });
83
+ const result = await sendKapsoText({
84
+ apiBaseUrl: "https://api.kapso.ai",
85
+ apiKey: "bad",
86
+ phoneNumberId: "p",
87
+ to: "34679077777",
88
+ body: "x",
89
+ });
90
+ expect(result.ok).toBe(false);
91
+ expect(result.sessionWindowExpired).toBe(false);
92
+ expect(result.errorMessage).toContain("Invalid API key");
93
+ });
94
+ });
@@ -0,0 +1,198 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import crypto from "node:crypto";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { closeDb, createAgent, getTaskById, initDb } from "../be/db";
5
+ import { handleWebhooks } from "../http/webhooks";
6
+ import { putKapsoNumberMapping } from "../integrations/kapso/config";
7
+ import { routeKapsoInbound } from "../integrations/kapso/inbound";
8
+
9
+ const TEST_DB_PATH = "./test-kapso-inbound.sqlite";
10
+ const HMAC_SECRET = "kapso-test-hmac-secret";
11
+
12
+ let agentId: string;
13
+
14
+ function makePayload(opts: {
15
+ phoneNumberId: string;
16
+ messageId?: string;
17
+ direction?: string;
18
+ type?: string;
19
+ text?: string;
20
+ from?: string;
21
+ conversationId?: string;
22
+ }) {
23
+ return {
24
+ message: {
25
+ id: opts.messageId ?? `wamid.${Math.random().toString(36).slice(2)}`,
26
+ from: opts.from ?? "34679077777",
27
+ type: opts.type ?? "text",
28
+ text: { body: opts.text ?? "hola" },
29
+ kapso: { direction: opts.direction ?? "inbound", content: opts.text ?? "hola" },
30
+ },
31
+ conversation: {
32
+ id: opts.conversationId ?? "conv-1",
33
+ phone_number: opts.from ?? "34679077777",
34
+ contact_name: "Taras",
35
+ },
36
+ phone_number_id: opts.phoneNumberId,
37
+ };
38
+ }
39
+
40
+ function sign(secret: string, body: string): string {
41
+ return crypto.createHmac("sha256", secret).update(body).digest("hex");
42
+ }
43
+
44
+ /** Minimal fake req/res to drive handleWebhooks without a live server. */
45
+ function fakeReqRes(rawBody: string, headers: Record<string, string>) {
46
+ const req = {
47
+ method: "POST",
48
+ headers,
49
+ async *[Symbol.asyncIterator]() {
50
+ yield Buffer.from(rawBody);
51
+ },
52
+ } as unknown as IncomingMessage;
53
+
54
+ const captured = { status: 0, body: "" };
55
+ const res = {
56
+ writeHead(status: number) {
57
+ captured.status = status;
58
+ return this;
59
+ },
60
+ end(chunk?: string) {
61
+ if (chunk) captured.body = chunk;
62
+ return this;
63
+ },
64
+ } as unknown as ServerResponse;
65
+
66
+ return { req, res, captured };
67
+ }
68
+
69
+ const KAPSO_PATH = ["api", "integrations", "kapso", "webhook"];
70
+
71
+ beforeAll(() => {
72
+ for (const suffix of ["", "-wal", "-shm"]) {
73
+ try {
74
+ require("node:fs").unlinkSync(`${TEST_DB_PATH}${suffix}`);
75
+ } catch {}
76
+ }
77
+ initDb(TEST_DB_PATH);
78
+ process.env.KAPSO_WEBHOOK_HMAC_SECRET = HMAC_SECRET;
79
+ const agent = createAgent({ name: "KapsoWorker", isLead: false, status: "idle" });
80
+ agentId = agent.id;
81
+ });
82
+
83
+ afterAll(() => {
84
+ closeDb();
85
+ delete process.env.KAPSO_WEBHOOK_HMAC_SECRET;
86
+ for (const suffix of ["", "-wal", "-shm"]) {
87
+ try {
88
+ require("node:fs").unlinkSync(`${TEST_DB_PATH}${suffix}`);
89
+ } catch {}
90
+ }
91
+ });
92
+
93
+ describe("routeKapsoInbound", () => {
94
+ test("mapping hit → dispatches a kapso-inbound task to the mapped agent", () => {
95
+ putKapsoNumberMapping({
96
+ phoneNumberId: "pn-task",
97
+ agentId,
98
+ createdAt: new Date().toISOString(),
99
+ });
100
+ const routing = routeKapsoInbound(makePayload({ phoneNumberId: "pn-task" }));
101
+ expect(routing.kind).toBe("task");
102
+ if (routing.kind !== "task") throw new Error("expected task");
103
+ const task = getTaskById(routing.taskId);
104
+ expect(task).not.toBeNull();
105
+ expect(task!.taskType).toBe("kapso-inbound");
106
+ expect(task!.agentId).toBe(agentId);
107
+ expect(task!.task).toContain("## Source: WhatsApp (Kapso)");
108
+ });
109
+
110
+ test("no mapping → no_mapping (does not break, no task)", () => {
111
+ const routing = routeKapsoInbound(makePayload({ phoneNumberId: "pn-unregistered" }));
112
+ expect(routing.kind).toBe("no_mapping");
113
+ });
114
+
115
+ test("workflow mapping → signals workflow dispatch", () => {
116
+ putKapsoNumberMapping({
117
+ phoneNumberId: "pn-wf",
118
+ workflowId: "11111111-1111-4111-8111-111111111111",
119
+ createdAt: new Date().toISOString(),
120
+ });
121
+ const routing = routeKapsoInbound(makePayload({ phoneNumberId: "pn-wf" }));
122
+ expect(routing.kind).toBe("workflow");
123
+ if (routing.kind !== "workflow") throw new Error("expected workflow");
124
+ expect(routing.workflowId).toBe("11111111-1111-4111-8111-111111111111");
125
+ });
126
+
127
+ test("non-inbound (outbound/status) → skip", () => {
128
+ const routing = routeKapsoInbound(
129
+ makePayload({ phoneNumberId: "pn-task", direction: "outbound" }),
130
+ );
131
+ expect(routing.kind).toBe("skip");
132
+ });
133
+
134
+ test("duplicate delivery of the same message id → second is deduped", () => {
135
+ putKapsoNumberMapping({
136
+ phoneNumberId: "pn-dup",
137
+ agentId,
138
+ createdAt: new Date().toISOString(),
139
+ });
140
+ const messageId = "wamid.DUPLICATE_TEST";
141
+ const first = routeKapsoInbound(makePayload({ phoneNumberId: "pn-dup", messageId }));
142
+ expect(first.kind).toBe("task");
143
+ const second = routeKapsoInbound(makePayload({ phoneNumberId: "pn-dup", messageId }));
144
+ expect(second.kind).toBe("duplicate");
145
+ });
146
+ });
147
+
148
+ describe("handleWebhooks — Kapso HMAC gate", () => {
149
+ test("valid HMAC + mapping hit → 200 and task routing", async () => {
150
+ putKapsoNumberMapping({
151
+ phoneNumberId: "pn-http",
152
+ agentId,
153
+ createdAt: new Date().toISOString(),
154
+ });
155
+ const rawBody = JSON.stringify(
156
+ makePayload({ phoneNumberId: "pn-http", messageId: "wamid.HTTP_OK" }),
157
+ );
158
+ const { req, res, captured } = fakeReqRes(rawBody, {
159
+ "x-webhook-signature": sign(HMAC_SECRET, rawBody),
160
+ });
161
+ const handled = await handleWebhooks(req, res, KAPSO_PATH);
162
+ expect(handled).toBe(true);
163
+ expect(captured.status).toBe(200);
164
+ expect(JSON.parse(captured.body)).toMatchObject({ received: true, routing: "task" });
165
+ });
166
+
167
+ test("valid HMAC + no mapping → 200 no_mapping (fallback, does not break)", async () => {
168
+ const rawBody = JSON.stringify(
169
+ makePayload({ phoneNumberId: "pn-http-unmapped", messageId: "wamid.HTTP_NOMAP" }),
170
+ );
171
+ const { req, res, captured } = fakeReqRes(rawBody, {
172
+ "x-webhook-signature": sign(HMAC_SECRET, rawBody),
173
+ });
174
+ await handleWebhooks(req, res, KAPSO_PATH);
175
+ expect(captured.status).toBe(200);
176
+ expect(JSON.parse(captured.body)).toMatchObject({ routing: "no_mapping" });
177
+ });
178
+
179
+ test("invalid HMAC → 401", async () => {
180
+ const rawBody = JSON.stringify(
181
+ makePayload({ phoneNumberId: "pn-http", messageId: "wamid.HTTP_BAD" }),
182
+ );
183
+ const { req, res, captured } = fakeReqRes(rawBody, {
184
+ "x-webhook-signature": sign("wrong-secret", rawBody),
185
+ });
186
+ await handleWebhooks(req, res, KAPSO_PATH);
187
+ expect(captured.status).toBe(401);
188
+ });
189
+
190
+ test("missing signature → 401", async () => {
191
+ const rawBody = JSON.stringify(
192
+ makePayload({ phoneNumberId: "pn-http", messageId: "wamid.HTTP_NOSIG" }),
193
+ );
194
+ const { req, res, captured } = fakeReqRes(rawBody, {});
195
+ await handleWebhooks(req, res, KAPSO_PATH);
196
+ expect(captured.status).toBe(401);
197
+ });
198
+ });
@@ -323,9 +323,10 @@ describe("Tool Annotations & Classification", () => {
323
323
  test("registered tool count matches expected total", () => {
324
324
  const count = Object.keys(tools).length;
325
325
  // We expect all tools to be registered when all capabilities are enabled (default)
326
- // Includes 11 skill tools, 7 MCP server tools, and reusable script tools
326
+ // Includes 11 skill tools, 7 MCP server tools, reusable script tools, and the
327
+ // native Kapso/WhatsApp tools (register/unregister number + send/reply message).
327
328
  expect(count).toBeGreaterThanOrEqual(45);
328
- expect(count).toBeLessThanOrEqual(100);
329
+ expect(count).toBeLessThanOrEqual(120);
329
330
  });
330
331
 
331
332
  test("core tools are fewer than deferred tools", () => {
@@ -0,0 +1,210 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { getAgentById, getLeadAgent } from "@/be/db";
4
+ import { registerKapsoWebhook } from "@/integrations/kapso/client";
5
+ import {
6
+ deleteKapsoNumberMapping,
7
+ getKapsoConfig,
8
+ getKapsoNumberMapping,
9
+ type KapsoNumberMapping,
10
+ putKapsoNumberMapping,
11
+ } from "@/integrations/kapso/config";
12
+ import { createToolRegistrar } from "@/tools/utils";
13
+
14
+ /** Build the native inbound webhook URL the swarm exposes for Kapso deliveries. */
15
+ function nativeWebhookUrl(): string {
16
+ const base = (
17
+ process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`
18
+ ).replace(/\/+$/, "");
19
+ return `${base}/api/integrations/kapso/webhook`;
20
+ }
21
+
22
+ export const registerRegisterKapsoNumberTool = (server: McpServer) => {
23
+ createToolRegistrar(server)(
24
+ "register-kapso-number",
25
+ {
26
+ title: "Register Kapso WhatsApp Number",
27
+ annotations: { idempotentHint: true, openWorldHint: true },
28
+ description:
29
+ "Provision a Kapso WhatsApp phone number for native inbound routing. Lead-only. Points the number's Kapso webhook at the swarm's native handler (signed with KAPSO_WEBHOOK_HMAC_SECRET) and stores a KV mapping so inbound messages route to an agent (defaults to the lead, or a workflow if workflowId is given). Returns the stored mapping + the registered webhook URL.",
30
+ inputSchema: z.object({
31
+ phoneNumberId: z
32
+ .string()
33
+ .min(1)
34
+ .describe("Kapso/Meta phone-number ID to provision (KAPSO_PHONE_NUMBER_ID)."),
35
+ agentId: z
36
+ .string()
37
+ .uuid()
38
+ .optional()
39
+ .describe(
40
+ "Agent to route inbound messages to as a `kapso-inbound` task. Defaults to the lead agent when omitted.",
41
+ ),
42
+ workflowId: z
43
+ .string()
44
+ .uuid()
45
+ .optional()
46
+ .describe(
47
+ "Advanced override: dispatch inbound via this workflow's webhook trigger instead of a task.",
48
+ ),
49
+ name: z.string().optional().describe("Human-friendly display name for the number."),
50
+ }),
51
+ outputSchema: z.object({
52
+ yourAgentId: z.string().uuid().optional(),
53
+ success: z.boolean(),
54
+ message: z.string(),
55
+ webhookUrl: z.string().optional(),
56
+ webhookRegistered: z.boolean().optional(),
57
+ mapping: z
58
+ .object({
59
+ phoneNumberId: z.string(),
60
+ agentId: z.string().optional(),
61
+ workflowId: z.string().optional(),
62
+ name: z.string().optional(),
63
+ createdAt: z.string(),
64
+ })
65
+ .optional(),
66
+ }),
67
+ },
68
+ async ({ phoneNumberId, agentId, workflowId, name }, requestInfo) => {
69
+ try {
70
+ // Lead-only: provisioning a number rewires inbound routing for the
71
+ // whole swarm, so restrict it to the lead agent.
72
+ const callerAgent = requestInfo.agentId ? getAgentById(requestInfo.agentId) : null;
73
+ if (!callerAgent?.isLead) {
74
+ const msg = "Permission denied. Only the lead can register a Kapso number.";
75
+ return {
76
+ content: [{ type: "text", text: msg }],
77
+ structuredContent: { yourAgentId: requestInfo.agentId, success: false, message: msg },
78
+ };
79
+ }
80
+
81
+ // Default the routing target to the lead when no agent/workflow is given.
82
+ const ownerAgentId = agentId ?? (workflowId ? undefined : getLeadAgent()?.id);
83
+
84
+ const config = getKapsoConfig();
85
+ const webhookUrl = nativeWebhookUrl();
86
+
87
+ // Best-effort: point the Kapso webhook at our native handler. The KV
88
+ // mapping (the durable routing record the inbound handler reads) is
89
+ // written regardless, so a manually-configured number still routes.
90
+ let webhookRegistered = false;
91
+ let webhookNote = "";
92
+ if (!config.apiKey) {
93
+ webhookNote =
94
+ " (KAPSO_API_KEY not configured — skipped provider webhook registration; configure the webhook in the Kapso dashboard)";
95
+ } else {
96
+ const result = await registerKapsoWebhook({
97
+ apiBaseUrl: config.apiBaseUrl,
98
+ apiKey: config.apiKey,
99
+ phoneNumberId,
100
+ webhookUrl,
101
+ secret: config.webhookHmacSecret,
102
+ });
103
+ webhookRegistered = result.ok;
104
+ if (!result.ok) {
105
+ webhookNote = ` (provider webhook registration failed: ${result.errorMessage})`;
106
+ } else if (result.alreadyRegistered) {
107
+ webhookNote = " (webhook already registered — skipped re-creation)";
108
+ }
109
+ }
110
+
111
+ const mapping: KapsoNumberMapping = {
112
+ phoneNumberId,
113
+ ...(ownerAgentId ? { agentId: ownerAgentId } : {}),
114
+ ...(workflowId ? { workflowId } : {}),
115
+ ...(name ? { name } : {}),
116
+ createdAt: new Date().toISOString(),
117
+ };
118
+ putKapsoNumberMapping(mapping);
119
+
120
+ const text = `Registered Kapso number ${phoneNumberId} → ${
121
+ workflowId
122
+ ? `workflow ${workflowId}`
123
+ : ownerAgentId
124
+ ? `agent ${ownerAgentId}`
125
+ : "task pool"
126
+ }${webhookNote}`;
127
+ return {
128
+ content: [{ type: "text", text }],
129
+ structuredContent: {
130
+ yourAgentId: requestInfo.agentId,
131
+ success: true,
132
+ message: text,
133
+ webhookUrl,
134
+ webhookRegistered,
135
+ mapping,
136
+ },
137
+ };
138
+ } catch (err) {
139
+ const errorMessage = err instanceof Error ? err.message : String(err);
140
+ return {
141
+ content: [{ type: "text", text: `Error: ${errorMessage}` }],
142
+ structuredContent: {
143
+ yourAgentId: requestInfo.agentId,
144
+ success: false,
145
+ message: errorMessage,
146
+ },
147
+ };
148
+ }
149
+ },
150
+ );
151
+ };
152
+
153
+ export const registerUnregisterKapsoNumberTool = (server: McpServer) => {
154
+ createToolRegistrar(server)(
155
+ "unregister-kapso-number",
156
+ {
157
+ title: "Unregister Kapso WhatsApp Number",
158
+ annotations: { idempotentHint: true },
159
+ description:
160
+ "Remove a Kapso phone number's native routing mapping from the KV store. Lead-only. Inbound messages for the number stop routing through the native handler. The Kapso-side webhook is not deleted automatically — remove it in the Kapso dashboard if you want deliveries to stop.",
161
+ inputSchema: z.object({
162
+ phoneNumberId: z
163
+ .string()
164
+ .min(1)
165
+ .describe("Kapso/Meta phone-number ID whose mapping should be removed."),
166
+ }),
167
+ outputSchema: z.object({
168
+ yourAgentId: z.string().uuid().optional(),
169
+ success: z.boolean(),
170
+ message: z.string(),
171
+ }),
172
+ },
173
+ async ({ phoneNumberId }, requestInfo) => {
174
+ try {
175
+ const callerAgent = requestInfo.agentId ? getAgentById(requestInfo.agentId) : null;
176
+ if (!callerAgent?.isLead) {
177
+ const msg = "Permission denied. Only the lead can unregister a Kapso number.";
178
+ return {
179
+ content: [{ type: "text", text: msg }],
180
+ structuredContent: { yourAgentId: requestInfo.agentId, success: false, message: msg },
181
+ };
182
+ }
183
+
184
+ const existing = getKapsoNumberMapping(phoneNumberId);
185
+ const deleted = deleteKapsoNumberMapping(phoneNumberId);
186
+ const text = existing
187
+ ? `Unregistered Kapso number ${phoneNumberId}`
188
+ : `No mapping found for Kapso number ${phoneNumberId}`;
189
+ return {
190
+ content: [{ type: "text", text }],
191
+ structuredContent: {
192
+ yourAgentId: requestInfo.agentId,
193
+ success: deleted,
194
+ message: text,
195
+ },
196
+ };
197
+ } catch (err) {
198
+ const errorMessage = err instanceof Error ? err.message : String(err);
199
+ return {
200
+ content: [{ type: "text", text: `Error: ${errorMessage}` }],
201
+ structuredContent: {
202
+ yourAgentId: requestInfo.agentId,
203
+ success: false,
204
+ message: errorMessage,
205
+ },
206
+ };
207
+ }
208
+ },
209
+ );
210
+ };
@@ -7,6 +7,41 @@
7
7
 
8
8
  import { registerTemplate } from "../prompts/registry";
9
9
 
10
+ // ============================================================================
11
+ // Kapso/WhatsApp inbound (native handler creates a kapso-inbound task)
12
+ // ============================================================================
13
+
14
+ registerTemplate({
15
+ eventType: "kapso.message.received",
16
+ header: "",
17
+ defaultBody: `# WhatsApp inbound (Kapso)
18
+
19
+ A Kapso webhook fired on the swarm's provisioned WhatsApp number. Load the \`kapso-whatsapp\` skill, then triage this like any other interaction and reply on WhatsApp by quote-replying the inbound WAMID (\`context.message_id\`).
20
+
21
+ ## Source: WhatsApp (Kapso)
22
+ - conversation_id: {{conversation_id}}
23
+ - inbound_wamid: {{inbound_wamid}}
24
+ - sender_phone: {{sender_phone}}
25
+ - contact_name: {{contact_name}}
26
+ - phone_number_id: {{phone_number_id}}{{test_note}}
27
+
28
+ ## Message
29
+ {{message_text}}`,
30
+ variables: [
31
+ { name: "conversation_id", description: "Kapso conversation id, or 'unknown'" },
32
+ { name: "inbound_wamid", description: "Inbound message WAMID, or 'unknown'" },
33
+ { name: "sender_phone", description: "Sender phone (E.164 no +), or 'unknown'" },
34
+ { name: "contact_name", description: "Contact display name, or 'unknown'" },
35
+ { name: "phone_number_id", description: "Provisioned phone-number id, or 'unknown'" },
36
+ {
37
+ name: "test_note",
38
+ description: "Appended note when the payload is a Kapso test delivery (else empty)",
39
+ },
40
+ { name: "message_text", description: "Inbound message text or a non-text placeholder" },
41
+ ],
42
+ category: "event",
43
+ });
44
+
10
45
  // ============================================================================
11
46
  // Worker task follow-ups (created by store-progress for the lead)
12
47
  // ============================================================================
@@ -98,6 +98,12 @@ export const DEFERRED_TOOLS = new Set([
98
98
  // AgentMail (1)
99
99
  "register-agentmail-inbox",
100
100
 
101
+ // Kapso/WhatsApp (4)
102
+ "register-kapso-number",
103
+ "unregister-kapso-number",
104
+ "send-whatsapp-message",
105
+ "reply-whatsapp-message",
106
+
101
107
  // Tracker (6)
102
108
  "tracker-status",
103
109
  "tracker-link-task",