@desplega.ai/agent-swarm 1.87.0 → 1.89.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 (102) hide show
  1. package/README.md +5 -1
  2. package/openapi.json +53 -1
  3. package/package.json +6 -5
  4. package/plugin/skills/composio/SKILL.md +98 -0
  5. package/src/be/db.ts +374 -9
  6. package/src/be/migrations/080_skill_system_defaults.sql +8 -0
  7. package/src/be/migrations/081_metrics.sql +39 -0
  8. package/src/be/migrations/082_user_audit_fields.sql +120 -0
  9. package/src/be/modelsdev-cache.json +3825 -2417
  10. package/src/be/seed/registry.ts +3 -2
  11. package/src/be/seed-skills/index.ts +179 -0
  12. package/src/cli.tsx +51 -4
  13. package/src/commands/e2b-stack-wizard.tsx +394 -0
  14. package/src/commands/e2b.ts +1352 -53
  15. package/src/commands/onboard/dashboard-url.ts +29 -0
  16. package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
  17. package/src/commands/onboard.tsx +3 -1
  18. package/src/commands/runner.ts +154 -22
  19. package/src/commands/x.ts +118 -0
  20. package/src/e2b/dispatch.ts +234 -18
  21. package/src/github/handlers.ts +40 -1
  22. package/src/heartbeat/heartbeat.ts +26 -5
  23. package/src/http/active-sessions.ts +32 -1
  24. package/src/http/auth.ts +36 -0
  25. package/src/http/core.ts +20 -16
  26. package/src/http/db-query.ts +20 -0
  27. package/src/http/index.ts +2 -0
  28. package/src/http/memory.ts +13 -1
  29. package/src/http/metrics.ts +447 -0
  30. package/src/http/operator-actor.ts +9 -0
  31. package/src/http/poll.ts +11 -1
  32. package/src/http/skills.ts +53 -0
  33. package/src/http/tasks.ts +4 -1
  34. package/src/http/webhooks.ts +75 -0
  35. package/src/http/workflows.ts +5 -1
  36. package/src/integrations/kapso/client.ts +82 -0
  37. package/src/memory/automatic-task-gate.ts +47 -0
  38. package/src/metrics/version.ts +26 -0
  39. package/src/prompts/base-prompt.ts +24 -1
  40. package/src/prompts/session-templates.ts +74 -0
  41. package/src/providers/claude-adapter.ts +19 -0
  42. package/src/providers/codex-adapter.ts +22 -0
  43. package/src/providers/ctx-mode-env.ts +10 -0
  44. package/src/providers/opencode-adapter.ts +72 -7
  45. package/src/server.ts +10 -1
  46. package/src/slack/blocks.ts +12 -4
  47. package/src/slack/watcher.ts +3 -3
  48. package/src/telemetry.ts +14 -1
  49. package/src/templates.d.ts +4 -0
  50. package/src/tests/base-prompt.test.ts +76 -0
  51. package/src/tests/budget-claim-gate.test.ts +26 -0
  52. package/src/tests/claude-adapter.test.ts +86 -1
  53. package/src/tests/codex-adapter.test.ts +89 -0
  54. package/src/tests/core-auth.test.ts +8 -1
  55. package/src/tests/e2b-dispatch.test.ts +603 -11
  56. package/src/tests/events-http.test.ts +6 -2
  57. package/src/tests/github-handlers-cancel-config.test.ts +262 -0
  58. package/src/tests/heartbeat.test.ts +84 -3
  59. package/src/tests/http-api-integration.test.ts +116 -1
  60. package/src/tests/kapso-client.test.ts +74 -1
  61. package/src/tests/kapso-inbound.test.ts +60 -2
  62. package/src/tests/metrics-http.test.ts +247 -0
  63. package/src/tests/opencode-adapter.test.ts +185 -30
  64. package/src/tests/prompt-template-session.test.ts +4 -2
  65. package/src/tests/runner-repo-autostash.test.ts +117 -0
  66. package/src/tests/runner-requester-profile.test.ts +25 -0
  67. package/src/tests/runner-skills-refresh.test.ts +1 -1
  68. package/src/tests/self-improvement.test.ts +89 -0
  69. package/src/tests/skill-update-scope.test.ts +88 -1
  70. package/src/tests/slack-blocks.test.ts +15 -0
  71. package/src/tests/swarm-x-tool.test.ts +90 -0
  72. package/src/tests/system-default-skills.test.ts +122 -0
  73. package/src/tests/telemetry-init.test.ts +86 -0
  74. package/src/tests/ui-logs-parser.test.ts +271 -0
  75. package/src/tests/user-token-rest-auth.test.ts +129 -0
  76. package/src/tests/workflow-async-v2.test.ts +23 -0
  77. package/src/tests/x-composio.test.ts +122 -0
  78. package/src/tools/create-metric.ts +191 -0
  79. package/src/tools/skills/skill-delete.ts +14 -0
  80. package/src/tools/skills/skill-update.ts +14 -0
  81. package/src/tools/store-progress.ts +19 -5
  82. package/src/tools/swarm-x.ts +116 -0
  83. package/src/tools/tool-config.ts +6 -0
  84. package/src/types.ts +121 -0
  85. package/src/utils/request-auth-context.ts +28 -0
  86. package/src/utils/skills-refresh.ts +2 -2
  87. package/src/workflows/engine.ts +24 -2
  88. package/src/workflows/executors/agent-task.ts +2 -0
  89. package/src/x/composio.ts +295 -0
  90. package/templates/skills/artifacts/config.json +1 -0
  91. package/templates/skills/attio-interaction/SKILL.md +279 -0
  92. package/templates/skills/attio-interaction/config.json +14 -0
  93. package/templates/skills/attio-interaction/content.md +272 -0
  94. package/templates/skills/kv-storage/config.json +1 -0
  95. package/templates/skills/pages/config.json +1 -0
  96. package/templates/skills/scheduled-task-resilience/config.json +1 -0
  97. package/templates/skills/swarm-scripts/SKILL.md +91 -0
  98. package/templates/skills/swarm-scripts/config.json +14 -0
  99. package/templates/skills/swarm-scripts/content.md +86 -0
  100. package/templates/skills/workflow-iterate/config.json +1 -0
  101. package/templates/skills/workflow-structured-output/config.json +1 -0
  102. package/tsconfig.json +2 -1
