@desplega.ai/agent-swarm 1.83.2 → 1.84.1

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.
Files changed (36) hide show
  1. package/README.md +48 -8
  2. package/openapi.json +24 -3
  3. package/package.json +1 -1
  4. package/src/be/migrations/076_kapso_sender_user_backfill.sql +43 -0
  5. package/src/commands/context-preamble.ts +178 -0
  6. package/src/commands/runner.ts +28 -1
  7. package/src/http/users.ts +11 -3
  8. package/src/http/webhooks.ts +101 -0
  9. package/src/integrations/kapso/client.ts +198 -0
  10. package/src/integrations/kapso/config.ts +104 -0
  11. package/src/integrations/kapso/inbound.ts +147 -0
  12. package/src/prompts/base-prompt.ts +15 -2
  13. package/src/prompts/session-templates.ts +26 -12
  14. package/src/server.ts +14 -0
  15. package/src/tests/agentmail-sending-skill.test.ts +75 -0
  16. package/src/tests/agents-list-model-display.test.ts +33 -0
  17. package/src/tests/base-prompt.test.ts +90 -1
  18. package/src/tests/http-users.test.ts +53 -0
  19. package/src/tests/kapso-client.test.ts +94 -0
  20. package/src/tests/kapso-inbound.test.ts +257 -0
  21. package/src/tests/kv-page-proxy.test.ts +1 -0
  22. package/src/tests/pagination-metrics.test.ts +4 -4
  23. package/src/tests/prompt-template-session.test.ts +13 -3
  24. package/src/tests/runner-context-preamble.test.ts +202 -0
  25. package/src/tests/tool-annotations.test.ts +3 -2
  26. package/src/tools/cancel-task.ts +13 -5
  27. package/src/tools/get-task-details.ts +18 -10
  28. package/src/tools/get-tasks.ts +9 -4
  29. package/src/tools/register-kapso-number.ts +210 -0
  30. package/src/tools/send-task.ts +9 -5
  31. package/src/tools/task-action.ts +20 -10
  32. package/src/tools/templates.ts +35 -0
  33. package/src/tools/tool-config.ts +6 -0
  34. package/src/tools/whatsapp-message.ts +135 -0
  35. package/templates/skills/agentmail-sending/SKILL.md +169 -0
  36. package/templates/skills/kapso-whatsapp/SKILL.md +383 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Thin Kapso REST client for the native integration. Pure `fetch` — no DB access.
