@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.
- package/README.md +48 -8
- package/openapi.json +5 -3
- package/package.json +1 -1
- package/src/be/db-queries/oauth.ts +33 -0
- package/src/be/db.ts +7 -1
- package/src/be/migrations/076_kapso_sender_user_backfill.sql +43 -0
- package/src/be/migrations/077_oauth_refresh_locks.sql +8 -0
- package/src/commands/context-preamble.ts +178 -0
- package/src/commands/runner.ts +87 -7
- package/src/http/index.ts +11 -3
- package/src/http/tasks.ts +17 -0
- package/src/http/users.ts +11 -3
- package/src/http/utils.ts +17 -0
- package/src/integrations/kapso/inbound.ts +36 -0
- package/src/oauth/ensure-token.ts +97 -11
- package/src/prompts/base-prompt.ts +15 -2
- package/src/prompts/session-templates.ts +26 -12
- package/src/providers/pi-mono-adapter.ts +44 -25
- package/src/server.ts +2 -0
- package/src/tasks/worker-follow-up.ts +82 -0
- package/src/tests/agentmail-sending-skill.test.ts +75 -0
- package/src/tests/agents-list-model-display.test.ts +45 -0
- package/src/tests/base-prompt.test.ts +90 -1
- package/src/tests/db-queries-oauth.test.ts +27 -0
- package/src/tests/ensure-token.test.ts +71 -0
- package/src/tests/http-log-scrubbing.test.ts +24 -0
- package/src/tests/http-users.test.ts +53 -0
- package/src/tests/kapso-inbound.test.ts +60 -1
- package/src/tests/kv-page-proxy.test.ts +1 -0
- package/src/tests/list-endpoint-slimming.test.ts +22 -1
- package/src/tests/oauth-access-token-tool.test.ts +138 -0
- package/src/tests/pagination-metrics.test.ts +4 -4
- package/src/tests/pi-mono-adapter.test.ts +37 -1
- package/src/tests/prompt-template-session.test.ts +13 -3
- package/src/tests/runner-context-preamble.test.ts +202 -0
- package/src/tests/runner-fallback-output.test.ts +118 -39
- package/src/tests/task-completion-idempotency.test.ts +89 -0
- package/src/tools/cancel-task.ts +13 -5
- package/src/tools/get-task-details.ts +18 -10
- package/src/tools/get-tasks.ts +9 -4
- package/src/tools/oauth-access-token.ts +118 -0
- package/src/tools/send-task.ts +9 -5
- package/src/tools/store-progress.ts +12 -77
- package/src/tools/task-action.ts +20 -10
- package/src/tools/tool-config.ts +2 -1
- package/src/types.ts +5 -0
- package/src/utils/secret-scrubber.ts +23 -0
- package/templates/skills/agentmail-sending/SKILL.md +148 -28
package/src/tools/get-tasks.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/tools/send-task.ts
CHANGED
|
@@ -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: [
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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 {
|
|
19
|
+
import { createWorkerTaskFollowUp } from "@/tasks/worker-follow-up";
|
|
23
20
|
import { createToolRegistrar } from "@/tools/utils";
|
|
24
|
-
import { AgentTaskSchema, AttachmentInputSchema
|
|
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
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
package/src/tools/task-action.ts
CHANGED
|
@@ -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: [
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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: [
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
package/src/tools/tool-config.ts
CHANGED
|
@@ -104,8 +104,9 @@ export const DEFERRED_TOOLS = new Set([
|
|
|
104
104
|
"send-whatsapp-message",
|
|
105
105
|
"reply-whatsapp-message",
|
|
106
106
|
|
|
107
|
-
// Tracker (
|
|
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:
|
|
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
|
|
7
|
+
# AgentMail Sending
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Canonical Base URL
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Use this base URL exactly:
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
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
|
-
|
|
85
|
+
type Args = {
|
|
86
|
+
inbox: string;
|
|
87
|
+
to: string[];
|
|
88
|
+
bcc: string[];
|
|
89
|
+
subject: string;
|
|
90
|
+
text: string;
|
|
91
|
+
};
|
|
21
92
|
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
+
Before every AgentMail send:
|
|
41
153
|
|
|
42
|
-
|
|
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
|
-
##
|
|
162
|
+
## Common Errors
|
|
45
163
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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. |
|