@desplega.ai/agent-swarm 1.84.0 → 1.85.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.
Files changed (48) hide show
  1. package/README.md +48 -8
  2. package/openapi.json +5 -3
  3. package/package.json +1 -1
  4. package/src/be/db-queries/oauth.ts +33 -0
  5. package/src/be/db.ts +7 -1
  6. package/src/be/migrations/076_kapso_sender_user_backfill.sql +43 -0
  7. package/src/be/migrations/077_oauth_refresh_locks.sql +8 -0
  8. package/src/commands/context-preamble.ts +178 -0
  9. package/src/commands/runner.ts +87 -7
  10. package/src/http/index.ts +11 -3
  11. package/src/http/tasks.ts +17 -0
  12. package/src/http/users.ts +11 -3
  13. package/src/http/utils.ts +17 -0
  14. package/src/integrations/kapso/inbound.ts +36 -0
  15. package/src/oauth/ensure-token.ts +97 -11
  16. package/src/prompts/base-prompt.ts +15 -2
  17. package/src/prompts/session-templates.ts +26 -12
  18. package/src/providers/pi-mono-adapter.ts +44 -25
  19. package/src/server.ts +2 -0
  20. package/src/tasks/worker-follow-up.ts +82 -0
  21. package/src/tests/agentmail-sending-skill.test.ts +75 -0
  22. package/src/tests/agents-list-model-display.test.ts +45 -0
  23. package/src/tests/base-prompt.test.ts +90 -1
  24. package/src/tests/db-queries-oauth.test.ts +27 -0
  25. package/src/tests/ensure-token.test.ts +71 -0
  26. package/src/tests/http-log-scrubbing.test.ts +24 -0
  27. package/src/tests/http-users.test.ts +53 -0
  28. package/src/tests/kapso-inbound.test.ts +60 -1
  29. package/src/tests/kv-page-proxy.test.ts +1 -0
  30. package/src/tests/list-endpoint-slimming.test.ts +22 -1
  31. package/src/tests/oauth-access-token-tool.test.ts +138 -0
  32. package/src/tests/pagination-metrics.test.ts +4 -4
  33. package/src/tests/pi-mono-adapter.test.ts +37 -1
  34. package/src/tests/prompt-template-session.test.ts +13 -3
  35. package/src/tests/runner-context-preamble.test.ts +202 -0
  36. package/src/tests/runner-fallback-output.test.ts +118 -39
  37. package/src/tests/task-completion-idempotency.test.ts +89 -0
  38. package/src/tools/cancel-task.ts +13 -5
  39. package/src/tools/get-task-details.ts +18 -10
  40. package/src/tools/get-tasks.ts +9 -4
  41. package/src/tools/oauth-access-token.ts +118 -0
  42. package/src/tools/send-task.ts +9 -5
  43. package/src/tools/store-progress.ts +12 -77
  44. package/src/tools/task-action.ts +20 -10
  45. package/src/tools/tool-config.ts +2 -1
  46. package/src/types.ts +5 -0
  47. package/src/utils/secret-scrubber.ts +23 -0
  48. package/templates/skills/agentmail-sending/SKILL.md +148 -28
@@ -140,6 +140,10 @@ export async function getTasksHandler(
140
140
  if (scheduleId) filters.push(`scheduleId='${scheduleId}'`);
141
141
 
142
142
  const filterMsg = filters.length > 0 ? ` (${filters.join(", ")})` : "";
143
+ const structuredContent = {
144
+ yourAgentId: agentId,
145
+ tasks: taskSummaries,
146
+ };
143
147
 
144
148
  return {
145
149
  content: [
@@ -147,11 +151,12 @@ export async function getTasksHandler(
147
151
  type: "text",
148
152
  text: `Found ${taskSummaries.length} task(s)${filterMsg}.`,
149
153
  },
154
+ {
155
+ type: "text",
156
+ text: JSON.stringify(structuredContent),
157
+ },
150
158
  ],
151
- structuredContent: {
152
- yourAgentId: agentId,
153
- tasks: taskSummaries,
154
- },
159
+ structuredContent,
155
160
  };
156
161
  }
157
162
 