3
+ *
4
+ * Two Kapso surfaces are used:
5
+ * - Meta Cloud API proxy: `{base}/meta/whatsapp/v24.0/{phoneNumberId}/messages`
6
+ * - Kapso platform API: `{base}/platform/v1/whatsapp/phone_numbers/{id}/webhooks`
7
+ *
8
+ * Both authenticate with the `X-API-Key` header.
9
+ */
10
+
11
+ /** Result of an outbound text/reply send through the Meta proxy. */
12
+ export interface KapsoSendResult {
13
+ ok: boolean;
14
+ status: number;
15
+ /** Outbound WAMID when the send succeeded. */
16
+ messageId?: string;
17
+ raw: unknown;
18
+ /** True when Kapso/Meta rejected the send for being outside the 24h session window. */
19
+ sessionWindowExpired: boolean;
20
+ errorMessage?: string;
21
+ }
22
+
23
+ /** Meta error codes that mean "outside the 24h customer-service window". */
24
+ const SESSION_WINDOW_ERROR_CODES = new Set([131047, 131051, 470]);
25
+
26
+ function extractMetaError(raw: unknown): { code?: number; message?: string } {
27
+ if (raw && typeof raw === "object" && "error" in raw) {
28
+ const err = (raw as { error?: { code?: number; message?: string } }).error;
29
+ if (err) return { code: err.code, message: err.message };
30
+ }
31
+ return {};
32
+ }
33
+
34
+ function isSessionWindowError(raw: unknown): boolean {
35
+ const { code, message } = extractMetaError(raw);
36
+ if (code !== undefined && SESSION_WINDOW_ERROR_CODES.has(code)) return true;
37
+ const text = (message ?? "").toLowerCase();
38
+ return text.includes("24 hours") || text.includes("re-engagement") || text.includes("outside");
39
+ }
40
+
41
+ async function parseJsonSafe(res: Response): Promise<unknown> {
42
+ const text = await res.text();
43
+ if (!text) return null;
44
+ try {
45
+ return JSON.parse(text);
46
+ } catch {
47
+ return text;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Send a free-form WhatsApp text message via the Meta Cloud API proxy. When
53
+ * `contextMessageId` is set, the message renders as a quote-reply to that WAMID.
54
+ */
55
+ export async function sendKapsoText(params: {
56
+ apiBaseUrl: string;
57
+ apiKey: string;
58
+ phoneNumberId: string;
59
+ to: string;
60
+ body: string;
61
+ previewUrl?: boolean;
62
+ contextMessageId?: string;
63
+ }): Promise<KapsoSendResult> {
64
+ const url = `${params.apiBaseUrl}/meta/whatsapp/v24.0/${params.phoneNumberId}/messages`;
65
+ const payload: Record<string, unknown> = {
66
+ messaging_product: "whatsapp",
67
+ recipient_type: "individual",
68
+ to: params.to,
69
+ type: "text",
70
+ text: { preview_url: params.previewUrl ?? false, body: params.body },
71
+ };
72
+ if (params.contextMessageId) {
73
+ payload.context = { message_id: params.contextMessageId };
74
+ }
75
+
76
+ const res = await fetch(url, {
77
+ method: "POST",
78
+ headers: { "X-API-Key": params.apiKey, "Content-Type": "application/json" },
79
+ body: JSON.stringify(payload),
80
+ });
81
+ const raw = await parseJsonSafe(res);
82
+
83
+ if (!res.ok) {
84
+ const { message } = extractMetaError(raw);
85
+ return {
86
+ ok: false,
87
+ status: res.status,
88
+ raw,
89
+ sessionWindowExpired: isSessionWindowError(raw),
90
+ errorMessage: message ?? `Kapso send failed with status ${res.status}`,
91
+ };
92
+ }
93
+
94
+ const messageId =
95
+ raw && typeof raw === "object" && "messages" in raw
96
+ ? (raw as { messages?: Array<{ id?: string }> }).messages?.[0]?.id
97
+ : undefined;
98
+ return { ok: true, status: res.status, messageId, raw, sessionWindowExpired: false };
99
+ }
100
+
101
+ /** Result of configuring a webhook on a phone number. */
102
+ export interface KapsoWebhookResult {
103
+ ok: boolean;
104
+ status: number;
105
+ raw: unknown;
106
+ errorMessage?: string;
107
+ /** True when an identical webhook already existed and we skipped re-creating it. */
108
+ alreadyRegistered?: boolean;
109
+ }
110
+
111
+ /** Pull a webhook array out of the various shapes Kapso's list endpoint may return. */
112
+ function extractWebhookList(raw: unknown): Array<{ url?: string; kind?: string }> {
113
+ if (Array.isArray(raw)) return raw as Array<{ url?: string; kind?: string }>;
114
+ if (raw && typeof raw === "object") {
115
+ for (const key of ["whatsapp_webhooks", "webhooks", "data"]) {
116
+ const val = (raw as Record<string, unknown>)[key];
117
+ if (Array.isArray(val)) return val as Array<{ url?: string; kind?: string }>;
118
+ }
119
+ }
120
+ return [];
121
+ }
122
+
123
+ /**
124
+ * Return true when a Kapso webhook already points at `webhookUrl` for this
125
+ * phone number — used to avoid creating duplicate webhooks on re-registration.
126
+ * Best-effort: returns false if the list endpoint is unavailable.
127
+ */
128
+ async function kapsoWebhookExists(params: {
129
+ apiBaseUrl: string;
130
+ apiKey: string;
131
+ phoneNumberId: string;
132
+ webhookUrl: string;
133
+ }): Promise<boolean> {
134
+ const url = `${params.apiBaseUrl}/platform/v1/whatsapp/phone_numbers/${params.phoneNumberId}/webhooks`;
135
+ try {
136
+ const res = await fetch(url, { headers: { "X-API-Key": params.apiKey } });
137
+ if (!res.ok) return false;
138
+ const raw = await parseJsonSafe(res);
139
+ return extractWebhookList(raw).some((w) => w?.url === params.webhookUrl);
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Register (or re-point) the Kapso webhook for a phone number so inbound events
147
+ * are delivered to `webhookUrl`, signed with `secret` via `X-Webhook-Signature`.
148
+ *
149
+ * First checks whether an identical webhook already exists for the number and
150
+ * skips the create call if so, to avoid piling up duplicate webhooks when a
151
+ * number is registered more than once.
152
+ */
153
+ export async function registerKapsoWebhook(params: {
154
+ apiBaseUrl: string;
155
+ apiKey: string;
156
+ phoneNumberId: string;
157
+ webhookUrl: string;
158
+ secret?: string;
159
+ events?: string[];
160
+ }): Promise<KapsoWebhookResult> {
161
+ const url = `${params.apiBaseUrl}/platform/v1/whatsapp/phone_numbers/${params.phoneNumberId}/webhooks`;
162
+
163
+ if (
164
+ await kapsoWebhookExists({
165
+ apiBaseUrl: params.apiBaseUrl,
166
+ apiKey: params.apiKey,
167
+ phoneNumberId: params.phoneNumberId,
168
+ webhookUrl: params.webhookUrl,
169
+ })
170
+ ) {
171
+ return { ok: true, status: 200, raw: null, alreadyRegistered: true };
172
+ }
173
+
174
+ const whatsapp_webhook: Record<string, unknown> = {
175
+ kind: "kapso",
176
+ url: params.webhookUrl,
177
+ events: params.events ?? ["whatsapp.message.received"],
178
+ };
179
+ if (params.secret) whatsapp_webhook.secret_key = params.secret;
180
+
181
+ const res = await fetch(url, {
182
+ method: "POST",
183
+ headers: { "X-API-Key": params.apiKey, "Content-Type": "application/json" },
184
+ body: JSON.stringify({ whatsapp_webhook }),
185
+ });
186
+ const raw = await parseJsonSafe(res);
187
+
188
+ if (!res.ok) {
189
+ const { message } = extractMetaError(raw);
190
+ return {
191
+ ok: false,
192
+ status: res.status,
193
+ raw,
194
+ errorMessage: message ?? `Kapso webhook registration failed with status ${res.status}`,
195
+ };
196
+ }
197
+ return { ok: true, status: res.status, raw };
198
+ }
@@ -0,0 +1,104 @@
1
+ import { deleteKv, getKv, getSwarmConfigs, upsertKv } from "@/be/db";
2
+
3
+ /**
4
+ * Native Kapso/WhatsApp integration — shared server-side config + mapping store.
5
+ *
6
+ * The mapping (phone-number-id → routing target) is backed by the swarm KV store
7
+ * under a pinned namespace, NOT a dedicated table. The inbound webhook handler and
8
+ * the `register-kapso-number` MCP tool are the only readers/writers.
9
+ */
10
+
11
+ /** Pinned KV namespace for phone-number → routing mappings. No TTL. */
12
+ export const KAPSO_NUMBERS_NAMESPACE = "integrations:kapso:numbers";
13
+
14
+ /** Pinned KV namespace for inbound message-id dedupe markers (24h TTL). */
15
+ export const KAPSO_DEDUPE_NAMESPACE = "integrations:kapso:dedupe";
16
+
17
+ /** How long a dedupe marker lives — long enough to cover Kapso's webhook retries. */
18
+ export const KAPSO_DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
19
+
20
+ /** Default Kapso API host when `KAPSO_API_BASE_URL` is unset (host only, no path). */
21
+ export const DEFAULT_KAPSO_API_BASE_URL = "https://api.kapso.ai";
22
+
23
+ /** A registered phone number and where its inbound messages should route. */
24
+ export interface KapsoNumberMapping {
25
+ phoneNumberId: string;
26
+ /** Route inbound to this agent as a task. */
27
+ agentId?: string;
28
+ /** Advanced override: dispatch via this workflow's webhook trigger instead of a task. */
29
+ workflowId?: string;
30
+ /** Human-friendly display name for the number. */
31
+ name?: string;
32
+ createdAt: string;
33
+ }
34
+
35
+ export interface KapsoConfig {
36
+ apiKey: string | undefined;
37
+ apiBaseUrl: string;
38
+ webhookHmacSecret: string | undefined;
39
+ phoneNumberId: string | undefined;
40
+ }
41
+
42
+ /**
43
+ * Read a swarm-config value (global scope) by key, falling back to the process
44
+ * env. Decryption happens inside `getSwarmConfigs`.
45
+ */
46
+ function readConfigValue(key: string): string | undefined {
47
+ const found = getSwarmConfigs({ scope: "global", key }).find(
48
+ (c) => typeof c.value === "string" && c.value.length > 0,
49
+ );
50
+ if (found) return found.value;
51
+ const env = process.env[key];
52
+ return env && env.length > 0 ? env : undefined;
53
+ }
54
+
55
+ /** Resolve the Kapso integration config from swarm config (env fallback). */
56
+ export function getKapsoConfig(): KapsoConfig {
57
+ const base = readConfigValue("KAPSO_API_BASE_URL") ?? DEFAULT_KAPSO_API_BASE_URL;
58
+ return {
59
+ apiKey: readConfigValue("KAPSO_API_KEY"),
60
+ apiBaseUrl: base.replace(/\/+$/, ""),
61
+ webhookHmacSecret: readConfigValue("KAPSO_WEBHOOK_HMAC_SECRET"),
62
+ phoneNumberId: readConfigValue("KAPSO_PHONE_NUMBER_ID"),
63
+ };
64
+ }
65
+
66
+ /** Look up the routing mapping for a phone-number-id, or null if unregistered. */
67
+ export function getKapsoNumberMapping(phoneNumberId: string): KapsoNumberMapping | null {
68
+ const row = getKv(KAPSO_NUMBERS_NAMESPACE, phoneNumberId);
69
+ return row ? (row.value as KapsoNumberMapping) : null;
70
+ }
71
+
72
+ /** Upsert a routing mapping (no TTL). */
73
+ export function putKapsoNumberMapping(mapping: KapsoNumberMapping): KapsoNumberMapping {
74
+ upsertKv({
75
+ namespace: KAPSO_NUMBERS_NAMESPACE,
76
+ key: mapping.phoneNumberId,
77
+ value: mapping,
78
+ valueType: "json",
79
+ expiresAt: null,
80
+ });
81
+ return mapping;
82
+ }
83
+
84
+ /** Delete a routing mapping. Returns true if a row was removed. */
85
+ export function deleteKapsoNumberMapping(phoneNumberId: string): boolean {
86
+ return deleteKv(KAPSO_NUMBERS_NAMESPACE, phoneNumberId);
87
+ }
88
+
89
+ /**
90
+ * Record a message-id as processed. Returns true the FIRST time a given id is
91
+ * seen and false on every subsequent delivery within the TTL window — so the
92
+ * caller drops duplicates (Kapso retries deliveries).
93
+ */
94
+ export function markKapsoMessageSeen(messageId: string): boolean {
95
+ if (getKv(KAPSO_DEDUPE_NAMESPACE, messageId)) return false;
96
+ upsertKv({
97
+ namespace: KAPSO_DEDUPE_NAMESPACE,
98
+ key: messageId,
99
+ value: 1,
100
+ valueType: "integer",
101
+ expiresAt: Date.now() + KAPSO_DEDUPE_TTL_MS,
102
+ });
103
+ return true;
104
+ }
@@ -0,0 +1,147 @@
1
+ import { resolveTemplate } from "@/prompts/resolver";
2
+ import { createTaskWithSiblingAwareness } from "@/tasks/sibling-awareness";
3
+ import { workflowEventBus } from "@/workflows/event-bus";
4
+ import "@/tools/templates";
5
+ import { recordUnmappedIdentity } from "@/be/unmapped-identities";
6
+ import { findUserByExternalId } from "@/be/users";
7
+ import { getKapsoNumberMapping, markKapsoMessageSeen } from "./config";
8
+
9
+ const KAPSO_IDENTITY_KIND = "kapso";
10
+ const WHATSAPP_IDENTITY_KIND = "whatsapp";
11
+
12
+ /** Minimal shape of the Kapso v2 inbound webhook payload (see the kapso-whatsapp skill). */
13
+ export interface KapsoWebhookPayload {
14
+ message?: {
15
+ id?: string;
16
+ from?: string;
17
+ type?: string;
18
+ text?: { body?: string };
19
+ kapso?: { direction?: string; content?: string; has_media?: boolean };
20
+ };
21
+ conversation?: {
22
+ id?: string;
23
+ phone_number?: string;
24
+ contact_name?: string;
25
+ };
26
+ phone_number_id?: string;
27
+ test?: boolean;
28
+ }
29
+
30
+ /** Outcome of routing one inbound webhook delivery. */
31
+ export type KapsoRouting =
32
+ | { kind: "skip"; reason: string }
33
+ | { kind: "duplicate"; messageId: string }
34
+ | { kind: "workflow"; workflowId: string }
35
+ | { kind: "task"; taskId: string }
36
+ | { kind: "no_mapping"; phoneNumberId: string };
37
+
38
+ function extractText(message: NonNullable<KapsoWebhookPayload["message"]>): string {
39
+ if (message.text?.body) return message.text.body;
40
+ if (message.kapso?.content) return message.kapso.content;
41
+ return `(non-text message — type: ${message.type ?? "unknown"})`;
42
+ }
43
+
44
+ function buildTaskDescription(payload: KapsoWebhookPayload): string {
45
+ const message = payload.message ?? {};
46
+ const conversation = payload.conversation ?? {};
47
+ return resolveTemplate("kapso.message.received", {
48
+ conversation_id: conversation.id ?? "unknown",
49
+ inbound_wamid: message.id ?? "unknown",
50
+ sender_phone: message.from ?? conversation.phone_number ?? "unknown",
51
+ contact_name: conversation.contact_name ?? "unknown",
52
+ phone_number_id: payload.phone_number_id ?? "unknown",
53
+ test_note: payload.test ? "\n- test: true (do NOT send a real WhatsApp reply)" : "",
54
+ message_text: extractText(message),
55
+ }).text;
56
+ }
57
+
58
+ function normalizeKapsoSender(payload: KapsoWebhookPayload): string | null {
59
+ const raw = payload.message?.from ?? payload.conversation?.phone_number ?? "";
60
+ const digits = raw.replace(/\D/g, "");
61
+ return digits || null;
62
+ }
63
+
64
+ function resolveKapsoRequestedByUserId(payload: KapsoWebhookPayload): string | undefined {
65
+ const externalId = normalizeKapsoSender(payload);
66
+ if (!externalId) return undefined;
67
+
68
+ const mapped =
69
+ findUserByExternalId(KAPSO_IDENTITY_KIND, externalId) ??
70
+ findUserByExternalId(WHATSAPP_IDENTITY_KIND, externalId);
71
+ if (mapped) return mapped.id;
72
+
73
+ recordUnmappedIdentity(KAPSO_IDENTITY_KIND, externalId, {
74
+ sampleEventType: "kapso.message.received",
75
+ sampleContext: [
76
+ payload.conversation?.contact_name ? `contact=${payload.conversation.contact_name}` : null,
77
+ payload.conversation?.id ? `conversation=${payload.conversation.id}` : null,
78
+ payload.message?.id ? `message=${payload.message.id}` : null,
79
+ payload.phone_number_id ? `phone_number_id=${payload.phone_number_id}` : null,
80
+ ]
81
+ .filter(Boolean)
82
+ .join(" "),
83
+ });
84
+
85
+ return undefined;
86
+ }
87
+
88
+ /**
89
+ * Route one inbound Kapso webhook delivery. Pure of HTTP concerns — the caller
90
+ * handles HMAC verification and the workflow-trigger dispatch (which needs the
91
+ * raw body + executor registry). This:
92
+ * 1. drops non-inbound events and deliveries missing a message id,
93
+ * 2. dedupes by message id (KV, 24h TTL),
94
+ * 3. emits the `kapso.message.received` workflow event (additive),
95
+ * 4. looks up the phone-number mapping and either signals a workflow dispatch
96
+ * or creates a native `kapso-inbound` task,
97
+ * 5. returns `no_mapping` when the number isn't registered (caller logs a warning).
98
+ */
99
+ export function routeKapsoInbound(payload: KapsoWebhookPayload): KapsoRouting {
100
+ const message = payload.message;
101
+ const direction = message?.kapso?.direction;
102
+ if (direction !== "inbound") {
103
+ return { kind: "skip", reason: `non_inbound (direction=${direction ?? "none"})` };
104
+ }
105
+
106
+ const messageId = message?.id;
107
+ if (!messageId) {
108
+ return { kind: "skip", reason: "missing_message_id" };
109
+ }
110
+
111
+ if (!markKapsoMessageSeen(messageId)) {
112
+ return { kind: "duplicate", messageId };
113
+ }
114
+
115
+ const phoneNumberId = payload.phone_number_id ?? "";
116
+
117
+ // Additive: let event-subscribed workflows observe inbound regardless of mapping.
118
+ workflowEventBus.emit("kapso.message.received", {
119
+ phoneNumberId,
120
+ conversationId: payload.conversation?.id,
121
+ messageId,
122
+ from: message?.from,
123
+ type: message?.type,
124
+ text: extractText(message ?? {}),
125
+ });
126
+
127
+ const mapping = phoneNumberId ? getKapsoNumberMapping(phoneNumberId) : null;
128
+ if (!mapping) {
129
+ return { kind: "no_mapping", phoneNumberId };
130
+ }
131
+
132
+ if (mapping.workflowId) {
133
+ return { kind: "workflow", workflowId: mapping.workflowId };
134
+ }
135
+
136
+ const task = createTaskWithSiblingAwareness(buildTaskDescription(payload), {
137
+ agentId: mapping.agentId ?? null,
138
+ source: "system",
139
+ taskType: "kapso-inbound",
140
+ tags: ["kapso-whatsapp", "inbound"],
141
+ priority: 70,
142
+ requestedByUserId: resolveKapsoRequestedByUserId(payload),
143
+ contextKey: `kapso:conversation:${payload.conversation?.id ?? messageId}`,
144
+ });
145
+
146
+ return { kind: "task", taskId: task.id };
147
+ }
@@ -23,6 +23,13 @@ const BOOTSTRAP_TOTAL_MAX_CHARS = 150_000;
23
23
  const truncationNotice = (file: string) =>
24
24
  `\n\n[...truncated, see /workspace/${file} for full content]\n`;
25
25
 
26
+ export function areSlackPromptToolsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
27
+ const slackDisable = env.SLACK_DISABLE;
28
+ if (slackDisable === "true" || slackDisable === "1") return false;
29
+
30
+ return Boolean(env.SLACK_BOT_TOKEN && env.SLACK_APP_TOKEN);
31
+ }
32
+
26
33
  export type BasePromptArgs = {
27
34
  role: string;
28
35
  agentId: string;
@@ -71,9 +78,15 @@ export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
71
78
  const compositeResult = await resolveTemplateAsync(compositeEventType, vars);
72
79
  let prompt = compositeResult.text;
73
80
 
81
+ const slackPromptToolsEnabled = areSlackPromptToolsEnabled();
82
+
83
+ if (hasMcp && slackPromptToolsEnabled) {
84
+ const slackResult = await resolveTemplateAsync("system.agent.slack", {});
85
+ prompt += slackResult.text;
86
+ }
87
+
74
88
  // Conditionally inject Slack instructions for workers with Slack-originated tasks
75
- // Skip for providers without MCP they can't call Slack tools (slack-reply, etc.)
76
- if (role !== "lead" && args.slackContext && hasMcp) {
89
+ if (role !== "lead" && args.slackContext && hasMcp && slackPromptToolsEnabled) {
77
90
  const slackResult = await resolveTemplateAsync("system.agent.worker.slack", {
78
91
  slackChannelId: args.slackContext.channelId,
79
92
  slackThreadTs: args.slackContext.threadTs ?? "",
@@ -59,16 +59,11 @@ As the lead agent, you coordinate all worker agents in the swarm.
59
59
  - \`send-task\`: Assign a task to a specific worker or to the general pool. Slack/AgentMail metadata auto-inherits from parent task.
60
60
  - \`store-progress\`: Track coordination notes or update task status
61
61
 
62
- **User Registration:** When a task arrives from an unknown user (no \`requestedByUserId\`), use the \`manage-user\` tool to register them before proceeding. Resolve their identity from the Slack metadata (user ID, display name) attached to the task.
63
-
64
- **Slack:**
65
- - \`slack-reply\`: Reply to user in the Slack thread (use taskId for context)
66
- - \`slack-read\`: Read thread/channel history (use taskId or channelId)
67
- - \`slack-list-channels\`: Discover available Slack channels
62
+ **User Registration:** When a task arrives from an unknown user (no \`requestedByUserId\`), use the \`manage-user\` tool to register them before proceeding. Resolve their identity from the task metadata attached to the task.
68
63
 
69
64
  **Identity:**
70
65
  - \`update-profile\`: Update your own or other agents' profile fields (name, role, capabilities, soulMd, identityMd, heartbeatMd, claudeMd, toolsMd, setupScript)
71
- - \`manage-user\`: Register or update human users (resolve from Slack/GitHub/GitLab identity)
66
+ - \`manage-user\`: Register or update human users (resolve from GitHub/GitLab identity or other source metadata)
72
67
 
73
68
  #### Task Routing
74
69
 
@@ -85,16 +80,14 @@ When composing task descriptions: include the repo URL (if applicable), specific
85
80
  For follow-up tasks that should continue from previous work, pass \`parentTaskId\` with the previous task's ID:
86
81
  - Worker resumes the parent's Claude session (full conversation context preserved)
87
82
  - Child task is auto-routed to the same worker (session data is local)
88
- - Slack metadata (channelId, threadTs, userId) auto-inherits
89
83
 
90
84
  If you explicitly assign to a different worker, session resume gracefully falls back to a fresh session.
91
85
 
92
- #### Follow-Up Tasks & Slack
86
+ #### Follow-Up Tasks
93
87
 
94
88
  When a worker completes or fails a task, you receive an automatic follow-up task. Handle it by:
95
89
  1. Review the output/failure reason
96
- 2. If the task has Slack metadata, use \`slack-reply\` with the task's ID to post the result back to the originating thread
97
- 3. Complete this task. Do NOT re-delegate or create new worker tasks from a follow-up \u2014 the worker's result IS the answer. Only escalate to the stakeholder if the worker explicitly failed and the failure needs human attention.
90
+ 2. Complete this task. Do NOT re-delegate or create new worker tasks from a follow-up \u2014 the worker's result IS the answer. Only escalate to the stakeholder if the worker explicitly failed and the failure needs human attention.
98
91
 
99
92
  #### Heartbeat Checklist
100
93
 
@@ -106,7 +99,6 @@ The system reads your \`/workspace/HEARTBEAT.md\` every 30 minutes. If it has co
106
99
 
107
100
  **Example standing orders:**
108
101
  \`\`\`markdown
109
- - Check Slack for unaddressed requests older than 1 hour
110
102
  - Review active tasks for any that seem stuck or need follow-up
111
103
  - If idle workers exist and unassigned tasks are available, investigate why
112
104
  \`\`\`
@@ -122,6 +114,28 @@ The system reads your \`/workspace/HEARTBEAT.md\` every 30 minutes. If it has co
122
114
  category: "system",
123
115
  });
124
116
 
117
+ registerTemplate({
118
+ eventType: "system.agent.slack",
119
+ header: "",
120
+ defaultBody: `
121
+ #### Slack Tools
122
+
123
+ - \`slack-reply\`: Reply to user in the Slack thread (use taskId for context)
124
+ - \`slack-read\`: Read thread/channel history (use taskId or channelId)
125
+ - \`slack-list-channels\`: Discover available Slack channels
126
+
127
+ **Slack User Registration:** When a task arrives from an unknown user (no \`requestedByUserId\`) with Slack metadata, use the \`manage-user\` tool to register them before proceeding. Resolve their identity from the Slack metadata (user ID, display name) attached to the task.
128
+
129
+ **Slack context inheritance:** For follow-up tasks using \`parentTaskId\`, Slack metadata (channelId, threadTs, userId) auto-inherits.
130
+
131
+ **Slack follow-up tasks:** When a worker completes or fails a task that has Slack metadata, use \`slack-reply\` with the task's ID to post the result back to the originating thread before completing the follow-up task.
132
+
133
+ **Slack standing orders:** If you maintain heartbeat standing orders, check Slack for unaddressed requests older than 1 hour when appropriate.
134
+ `,
135
+ variables: [],
136
+ category: "system",
137
+ });
138
+
125
139
  registerTemplate({
126
140
  eventType: "system.agent.worker",
127
141
  header: "",
package/src/server.ts CHANGED
@@ -55,6 +55,10 @@ import {
55
55
  } from "./tools/prompt-templates";
56
56
  import { registerReadMessagesTool } from "./tools/read-messages";
57
57
  import { registerRegisterAgentmailInboxTool } from "./tools/register-agentmail-inbox";
58
+ import {
59
+ registerRegisterKapsoNumberTool,
60
+ registerUnregisterKapsoNumberTool,
61
+ } from "./tools/register-kapso-number";
58
62
  // Services capability
59
63
  import { registerRegisterServiceTool } from "./tools/register-service";
60
64
  // Repo management tools
@@ -118,6 +122,10 @@ import { registerUnregisterServiceTool } from "./tools/unregister-service";
118
122
  // Profiles capability
119
123
  import { registerUpdateProfileTool } from "./tools/update-profile";
120
124
  import { registerUpdateServiceStatusTool } from "./tools/update-service-status";
125
+ import {
126
+ registerReplyWhatsappMessageTool,
127
+ registerSendWhatsappMessageTool,
128
+ } from "./tools/whatsapp-message";
121
129
  // Workflows capability
122
130
  import {
123
131
  registerCancelWorkflowRunTool,
@@ -228,6 +236,12 @@ export function createServer() {
228
236
  // AgentMail integration tool (always registered, self-service inbox mapping)
229
237
  registerRegisterAgentmailInboxTool(server);
230
238
 
239
+ // Kapso/WhatsApp integration tools (native inbound provisioning + outbound)
240
+ registerRegisterKapsoNumberTool(server);
241
+ registerUnregisterKapsoNumberTool(server);
242
+ registerSendWhatsappMessageTool(server);
243
+ registerReplyWhatsappMessageTool(server);
244
+
231
245
  // Task pool capability - task pool operations (create unassigned, claim, release, accept, reject)
232
246
  if (hasCapability("task-pool")) {
233
247
  registerTaskActionTool(server);
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ const skillPath = `${import.meta.dir}/../../templates/skills/agentmail-sending/SKILL.md`;
4
+ const skill = await Bun.file(skillPath).text();
5
+ const curlInboxVariable = "$" + "{INBOX}";
6
+ const scriptApiKeyVariable = "$" + "{apiKey}";
7
+
8
+ function requireMatch(pattern: RegExp, label: string): RegExpMatchArray {
9
+ const match = skill.match(pattern);
10
+ if (!match) {
11
+ throw new Error(`Missing ${label}`);
12
+ }
13
+ return match;
14
+ }
15
+
16
+ describe("agentmail-sending skill template", () => {
17
+ test("pins the canonical base URL and rejects the hallucinated host", () => {
18
+ expect(skill).toContain("```text\nhttps://api.agentmail.to/v0/\n```");
19
+ expect(skill).toContain("DO NOT use `api.agentmail.ai`");
20
+ expect(skill).not.toContain("https://api.agentmail.ai");
21
+ });
22
+
23
+ test("pins send-message field names and text-only guidance", () => {
24
+ expect(skill).toContain("```text\nto\nbcc\nsubject\ntext\n```");
25
+ expect(skill).toContain("Use `text`, NOT `text_body`, `body`, or `content`.");
26
+ expect(skill).toContain("Do NOT pass `html`.");
27
+ });
28
+
29
+ test("curl example uses the canonical endpoint, bearer auth, and exact JSON fields", () => {
30
+ expect(skill).toContain("https://api.agentmail.to/v0/inboxes/{inbox}/messages/send");
31
+ expect(skill).toContain(
32
+ [
33
+ 'curl -sS -X POST "https://api.agentmail.to/v0/inboxes/',
34
+ curlInboxVariable,
35
+ '/messages/send"',
36
+ ].join(""),
37
+ );
38
+ expect(skill).toContain('-H "Authorization: Bearer $AGENTMAIL_API_KEY"');
39
+
40
+ const jsonBlock = requireMatch(
41
+ /--data-binary @- <<'JSON'\n([\s\S]*?)\nJSON/,
42
+ "curl JSON body",
43
+ )[1];
44
+ const payload = JSON.parse(jsonBlock);
45
+
46
+ expect(Object.keys(payload)).toEqual(["to", "bcc", "subject", "text"]);
47
+ expect(payload).not.toHaveProperty("text_body");
48
+ expect(payload).not.toHaveProperty("body");
49
+ expect(payload).not.toHaveProperty("content");
50
+ expect(payload).not.toHaveProperty("html");
51
+ });
52
+
53
+ test("script_upsert example uses fetch and resolves AGENTMAIL_API_KEY from swarm config", () => {
54
+ const scriptBlock = requireMatch(/```ts\n([\s\S]*?)\n```/, "script_upsert example")[1];
55
+
56
+ expect(scriptBlock).toContain("await script_upsert({");
57
+ expect(scriptBlock).toContain("ctx.swarm.config.get('AGENTMAIL_API_KEY')");
58
+ expect(scriptBlock).toContain("ctx.stdlib.fetch(");
59
+ expect(scriptBlock).toContain("https://api.agentmail.to/v0/inboxes/");
60
+ expect(scriptBlock).toContain("messages/send");
61
+ expect(scriptBlock).toContain(
62
+ ["Authorization: \\`Bearer \\", scriptApiKeyVariable, "\\`"].join(""),
63
+ );
64
+ expect(scriptBlock).toContain("text: args.text");
65
+ expect(scriptBlock).not.toContain("text_body");
66
+ expect(scriptBlock).not.toContain("html:");
67
+ });
68
+
69
+ test("common error table covers known AgentMail mistakes", () => {
70
+ expect(skill).toContain("404 on `/v0/inboxes/.../send`");
71
+ expect(skill).toContain('422 `{"detail":"text Field required"}`');
72
+ expect(skill).toContain("401");
73
+ expect(skill).toContain("HTML rendering bug");
74
+ });
75
+ });