@@ -25,6 +25,10 @@ const DEFAULT_TIMEOUT_MS = 30_000;
25
25
  const MAX_ITERATIONS = Number(process.env.WORKFLOW_MAX_ITERATIONS) || 100;
26
26
  const MAX_STEPS_PER_RUN = Number(process.env.WORKFLOW_MAX_STEPS_PER_RUN) || 500;
27
27
 
28
+ export interface WorkflowExecutionOptions {
29
+ requestedByUserId?: string;
30
+ }
31
+
28
32
  /**
29
33
  * Error thrown when trigger data fails validation against a workflow's triggerSchema.
30
34
  */
@@ -50,6 +54,7 @@ export async function startWorkflowExecution(
50
54
  workflow: Workflow,
51
55
  triggerData: unknown,
52
56
  registry: ExecutorRegistry,
57
+ options: WorkflowExecutionOptions = {},
53
58
  ): Promise<string> {
54
59
  // Validate trigger data against triggerSchema (before any DB writes)
55
60
  if (workflow.triggerSchema) {
@@ -76,6 +81,9 @@ export async function startWorkflowExecution(
76
81
 
77
82
  // Resolve inputs and merge into initial context
78
83
  const ctx: Record<string, unknown> = { trigger: triggerData };
84
+ if (options.requestedByUserId) {
85
+ ctx.swarm = { requestedByUserId: options.requestedByUserId };
86
+ }
79
87
 
80
88
  // Inject workflow-level metadata for interpolation ({{workflow.dir}}, {{workflow.vcsRepo}})
81
89
  if (workflow.dir || workflow.vcsRepo) {
@@ -98,7 +106,16 @@ export async function startWorkflowExecution(
98
106
 
99
107
  const entryNodes = findEntryNodes(workflow.definition);
100
108
  const secretKeys = getSecretInputKeys(workflow.input);
101
- await walkGraph(workflow.definition, runId, ctx, entryNodes, registry, workflow.id, secretKeys);
109
+ await walkGraph(
110
+ workflow.definition,
111
+ runId,
112
+ ctx,
113
+ entryNodes,
114
+ registry,
115
+ workflow.id,
116
+ secretKeys,
117
+ options,
118
+ );
102
119
  return runId;
103
120
  }
104
121
 
@@ -127,6 +144,7 @@ export async function walkGraph(
127
144
  registry: ExecutorRegistry,
128
145
  workflowId?: string,
129
146
  secretKeys: Set<string> = new Set(),
147
+ options: WorkflowExecutionOptions = {},
130
148
  ): Promise<void> {
131
149
  let nodeExecutionCount = 0;
132
150
  const completedNodeIds = new Set(getCompletedStepNodeIds(runId));
@@ -234,7 +252,7 @@ export async function walkGraph(
234
252
  // Execute all pending nodes in parallel
235
253
  const results = await Promise.all(
236
254
  pendingNodes.map((node) =>
237
- executeStep(def, runId, ctx, node, registry, workflowId, secretKeys).catch(
255
+ executeStep(def, runId, ctx, node, registry, workflowId, secretKeys, options).catch(
238
256
  (_err): StepResult => ({
239
257
  outcome: "failed",
240
258
  successors: [],
@@ -385,6 +403,7 @@ async function executeStep(
385
403
  registry: ExecutorRegistry,
386
404
  workflowId?: string,
387
405
  secretKeys: Set<string> = new Set(),
406
+ options: WorkflowExecutionOptions = {},
388
407
  ): Promise<StepResult> {
389
408
  // Use iteration-aware idempotency key to support loops.
390
409
  // Count existing steps for this node to determine the current iteration.
@@ -438,6 +457,7 @@ async function executeStep(
438
457
  if (ctx.trigger !== undefined) interpolationCtx.trigger = ctx.trigger;
439
458
  if (ctx.input !== undefined) interpolationCtx.input = ctx.input;
440
459
  if (ctx.workflow !== undefined) interpolationCtx.workflow = ctx.workflow;
460
+ if (ctx.swarm !== undefined) interpolationCtx.swarm = ctx.swarm;
441
461
  // Resolve declared inputs
442
462
  for (const [localName, sourcePath] of Object.entries(node.inputs)) {
443
463
  const keys = sourcePath.split(".");
@@ -457,6 +477,7 @@ async function executeStep(
457
477
  if (ctx.trigger !== undefined) interpolationCtx.trigger = ctx.trigger;
458
478
  if (ctx.input !== undefined) interpolationCtx.input = ctx.input;
459
479
  if (ctx.workflow !== undefined) interpolationCtx.workflow = ctx.workflow;
480
+ if (ctx.swarm !== undefined) interpolationCtx.swarm = ctx.swarm;
460
481
  }
461
482
 
462
483
  // 3c. Validate resolved inputs against inputSchema if defined
@@ -492,6 +513,7 @@ async function executeStep(
492
513
  nodeId: node.id,
493
514
  workflowId: workflowId || "",
494
515
  dryRun: false,
516
+ requestedByUserId: options.requestedByUserId,
495
517
  };
496
518
 
497
519
  const timeoutMs =
@@ -18,6 +18,7 @@ const AgentTaskConfigSchema = z.object({
18
18
  vcsRepo: z.string().min(1).optional(),
19
19
  model: z.string().min(1).optional(),
20
20
  parentTaskId: z.string().uuid().optional(),
21
+ requestedByUserId: z.string().optional(),
21
22
  outputSchema: z.record(z.string(), z.unknown()).optional(),
22
23
  followUpConfig: FollowUpConfigSchema.optional(),
23
24
  });
@@ -95,6 +96,7 @@ export class AgentTaskExecutor extends BaseExecutor<
95
96
  vcsRepo: effectiveVcsRepo,
96
97
  model: config.model,
97
98
  parentTaskId: config.parentTaskId,
99
+ requestedByUserId: config.requestedByUserId ?? meta.requestedByUserId,
98
100
  outputSchema: config.outputSchema,
99
101
  followUpConfig: config.followUpConfig,
100
102
  contextKey: workflowContextKey({ workflowRunId: meta.runId }),
@@ -0,0 +1,295 @@
1
+ import { registerVolatileSecret, scrubObject, scrubSecrets } from "../utils/secret-scrubber";
2
+
3
+ export const DEFAULT_COMPOSIO_BASE_URL = "https://backend.composio.dev/api/v3.1";
4
+ export const COMPOSIO_HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"] as const;
5
+
6
+ const HTTP_METHODS = new Set<string>(COMPOSIO_HTTP_METHODS);
7
+
8
+ export type ComposioHttpMethod = (typeof COMPOSIO_HTTP_METHODS)[number];
9
+
10
+ export interface ComposioRequestArgs {
11
+ baseUrl: string;
12
+ body?: unknown;
13
+ endpoint: string;
14
+ headers: Record<string, string>;
15
+ method: string;
16
+ query: Array<[string, string]>;
17
+ raw: boolean;
18
+ useOrgKey: boolean;
19
+ }
20
+
21
+ export interface ComposioRequestDeps {
22
+ env?: Record<string, string | undefined>;
23
+ fetch?: typeof fetch;
24
+ }
25
+
26
+ export interface ComposioExecutionResult {
27
+ body: unknown;
28
+ contentType: string | null;
29
+ error?: string;
30
+ formattedBody: string;
31
+ method: string;
32
+ ok: boolean;
33
+ status: number;
34
+ statusText: string;
35
+ text: string;
36
+ url: string;
37
+ }
38
+
39
+ export function parseComposioArgs(
40
+ argv: string[],
41
+ env: Record<string, string | undefined> = process.env,
42
+ ): ComposioRequestArgs {
43
+ const method = argv[0]?.toUpperCase();
44
+ if (!method || !HTTP_METHODS.has(method)) {
45
+ throw new Error(
46
+ "first argument after composio must be an HTTP method: GET, POST, PUT, PATCH, DELETE, or HEAD",
47
+ );
48
+ }
49
+
50
+ const endpoint = argv[1];
51
+ if (!endpoint || endpoint.startsWith("-")) {
52
+ throw new Error("endpoint path is required, e.g. /tools");
53
+ }
54
+ assertRelativeComposioPath(endpoint);
55
+
56
+ const parsed: ComposioRequestArgs = {
57
+ baseUrl: env.COMPOSIO_BASE_URL || DEFAULT_COMPOSIO_BASE_URL,
58
+ endpoint,
59
+ headers: {},
60
+ method,
61
+ query: [],
62
+ raw: false,
63
+ useOrgKey: false,
64
+ };
65
+
66
+ for (let i = 2; i < argv.length; i++) {
67
+ const arg = argv[i] ?? "";
68
+ if (arg === "--base-url") {
69
+ parsed.baseUrl = requiredValue(argv, ++i, "--base-url");
70
+ } else if (arg === "--body" || arg === "--data") {
71
+ parsed.body = parseJsonArg(requiredValue(argv, ++i, arg), arg);
72
+ } else if (arg === "--query" || arg === "-q") {
73
+ parsed.query.push(parsePair(requiredValue(argv, ++i, arg), arg));
74
+ } else if (arg === "--header" || arg === "-H") {
75
+ const [key, value] = parseHeader(requiredValue(argv, ++i, arg));
76
+ parsed.headers[key] = value;
77
+ } else if (arg === "--org") {
78
+ parsed.useOrgKey = true;
79
+ } else if (arg === "--raw") {
80
+ parsed.raw = true;
81
+ } else {
82
+ throw new Error(`unknown option: ${arg}`);
83
+ }
84
+ }
85
+
86
+ if ((method === "GET" || method === "HEAD") && parsed.body !== undefined) {
87
+ throw new Error(`${method} requests cannot include --body`);
88
+ }
89
+
90
+ return parsed;
91
+ }
92
+
93
+ export async function executeComposioRequest(
94
+ args: ComposioRequestArgs,
95
+ deps: ComposioRequestDeps = {},
96
+ ): Promise<ComposioExecutionResult> {
97
+ const env = deps.env ?? process.env;
98
+ const fetchImpl = deps.fetch ?? fetch;
99
+ const apiKey = args.useOrgKey ? env.COMPOSIO_ORG_API_KEY : env.COMPOSIO_API_KEY;
100
+ const keyName = args.useOrgKey ? "COMPOSIO_ORG_API_KEY" : "COMPOSIO_API_KEY";
101
+
102
+ if (!apiKey) {
103
+ return {
104
+ body: null,
105
+ contentType: null,
106
+ error: `${keyName} is required. Bun auto-loads .env when running the CLI.`,
107
+ formattedBody: "",
108
+ method: args.method,
109
+ ok: false,
110
+ status: 0,
111
+ statusText: "Missing API key",
112
+ text: "",
113
+ url: buildComposioUrl(args.baseUrl, args.endpoint, args.query),
114
+ };
115
+ }
116
+
117
+ registerVolatileSecret(apiKey, keyName);
118
+
119
+ const url = buildComposioUrl(args.baseUrl, args.endpoint, args.query);
120
+ const headers: Record<string, string> = {
121
+ ...args.headers,
122
+ [args.useOrgKey ? "x-org-api-key" : "x-api-key"]: apiKey,
123
+ };
124
+ if (args.body !== undefined && !headers["Content-Type"] && !headers["content-type"]) {
125
+ headers["Content-Type"] = "application/json";
126
+ }
127
+
128
+ let response: Response;
129
+ try {
130
+ response = await fetchImpl(url, {
131
+ body: args.body === undefined ? undefined : JSON.stringify(args.body),
132
+ headers,
133
+ method: args.method,
134
+ });
135
+ } catch (err) {
136
+ return {
137
+ body: null,
138
+ contentType: null,
139
+ error: `request failed: ${scrubSecrets(errorMessage(err))}`,
140
+ formattedBody: "",
141
+ method: args.method,
142
+ ok: false,
143
+ status: 0,
144
+ statusText: "Request failed",
145
+ text: "",
146
+ url,
147
+ };
148
+ }
149
+
150
+ const text = await response.text();
151
+ const contentType = response.headers.get("Content-Type");
152
+ const body = parseResponseBody(text, contentType);
153
+ const formattedBody = formatResponseBody(text, contentType, args.raw);
154
+
155
+ return {
156
+ body: scrubObject(body),
157
+ contentType,
158
+ formattedBody,
159
+ method: args.method,
160
+ ok: response.ok,
161
+ status: response.status,
162
+ statusText: response.statusText,
163
+ text: scrubSecrets(text),
164
+ url,
165
+ };
166
+ }
167
+
168
+ export function composioArgsFromParts(input: {
169
+ baseUrl?: string;
170
+ body?: unknown;
171
+ endpoint: string;
172
+ headers?: Record<string, string>;
173
+ method: ComposioHttpMethod | string;
174
+ query?: Array<[string, string]> | Record<string, string | number | boolean | null | undefined>;
175
+ raw?: boolean;
176
+ useOrgKey?: boolean;
177
+ }): ComposioRequestArgs {
178
+ const method = input.method.toUpperCase();
179
+ if (!HTTP_METHODS.has(method)) {
180
+ throw new Error(`unsupported HTTP method: ${input.method}`);
181
+ }
182
+ if ((method === "GET" || method === "HEAD") && input.body !== undefined) {
183
+ throw new Error(`${method} requests cannot include a body`);
184
+ }
185
+ assertRelativeComposioPath(input.endpoint);
186
+
187
+ return {
188
+ baseUrl: input.baseUrl || process.env.COMPOSIO_BASE_URL || DEFAULT_COMPOSIO_BASE_URL,
189
+ body: input.body,
190
+ endpoint: input.endpoint,
191
+ headers: input.headers ?? {},
192
+ method,
193
+ query: normalizeQuery(input.query),
194
+ raw: input.raw ?? false,
195
+ useOrgKey: input.useOrgKey ?? false,
196
+ };
197
+ }
198
+
199
+ export function formatComposioResultForCli(result: ComposioExecutionResult): string {
200
+ if (result.formattedBody) return result.formattedBody;
201
+ return `HTTP ${result.status} ${result.statusText}`.trim();
202
+ }
203
+
204
+ function normalizeQuery(
205
+ query?: Array<[string, string]> | Record<string, string | number | boolean | null | undefined>,
206
+ ): Array<[string, string]> {
207
+ if (!query) return [];
208
+ if (Array.isArray(query)) return query;
209
+ const pairs: Array<[string, string]> = [];
210
+ for (const [key, value] of Object.entries(query)) {
211
+ if (value === undefined || value === null) continue;
212
+ pairs.push([key, String(value)]);
213
+ }
214
+ return pairs;
215
+ }
216
+
217
+ function assertRelativeComposioPath(endpoint: string): void {
218
+ if (/^https?:\/\//i.test(endpoint)) {
219
+ throw new Error("endpoint must be a Composio API path, not an absolute URL");
220
+ }
221
+ }
222
+
223
+ function buildComposioUrl(
224
+ baseUrl: string,
225
+ endpoint: string,
226
+ query: Array<[string, string]>,
227
+ ): string {
228
+ const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
229
+ const cleanEndpoint = endpoint.replace(/^\/+/, "");
230
+ const url = new URL(cleanEndpoint, normalizedBase);
231
+ for (const [key, value] of query) {
232
+ url.searchParams.append(key, value);
233
+ }
234
+ return url.toString();
235
+ }
236
+
237
+ function formatResponseBody(text: string, contentType: string | null, raw: boolean): string {
238
+ if (raw) return scrubSecrets(text);
239
+ const body = parseResponseBody(text, contentType);
240
+ if (body === null || body === "") return "";
241
+ if (body !== text) return scrubSecrets(JSON.stringify(body, null, 2));
242
+ return scrubSecrets(text);
243
+ }
244
+
245
+ function parseResponseBody(text: string, contentType: string | null): unknown {
246
+ const trimmed = text.trim();
247
+ if (!trimmed) return "";
248
+ if (contentType?.includes("json") || trimmed.startsWith("{") || trimmed.startsWith("[")) {
249
+ try {
250
+ return JSON.parse(trimmed);
251
+ } catch {
252
+ return text;
253
+ }
254
+ }
255
+ return text;
256
+ }
257
+
258
+ function parseJsonArg(value: string, flag: string): unknown {
259
+ try {
260
+ return JSON.parse(value);
261
+ } catch (err) {
262
+ throw new Error(`${flag} must be valid JSON: ${errorMessage(err)}`);
263
+ }
264
+ }
265
+
266
+ function parsePair(raw: string, flag: string): [string, string] {
267
+ const index = raw.indexOf("=");
268
+ if (index <= 0) {
269
+ throw new Error(`${flag} must use key=value`);
270
+ }
271
+ return [raw.slice(0, index), raw.slice(index + 1)];
272
+ }
273
+
274
+ function parseHeader(raw: string): [string, string] {
275
+ const equalsIndex = raw.indexOf("=");
276
+ const colonIndex = raw.indexOf(":");
277
+ const index =
278
+ colonIndex > 0 && (equalsIndex === -1 || colonIndex < equalsIndex) ? colonIndex : equalsIndex;
279
+ if (index <= 0) {
280
+ throw new Error("--header must use Name=value or Name: value");
281
+ }
282
+ return [raw.slice(0, index).trim(), raw.slice(index + 1).trim()];
283
+ }
284
+
285
+ function requiredValue(argv: string[], index: number, flag: string): string {
286
+ const value = argv[index];
287
+ if (!value || value.startsWith("--")) {
288
+ throw new Error(`${flag} requires a value`);
289
+ }
290
+ return value;
291
+ }
292
+
293
+ function errorMessage(err: unknown): string {
294
+ return err instanceof Error ? err.message : String(err);
295
+ }
@@ -9,5 +9,6 @@
9
9
  "category": "skills",
10
10
  "placeholders": [],
11
11
  "runAllSeedersCandidate": true,
12
+ "systemDefault": true,
12
13
  "tags": ["artifacts", "evidence", "qa"]
13
14
  }
@@ -0,0 +1,279 @@
1
+ ---
2
+ name: attio-interaction
3
+ description: "How to read and write your Attio CRM via the REST API v2: query/filter records, upsert companies/people/deals with matching_attribute, write notes and tasks, manage list entries, and handle webhooks. Auth via ATTIO_API_KEY (Bearer token). Use this skill whenever you need to interact with Attio CRM data from the swarm."
4
+ user-invocable: false
5
+ agentAutoTrigger: When asked to read from, write to, search, query, or update Attio CRM records, deals, companies, people, notes, tasks, or lists. Also when a task references Attio data such as pipeline, ICP scoring, lead enrichment, stale deals, CRM hygiene, deal handoffs, or logging an interaction to Attio.
6
+ ---
7
+
8
+ # Attio Interaction (Read + Write)
9
+
10
+ Use this skill to read and write your Attio CRM through the REST API v2. Every read or write is a direct API call; agent-swarm does not maintain a separate Attio sync.
11
+
12
+ ## TL;DR
13
+
14
+ 1. Resolve `ATTIO_API_KEY` from swarm config before making calls.
15
+ 2. Base URL: `https://api.attio.com/v2/`
16
+ 3. Use `Authorization: Bearer $ATTIO_API_KEY`, `Content-Type: application/json`, and `Accept: application/json`.
17
+ 4. Prefer upsert over create for People, Companies, and Deals: `PUT /v2/objects/{slug}/records` with `matching_attribute`.
18
+ 5. Rate limits: 100 reads/sec, 25 writes/sec. Pace write bursts to roughly 15-20/sec.
19
+ 6. Attribute values are arrays, even for scalar values: `[{ "value": 42 }]`, never `42`.
20
+
21
+ ## Authentication
22
+
23
+ ```bash
24
+ ATTIO_API_KEY=$(get-config key="ATTIO_API_KEY" includeSecrets=true)
25
+
26
+ curl -sS "https://api.attio.com/v2/objects" \
27
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
28
+ -H "Accept: application/json"
29
+ ```
30
+
31
+ If calls return `401`, re-fetch the key from config. If it still fails, notify the Lead; the key may need rotation. Do not retry silently.
32
+
33
+ ## Core object slugs
34
+
35
+ | Object | API slug | Primary matching attribute |
36
+ |---|---|---|
37
+ | Companies | `companies` | `domains` |
38
+ | People | `people` | `email_addresses` |
39
+ | Deals | `deals` | Usually no global dedupe key; link to company/person records |
40
+
41
+ Custom objects use their configured API slug. Discover them with `GET /v2/objects`.
42
+
43
+ ## Common operations
44
+
45
+ ### 1. Discover objects and slugs
46
+
47
+ ```bash
48
+ curl -sS "https://api.attio.com/v2/objects" \
49
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
50
+ -H "Accept: application/json" \
51
+ | jq '.data[] | {slug: .api_slug, name: .title}'
52
+ ```
53
+
54
+ ### 2. Query records with filters and pagination
55
+
56
+ ```bash
57
+ curl -sS -X POST "https://api.attio.com/v2/objects/companies/records/query" \
58
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
59
+ -H "Content-Type: application/json" \
60
+ -d '{
61
+ "filter": {
62
+ "stage": { "$not_equal": "Won" }
63
+ },
64
+ "limit": 100,
65
+ "offset": 0
66
+ }' | jq '.data[] | {record_id: .id.record_id, name: .values.name[0].value}'
67
+ ```
68
+
69
+ Paginate by increasing `offset` until `data` is empty. For no filter, omit the `filter` key.
70
+
71
+ ### 3. Get a single record
72
+
73
+ ```bash
74
+ curl -sS "https://api.attio.com/v2/objects/companies/records/{RECORD_ID}" \
75
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
76
+ -H "Accept: application/json" \
77
+ | jq '.data.values'
78
+ ```
79
+
80
+ ### 4. Upsert a company by domain
81
+
82
+ Use `PUT` with `matching_attribute`. It creates if not found and updates if found, so it is safe to call repeatedly.
83
+
84
+ ```bash
85
+ curl -sS -X PUT "https://api.attio.com/v2/objects/companies/records" \
86
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
87
+ -H "Content-Type: application/json" \
88
+ -d '{
89
+ "data": {
90
+ "values": {
91
+ "name": [{ "value": "Acme Corp" }],
92
+ "domains": [{ "domain": "acme.com" }],
93
+ "employee_count": [{ "value": 150 }]
94
+ }
95
+ },
96
+ "matching_attribute": "domains"
97
+ }' | jq '{record_id: .data.id.record_id}'
98
+ ```
99
+
100
+ ### 5. Upsert a person by email
101
+
102
+ ```bash
103
+ curl -sS -X PUT "https://api.attio.com/v2/objects/people/records" \
104
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
105
+ -H "Content-Type: application/json" \
106
+ -d '{
107
+ "data": {
108
+ "values": {
109
+ "name": [{ "first_name": "Jane", "last_name": "Doe" }],
110
+ "email_addresses": [{ "email_address": "jane@acme.com" }],
111
+ "job_title": [{ "value": "CTO" }]
112
+ }
113
+ },
114
+ "matching_attribute": "email_addresses"
115
+ }' | jq '{record_id: .data.id.record_id}'
116
+ ```
117
+
118
+ ### 6. Update specific attributes on an existing record
119
+
120
+ ```bash
121
+ curl -sS -X PATCH "https://api.attio.com/v2/objects/companies/records/{RECORD_ID}" \
122
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
123
+ -H "Content-Type: application/json" \
124
+ -d '{
125
+ "data": {
126
+ "values": {
127
+ "icp_score": [{ "value": 85 }],
128
+ "icp_tier": [{ "value": "Tier 1" }]
129
+ }
130
+ }
131
+ }'
132
+ ```
133
+
134
+ ### 7. Write a note to a record
135
+
136
+ ```bash
137
+ curl -sS -X POST "https://api.attio.com/v2/notes" \
138
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
139
+ -H "Content-Type: application/json" \
140
+ -d '{
141
+ "data": {
142
+ "parent_object": "companies",
143
+ "parent_record_id": "{RECORD_ID}",
144
+ "title": "Enrichment - 2026-06-02",
145
+ "content": "Employee count: 150. Funding stage: Series A. Tech stack: Node.js, React."
146
+ }
147
+ }' | jq '{note_id: .data.id.note_id}'
148
+ ```
149
+
150
+ ### 8. Create a task linked to a record
151
+
152
+ ```bash
153
+ curl -sS "https://api.attio.com/v2/workspace_members" \
154
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
155
+ -H "Accept: application/json" \
156
+ | jq '.data[] | {member_id: .id.workspace_member_id, name: .name, email: .email_address}'
157
+
158
+ curl -sS -X POST "https://api.attio.com/v2/tasks" \
159
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
160
+ -H "Content-Type: application/json" \
161
+ -d '{
162
+ "data": {
163
+ "content": "Follow up - no contact in 21 days",
164
+ "is_completed": false,
165
+ "assignees": [
166
+ { "referenced_actor_type": "workspace-member", "referenced_actor_id": "{MEMBER_ID}" }
167
+ ],
168
+ "linked_records": [
169
+ { "target_object": "deals", "target_record_id": "{DEAL_RECORD_ID}" }
170
+ ]
171
+ }
172
+ }' | jq '{task_id: .data.id.task_id}'
173
+ ```
174
+
175
+ ### 9. Post a comment on a record
176
+
177
+ ```bash
178
+ curl -sS -X POST "https://api.attio.com/v2/comments" \
179
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
180
+ -H "Content-Type: application/json" \
181
+ -d '{
182
+ "data": {
183
+ "record": { "target_object": "companies", "target_record_id": "{RECORD_ID}" },
184
+ "content": [
185
+ { "type": "text", "text": "Possible duplicate of acme-corp-old - please review and merge." }
186
+ ]
187
+ }
188
+ }' | jq '{comment_id: .data.id.comment_id}'
189
+ ```
190
+
191
+ ### 10. Query a list or pipeline view
192
+
193
+ ```bash
194
+ curl -sS "https://api.attio.com/v2/lists" \
195
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
196
+ -H "Accept: application/json" \
197
+ | jq '.data[] | {list_id: .id.list_id, name: .title}'
198
+
199
+ curl -sS -X POST "https://api.attio.com/v2/lists/{LIST_ID}/entries/query" \
200
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
201
+ -H "Content-Type: application/json" \
202
+ -d '{ "limit": 100, "offset": 0 }' \
203
+ | jq '.data[] | {entry_id: .id.entry_id, record_id: .record_id}'
204
+ ```
205
+
206
+ ### 11. Global search across objects
207
+
208
+ ```bash
209
+ curl -sS -X POST "https://api.attio.com/v2/records/search" \
210
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
211
+ -H "Content-Type: application/json" \
212
+ -d '{ "query": "acme", "limit": 10 }' \
213
+ | jq '.data[] | {object: .object_type, record_id: .id.record_id}'
214
+ ```
215
+
216
+ ## Webhooks
217
+
218
+ Attio delivers webhooks at least once. Payloads contain IDs only, so always re-fetch the full record via `GET` before acting.
219
+
220
+ Key event types:
221
+
222
+ - `record.created`, `record.updated`, `record.deleted`
223
+ - `list-entry.created`, `list-entry.updated`, `list-entry.deleted`
224
+ - `note.created`
225
+ - `task.created`, `task.completed`
226
+
227
+ Webhook timeout is 5 seconds. Respond `200` immediately and do async processing in a follow-up swarm task.
228
+
229
+ ## Rate limits
230
+
231
+ | Operation | Hard limit | Safe working rate |
232
+ |---|---|---|
233
+ | Reads | 100 req/sec | ~80 req/sec |
234
+ | Writes | 25 req/sec | ~15-20 req/sec |
235
+
236
+ Add `sleep 0.05` between write calls in loops. Attio does not provide a native batch endpoint for these operations.
237
+
238
+ ## Operational rules
239
+
240
+ - Upsert first. Use `PUT /records` with `matching_attribute` for create-or-update. `POST /records` can create duplicates.
241
+ - Re-fetch webhook records. Webhook payloads are event hints, not full source-of-truth records.
242
+ - Values are arrays. Every attribute value must be wrapped in an array.
243
+ - No merge endpoint. Attio has no API-level record merge; dedupe agents should flag duplicates as comments or tasks for human review.
244
+ - Check config first. Fetch `ATTIO_API_KEY` via `get-config includeSecrets=true`; never hardcode it.
245
+
246
+ ## Error handling
247
+
248
+ | Status | Likely cause | Action |
249
+ |---|---|---|
250
+ | 401 | API key invalid or expired | Re-fetch from config. If still failing, notify Lead for rotation. |
251
+ | 403 | Key lacks permission | Check the Attio API key's workspace permissions. |
252
+ | 404 | Wrong object slug or record ID | Re-discover slugs with `GET /v2/objects`. |
253
+ | 400 | Malformed body | Ensure attribute values are wrapped in arrays. |
254
+ | 422 | Validation or conflict error | Read the `errors` array for field-level details. |
255
+ | 429 | Rate-limited | Back off and retry after `Retry-After` if provided. |
256
+
257
+ ## Worked example: stale deal reactivation
258
+
259
+ ```bash
260
+ ATTIO_API_KEY=$(get-config key="ATTIO_API_KEY" includeSecrets=true)
261
+
262
+ DEALS=$(curl -sS -X POST "https://api.attio.com/v2/objects/deals/records/query" \
263
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
264
+ -H "Content-Type: application/json" \
265
+ -d '{"filter": {"stage": {"$not_equal": "Won"}}, "limit": 200}')
266
+
267
+ echo "$DEALS" | jq -r '.data[] | .id.record_id' | while read -r RECORD_ID; do
268
+ RECORD=$(curl -sS "https://api.attio.com/v2/objects/deals/records/$RECORD_ID" \
269
+ -H "Authorization: Bearer $ATTIO_API_KEY" \
270
+ -H "Accept: application/json")
271
+ # Compute staleness from the relevant date attribute. If stale, create a task.
272
+ sleep 0.05
273
+ done
274
+ ```
275
+
276
+ ## Related references
277
+
278
+ - Official Attio REST API docs: https://developers.attio.com/reference
279
+ - Official Attio MCP overview: https://docs.attio.com/mcp/overview