@@ -0,0 +1,118 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { getOAuthTokens } from "@/be/db-queries/oauth";
4
+ import { ensureTokenOrThrow } from "@/oauth/ensure-token";
5
+ import { createToolRegistrar } from "@/tools/utils";
6
+ import { registerVolatileSecret } from "@/utils/secret-scrubber";
7
+
8
+ type OAuthProvider = string;
9
+
10
+ export interface OAuthAccessTokenResult {
11
+ provider: OAuthProvider;
12
+ accessToken: string;
13
+ expiresAt: string;
14
+ tokenType: "Bearer";
15
+ }
16
+
17
+ function assertTokenUsable(
18
+ provider: OAuthProvider,
19
+ expiresAt: string,
20
+ minValidityMs: number,
21
+ ): void {
22
+ const expiresAtMs = Date.parse(expiresAt);
23
+ if (!Number.isFinite(expiresAtMs)) {
24
+ throw new Error(`${provider} OAuth token has an invalid expiry`);
25
+ }
26
+ if (expiresAtMs - Date.now() < minValidityMs) {
27
+ throw new Error(
28
+ `${provider} OAuth token is expired or expiring soon and could not be refreshed`,
29
+ );
30
+ }
31
+ }
32
+
33
+ export async function resolveOAuthAccessToken(
34
+ provider: OAuthProvider,
35
+ minValiditySeconds = 300,
36
+ ): Promise<OAuthAccessTokenResult> {
37
+ const minValidityMs = minValiditySeconds * 1000;
38
+ await ensureTokenOrThrow(provider, minValidityMs);
39
+
40
+ const tokens = getOAuthTokens(provider);
41
+ if (!tokens) {
42
+ throw new Error(`${provider} OAuth tokens are not connected`);
43
+ }
44
+
45
+ assertTokenUsable(provider, tokens.expiresAt, minValidityMs);
46
+ registerVolatileSecret(tokens.accessToken, `${provider.toUpperCase()}_OAUTH_ACCESS_TOKEN`);
47
+
48
+ return {
49
+ provider,
50
+ accessToken: tokens.accessToken,
51
+ expiresAt: tokens.expiresAt,
52
+ tokenType: "Bearer",
53
+ };
54
+ }
55
+
56
+ export const registerGetOauthAccessTokenTool = (server: McpServer) => {
57
+ createToolRegistrar(server)(
58
+ "get-oauth-access-token",
59
+ {
60
+ title: "Get OAuth access token",
61
+ description:
62
+ "Return a valid plaintext OAuth access token for an integrated tracker. The token is refreshed first when it is near expiry. Returns access_token only; never returns refresh_token.",
63
+ annotations: { destructiveHint: false, openWorldHint: true },
64
+ inputSchema: z.object({
65
+ provider: z
66
+ .string()
67
+ .min(1)
68
+ .max(64)
69
+ .regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/, "provider must be a slug")
70
+ .describe("OAuth provider slug to read from oauth_tokens (for example: linear, jira)."),
71
+ minValiditySeconds: z
72
+ .number()
73
+ .int()
74
+ .min(0)
75
+ .max(3600)
76
+ .optional()
77
+ .default(300)
78
+ .describe("Minimum remaining token lifetime required before returning it."),
79
+ }),
80
+ outputSchema: z.object({
81
+ success: z.boolean(),
82
+ message: z.string(),
83
+ provider: z.string().optional(),
84
+ accessToken: z.string().optional(),
85
+ expiresAt: z.string().optional(),
86
+ tokenType: z.literal("Bearer").optional(),
87
+ }),
88
+ },
89
+ async ({ provider, minValiditySeconds }, _requestInfo, _meta) => {
90
+ try {
91
+ const token = await resolveOAuthAccessToken(provider, minValiditySeconds);
92
+ const message = `${provider} OAuth access token resolved; expires at ${token.expiresAt}.`;
93
+ return {
94
+ content: [
95
+ {
96
+ type: "text",
97
+ text: `${message}\n\n${token.accessToken}`,
98
+ },
99
+ ],
100
+ structuredContent: {
101
+ success: true,
102
+ message,
103
+ ...token,
104
+ },
105
+ };
106
+ } catch (err) {
107
+ const message = err instanceof Error ? err.message : String(err);
108
+ return {
109
+ content: [{ type: "text", text: `Failed to resolve OAuth access token: ${message}` }],
110
+ structuredContent: {
111
+ success: false,
112
+ message,
113
+ },
114
+ };
115
+ }
116
+ },
117
+ );
118
+ };
@@ -326,13 +326,17 @@ export async function sendTaskHandler(
326
326
  });
327
327
 
328
328
  const result = txn();
329
+ const structuredContent = {
330
+ yourAgentId: creatorAgentId,
331
+ ...result,
332
+ };
329
333
 
330
334
  return {
331
- content: [{ type: "text", text: result.message }],
332
- structuredContent: {
333
- yourAgentId: creatorAgentId,
334
- ...result,
335
- },
335
+ content: [
336
+ { type: "text", text: result.message },
337
+ { type: "text", text: JSON.stringify(result) },
338
+ ],
339
+ structuredContent,
336
340
  };
337
341
  }
338
342
 
@@ -3,14 +3,11 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import * as z from "zod";
4
4
  import {
5
5
  completeTask,
6
- createTaskExtended,
7
6
  failTask,
8
7
  getAgentById,
9
8
  getDb,
10
- getLeadAgent,
11
9
  getResolvedConfig,
12
10
  getSessionLogsByTaskId,
13
- getTaskAttachments,
14
11
  getTaskById,
15
12
  insertTaskAttachment,
16
13
  updateAgentStatusFromCapacity,
@@ -19,11 +16,9 @@ import {
19
16
  import { getEmbeddingProvider, getMemoryStore } from "@/be/memory";
20
17
  import { getRetrievalsForTask } from "@/be/memory/raters/retrieval";
21
18
  import { runServerRaters } from "@/be/memory/raters/run-server-raters";
22
- import { resolveTemplate } from "@/prompts/resolver";
19
+ import { createWorkerTaskFollowUp } from "@/tasks/worker-follow-up";
23
20
  import { createToolRegistrar } from "@/tools/utils";
24
- import { AgentTaskSchema, AttachmentInputSchema, type TaskAttachment } from "@/types";
25
- // Side-effect import: registers task lifecycle templates in the in-memory registry
26
- import "./templates";
21
+ import { AgentTaskSchema, AttachmentInputSchema } from "@/types";
27
22
  import { validateJsonSchema } from "@/workflows/json-schema-validator";
28
23
 
29
24
  // Phase 11: the `cost` / `costData` field was removed from this tool's input
@@ -33,29 +28,6 @@ import { validateJsonSchema } from "@/workflows/json-schema-validator";
33
28
  // echoed the schema example, producing noise rows keyed `mcp-<taskId>-<ts>`
34
29
  // that double-counted alongside the harness's authoritative entry.
35
30
 
36
- function attachmentPointer(a: TaskAttachment): string {
37
- switch (a.kind) {
38
- case "url":
39
- return a.url ?? "";
40
- case "page":
41
- return `page:${a.pageId ?? ""}`;
42
- case "agent-fs":
43
- return `agent-fs:${a.path ?? ""}`;
44
- case "shared-fs":
45
- return `shared-fs:${a.path ?? ""}`;
46
- }
47
- }
48
-
49
- function formatAttachmentsBlock(attachments: TaskAttachment[]): string {
50
- if (attachments.length === 0) return "";
51
- const lines = attachments.map((a) => {
52
- const tag = a.isPrimary ? "[primary] " : "";
53
- const intent = a.intent ? ` (intent: ${a.intent})` : "";
54
- return `- ${tag}${a.name} — ${attachmentPointer(a)}${intent}`;
55
- });
56
- return `\n\nAttachments (${attachments.length}):\n${lines.join("\n")}`;
57
- }
58
-
59
31
  export const registerStoreProgressTool = (server: McpServer) => {
60
32
  createToolRegistrar(server)(
61
33
  "store-progress",
@@ -460,53 +432,16 @@ export const registerStoreProgressTool = (server: McpServer) => {
460
432
  !("wasNoOp" in result && result.wasNoOp)
461
433
  ) {
462
434
  try {
463
- const taskAgent = getAgentById(result.task.agentId ?? "");
464
- // Only create follow-ups for worker tasks (not lead's own tasks)
465
- if (taskAgent && !taskAgent.isLead) {
466
- const leadAgent = getLeadAgent();
467
- if (leadAgent) {
468
- const agentName = taskAgent.name || result.task.agentId?.slice(0, 8) || "Unknown";
469
- const taskDesc = result.task.task.slice(0, 200);
470
-
471
- let followUpDescription: string;
472
- if (status === "completed") {
473
- const attachmentsBlock = formatAttachmentsBlock(getTaskAttachments(taskId));
474
- const outputSummary = output
475
- ? `${output.slice(0, 500)}${output.length > 500 ? "..." : ""}${attachmentsBlock}`
476
- : `(no output)${attachmentsBlock}`;
477
- const completedResult = resolveTemplate("task.worker.completed", {
478
- agent_name: agentName,
479
- task_desc: taskDesc,
480
- output_summary: outputSummary,
481
- task_id: taskId,
482
- });
483
- followUpDescription = completedResult.text;
484
- } else {
485
- const reason = failureReason || "(no reason given)";
486
- const failedResult = resolveTemplate("task.worker.failed", {
487
- agent_name: agentName,
488
- task_desc: taskDesc,
489
- failure_reason: reason,
490
- task_id: taskId,
491
- });
492
- followUpDescription = failedResult.text;
493
- }
494
-
495
- // If the original task came from Slack, forward context so lead can reply
496
- createTaskExtended(followUpDescription, {
497
- agentId: leadAgent.id,
498
- source: "system",
499
- taskType: "follow-up",
500
- parentTaskId: taskId,
501
- slackChannelId: result.task.slackChannelId,
502
- slackThreadTs: result.task.slackThreadTs,
503
- slackUserId: result.task.slackUserId,
504
- });
505
-
506
- console.log(
507
- `[store-progress] Created follow-up task for lead (${leadAgent.name}) — ${status} task ${taskId.slice(0, 8)} by ${agentName}`,
508
- );
509
- }
435
+ const followUp = createWorkerTaskFollowUp({
436
+ task: result.task,
437
+ status,
438
+ output,
439
+ failureReason,
440
+ });
441
+ if (followUp) {
442
+ console.log(
443
+ `[store-progress] Created follow-up task ${followUp.id.slice(0, 8)} for ${status} task ${taskId.slice(0, 8)}`,
444
+ );
510
445
  }
511
446
  } catch (err) {
512
447
  // Non-blocking — follow-up task creation failure should not affect the store-progress response
@@ -108,24 +108,34 @@ type TaskActionResult = {
108
108
 
109
109
  function agentOnlyActionResult(): CallToolResult {
110
110
  const message = "This action is only available to worker agents.";
111
+ const structuredContent = {
112
+ success: false,
113
+ message,
114
+ };
115
+
111
116
  return {
112
117
  isError: true,
113
- content: [{ type: "text", text: message }],
114
- structuredContent: {
115
- success: false,
116
- message,
117
- },
118
+ content: [
119
+ { type: "text", text: message },
120
+ { type: "text", text: JSON.stringify(structuredContent) },
121
+ ],
122
+ structuredContent,
118
123
  };
119
124
  }
120
125
 
121
126
  function taskActionCallResult(result: TaskActionResult, agentId?: string): CallToolResult {
122
127
  const { refusalSideEffects: _omit, ...publicResult } = result;
128
+ const structuredContent = {
129
+ yourAgentId: agentId,
130
+ ...publicResult,
131
+ };
132
+
123
133
  return {
124
- content: [{ type: "text", text: result.message }],
125
- structuredContent: {
126
- yourAgentId: agentId,
127
- ...publicResult,
128
- },
134
+ content: [
135
+ { type: "text", text: result.message },
136
+ { type: "text", text: JSON.stringify(structuredContent) },
137
+ ],
138
+ structuredContent,
129
139
  };
130
140
  }
131
141
 
@@ -104,8 +104,9 @@ export const DEFERRED_TOOLS = new Set([
104
104
  "send-whatsapp-message",
105
105
  "reply-whatsapp-message",
106
106
 
107
- // Tracker (6)
107
+ // Tracker (7)
108
108
  "tracker-status",
109
+ "get-oauth-access-token",
109
110
  "tracker-link-task",
110
111
  "tracker-unlink",
111
112
  "tracker-sync-status",
package/src/types.ts CHANGED
@@ -212,6 +212,10 @@ export const AgentTaskSchema = z.object({
212
212
  // Provider tracking — which harness provider ran this task
213
213
  provider: ProviderNameSchema.optional(),
214
214
  providerMeta: z.record(z.string(), z.unknown()).optional(),
215
+
216
+ // Aggregated session cost for task list/read models. Undefined means no
217
+ // session cost rows have been recorded for this task.
218
+ totalCostUsd: z.number().min(0).optional(),
215
219
  });
216
220
 
217
221
  // ============================================================================
@@ -1328,6 +1332,7 @@ export type AgentTaskSummary = Pick<
1328
1332
  | "lastUpdatedAt"
1329
1333
  | "finishedAt"
1330
1334
  | "peakContextPercent"
1335
+ | "totalCostUsd"
1331
1336
  >;
1332
1337
 
1333
1338
  export const PageVersionSchema = z.object({
@@ -142,6 +142,7 @@ interface ScrubCache {
142
142
  }
143
143
 
144
144
  let cache: ScrubCache | null = null;
145
+ const volatileSecrets = new Map<string, string>();
145
146
 
146
147
  /** Fingerprint current env so we can invalidate cache cheaply when it changes. */
147
148
  function snapshotEnv(): string {
@@ -225,6 +226,12 @@ export function scrubSecrets(text: string | null | undefined): string {
225
226
  }
226
227
  }
227
228
 
229
+ for (const [value, name] of volatileSecrets) {
230
+ if (out.includes(value)) {
231
+ out = out.split(value).join(`[REDACTED:${name}]`);
232
+ }
233
+ }
234
+
228
235
  // Pass 2: structural patterns (catches secrets we never saw in env, e.g.
229
236
  // a token pasted into a tool_result by the operator or fetched from a
230
237
  // third-party API during a task).
@@ -265,3 +272,19 @@ export function scrubObject<T>(value: T, seen = new WeakSet<object>()): T {
265
272
  export function refreshSecretScrubberCache(): void {
266
273
  cache = null;
267
274
  }
275
+
276
+ /**
277
+ * Register a runtime-fetched secret that is not present in process.env.
278
+ *
279
+ * Use this before returning short-lived tokens through an API/tool result so
280
+ * follow-on logs, telemetry previews, and session-log egress can redact the
281
+ * concrete value even though the caller still receives it.
282
+ */
283
+ export function registerVolatileSecret(value: string, name: string): void {
284
+ if (value.length < MIN_VALUE_LENGTH) return;
285
+ volatileSecrets.set(value, name);
286
+ }
287
+
288
+ export function clearVolatileSecretsForTesting(): void {
289
+ volatileSecrets.clear();
290
+ }
@@ -1,49 +1,169 @@
1
1
  ---
2
2
  name: agentmail-sending
3
- description: CRITICAL rules for sending emails via AgentMail API. Covers the HTML bug workaround, BCC policy, and best practices. ALL agents MUST follow these rules when using send_message or reply_to_message.
3
+ description: Canonical AgentMail send-message API reference for swarm agents. Pins the base URL, required field names, text-only rendering workaround, BCC policy, and ready-to-copy curl / swarm-script examples so agents do not rediscover the API surface at runtime.
4
4
  user-invocable: false
5
5
  ---
6
6
 
7
- # AgentMail Sending Rules
7
+ # AgentMail Sending
8
8
 
9
- These rules are MANDATORY for all agents sending email via AgentMail. Violating them will result in blank emails reaching real people.
9
+ ## Canonical Base URL
10
10
 
11
- ## Rule 1: TEXT ONLY — Never Pass `html` Parameter
11
+ Use this base URL exactly:
12
12
 
13
- **AgentMail has a critical bug (as of 2026-03-25):** When both `text` and `html` parameters are passed to `send_message` or `reply_to_message`, the HTML body content is silently dropped. The resulting email has an empty `<div dir="ltr"></div>`. Email clients (Gmail, etc.) prefer the HTML version over plain text, so recipients see a completely blank email.
13
+ ```text
14
+ https://api.agentmail.to/v0/
15
+ ```
16
+
17
+ DO NOT use `api.agentmail.ai`. That host is a hallucination and will not send mail through AgentMail's current API.
18
+
19
+ ## Canonical Send-Message Fields
20
+
21
+ For `POST /inboxes/{inbox}/messages/send`, the JSON body fields are exactly:
22
+
23
+ ```text
24
+ to
25
+ bcc
26
+ subject
27
+ text
28
+ ```
29
+
30
+ Use `text`, NOT `text_body`, `body`, or `content`.
31
+
32
+ Do NOT pass `html`. AgentMail has a known rendering bug: when `html` is passed with `text`, the HTML body can be empty and email clients may show a blank email. AgentMail renders `text` correctly on its own.
33
+
34
+ ## Rule 0: One-Shots Stay One-Shots
35
+
36
+ For a one-off send, such as a kickoff email or a single notification, do not create a reusable swarm-script. Use raw `curl` from Bash, or inline `script_run` if you need swarm-visible execution.
37
+
38
+ Only use `script_upsert` when the send will be reused by a workflow that fires repeatedly.
39
+
40
+ ## Default Example: Raw curl
41
+
42
+ Use this direct API call first. It does not assume any SDK is installed.
43
+
44
+ Endpoint:
45
+
46
+ ```text
47
+ https://api.agentmail.to/v0/inboxes/{inbox}/messages/send
48
+ ```
49
+
50
+ ```bash
51
+ INBOX="<agentmail-inbox-id>"
52
+
53
+ curl -sS -X POST "https://api.agentmail.to/v0/inboxes/${INBOX}/messages/send" \
54
+ -H "Authorization: Bearer $AGENTMAIL_API_KEY" \
55
+ -H "Content-Type: application/json" \
56
+ --data-binary @- <<'JSON'
57
+ {
58
+ "to": ["recipient@example.com"],
59
+ "bcc": ["oversight@example.com"],
60
+ "subject": "Subject line",
61
+ "text": "Plain-text email body."
62
+ }
63
+ JSON
64
+ ```
65
+
66
+ Notes:
67
+
68
+ - `AGENTMAIL_API_KEY` must be configured in swarm config or exported into the shell before running curl.
69
+ - Keep `bcc` for external recipients so a human oversight inbox sees outbound email.
70
+ - Do not add `html`; `text` is the canonical content field.
71
+
72
+ ## Reusable Workflow Example: script_upsert
73
+
74
+ Use this only when the send is part of a reusable workflow. The script resolves the API key from swarm config at runtime and calls the same raw HTTP endpoint with `fetch`.
14
75
 
15
- **What to do:**
16
- - ONLY pass the `text` parameter
17
- - NEVER pass the `html` parameter
18
- - This applies to BOTH `send_message` and `reply_to_message`
76
+ ```ts
77
+ await script_upsert({
78
+ name: "send-agentmail-text-email",
79
+ description: "Send a text-only AgentMail message from a reusable workflow.",
80
+ intent: "Reusable workflow email send via AgentMail raw API",
81
+ scope: "agent",
82
+ source: `
83
+ import type { ScriptContext } from "swarm-sdk";
19
84
 
20
- **Why this matters:** This bug causes outbound emails to arrive completely blank, burning contacts permanently. It is not a cosmetic issue — it is a data loss / reputation issue.
85
+ type Args = {
86
+ inbox: string;
87
+ to: string[];
88
+ bcc: string[];
89
+ subject: string;
90
+ text: string;
91
+ };
21
92
 
22
- ## Rule 2: BCC a Human Oversight Address on Outbound Emails
93
+ export default async (args: Args, ctx: ScriptContext) => {
94
+ const redactedKey = ctx.swarm.config.get('AGENTMAIL_API_KEY');
95
+ if (!redactedKey) {
96
+ throw new Error("AGENTMAIL_API_KEY is not configured in swarm config");
97
+ }
23
98
 
24
- All outbound emails to external recipients MUST include a human oversight email address as BCC. This gives your team visibility into what the swarm is sending on your behalf.
99
+ const apiKey = ctx.stdlib.Redacted.value(redactedKey);
100
+ const response = await ctx.stdlib.fetch(
101
+ \`https://api.agentmail.to/v0/inboxes/\${encodeURIComponent(args.inbox)}/messages/send\`,
102
+ {
103
+ method: "POST",
104
+ headers: {
105
+ Authorization: \`Bearer \${apiKey}\`,
106
+ "Content-Type": "application/json",
107
+ },
108
+ body: JSON.stringify({
109
+ to: args.to,
110
+ bcc: args.bcc,
111
+ subject: args.subject,
112
+ text: args.text,
113
+ }),
114
+ },
115
+ );
25
116
 
26
- **Configure a BCC oversight address for your swarm** (e.g. a founder address, ops inbox, or shared team address):
117
+ const responseText = await response.text();
118
+ if (!response.ok) {
119
+ throw new Error(\`AgentMail send failed: \${response.status} \${responseText}\`);
120
+ }
27
121
 
122
+ return responseText ? JSON.parse(responseText) : { ok: true };
123
+ };
124
+ `,
125
+ });
28
126
  ```
29
- send_message({
30
- inboxId: "<your-agentmail-inbox-id>",
31
- to: ["recipient@example.com"],
32
- bcc: ["oversight@yourcompany.com"],
33
- subject: "...",
34
- text: "..."
35
- })
127
+
128
+ Run it from a workflow with args shaped like:
129
+
130
+ ```json
131
+ {
132
+ "inbox": "<agentmail-inbox-id>",
133
+ "to": ["recipient@example.com"],
134
+ "bcc": ["oversight@example.com"],
135
+ "subject": "Subject line",
136
+ "text": "Plain-text email body."
137
+ }
36
138
  ```
37
139
 
38
- **Exception:** Internal emails between your swarm's own agent inboxes do NOT need BCC.
140
+ ## BCC Policy
141
+
142
+ All outbound emails to external recipients MUST include a human oversight email address in `bcc`. This gives the operator visibility into what the swarm sends.
143
+
144
+ Exception: internal emails between the swarm's own agent inboxes do not need BCC.
145
+
146
+ ## Human Approval
147
+
148
+ Never send outreach or cold emails to external recipients without explicit human approval. Draft the email, present it for review, and send only after receiving approval.
149
+
150
+ ## Checklist
39
151
 
40
- ## Rule 3: Human Approval Before Sending to External Recipients
152
+ Before every AgentMail send:
41
153
 
42
- Never send outreach or cold emails to external recipients without explicit human approval. Draft the emails, present them for review, and only send after receiving "approved" or equivalent confirmation.
154
+ - Use `https://api.agentmail.to/v0/`.
155
+ - Use only `to`, `bcc`, `subject`, and `text` in the send-message JSON body.
156
+ - Use `text`, not `text_body`, `body`, or `content`.
157
+ - Do not pass `html`.
158
+ - BCC a human oversight address for external recipients.
159
+ - Get human approval for outreach or cold email.
160
+ - Use raw `curl` or inline `script_run` for one-offs; reserve `script_upsert` for reusable workflow sends.
43
161
 
44
- ## Summary Checklist
162
+ ## Common Errors
45
163
 
46
- Before every `send_message` or `reply_to_message` call:
47
- - [ ] Only `text` param, NO `html` param
48
- - [ ] BCC your oversight address if recipient is external
49
- - [ ] Human-approved if it is outreach/cold email
164
+ | Symptom | Cause / fix |
165
+ |---|---|
166
+ | 404 on `/v0/inboxes/.../send` | Check the base URL. Use `api.agentmail.to`, not `api.agentmail.ai`. |
167
+ | 422 `{"detail":"text Field required"}` | The request used `text_body` or `body` instead of `text`. |
168
+ | 401 | `AGENTMAIL_API_KEY` is not configured in swarm config. In scripts, use `swarm.config.get('AGENTMAIL_API_KEY')`. |
169
+ | HTML rendering bug | Do not pass `html` at all. AgentMail renders `text` correctly. |