@desplega.ai/agent-swarm 1.80.2 → 1.80.3

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 CHANGED
@@ -34,6 +34,9 @@
34
34
  <a href="https://x.com/desplegalabs">
35
35
  <img src="https://img.shields.io/badge/𝕏-@desplegalabs-000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X">
36
36
  </a>
37
+ <a href="https://www.linkedin.com/company/desplega-labs/">
38
+ <img src="https://img.shields.io/badge/LinkedIn-Desplega%20Labs-0A66C2?style=for-the-badge&logo=linkedin&logoColor=white" alt="Desplega Labs on LinkedIn">
39
+ </a>
37
40
  </p>
38
41
 
39
42
  > **What if your AI agents remembered everything, learned from every mistake, and got better with every task?**
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.80.2",
5
+ "version": "1.80.3",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.80.2",
3
+ "version": "1.80.3",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -43,6 +43,7 @@ export interface AgentMailWebhookPayload {
43
43
 
44
44
  export type AgentMailEventType =
45
45
  | "message.received"
46
+ | "message.received.unauthenticated"
46
47
  | "message.sent"
47
48
  | "message.delivered"
48
49
  | "message.bounced"
@@ -412,9 +412,18 @@ export async function handleWebhooks(
412
412
 
413
413
  try {
414
414
  switch (payload.event_type) {
415
+ case "message.received.unauthenticated":
416
+ console.warn(
417
+ `[AgentMail] Received unauthenticated message - treating as received event for inbox ${payload.message?.inbox_id ?? "unknown"}`,
418
+ );
419
+
420
+ await handleMessageReceived(payload);
421
+ break;
422
+
415
423
  case "message.received":
416
424
  await handleMessageReceived(payload);
417
425
  break;
426
+
418
427
  default:
419
428
  console.log(`[AgentMail] Ignoring event type: ${payload.event_type}`);
420
429
  }
@@ -341,24 +341,11 @@ export async function handleWorkflows(
341
341
  }
342
342
  const rawBody = Buffer.concat(chunks).toString();
343
343
 
344
- // Validate JSON before processing (but pass raw string for HMAC)
345
- try {
346
- if (rawBody) JSON.parse(rawBody);
347
- } catch {
348
- jsonError(res, "Invalid JSON body", 400);
349
- return true;
350
- }
351
-
352
- const signature =
353
- (req.headers["x-hub-signature-256"] as string | undefined) ??
354
- (req.headers["x-signature"] as string | undefined);
355
-
356
344
  try {
357
345
  const result = await handleWebhookTrigger(
358
346
  workflowId,
359
- rawBody, // Raw body string — used for HMAC verification + passed as triggerData
360
- signature,
361
- signature,
347
+ rawBody, // Raw body string — HMAC is verified against raw bytes; JSON parsing happens inside
348
+ req.headers, // Full header bag — signature header resolved per trigger config
362
349
  getExecutorRegistry(),
363
350
  );
364
351
  json(res, result, 201);
@@ -1586,10 +1586,14 @@ describe("AgentMail Webhooks (with filters)", () => {
1586
1586
  expect(body).toEqual({ received: true });
1587
1587
  });
1588
1588
 
1589
- test("accepts second allowed inbox domain", async () => {
1590
- const { status } = await postWebhook(
1591
- makePayload({ inboxId: "support@y.xyz", from: "bob@b.com" }),
1592
- );
1589
+ test.each([
1590
+ "message.received",
1591
+ "message.received.unauthenticated",
1592
+ ])("accepts webhook for allowed event type '%s'", async (eventType) => {
1593
+ const { status } = await postWebhook({
1594
+ ...makePayload({ inboxId: "support@y.xyz", from: "bob@b.com" }),
1595
+ event_type: eventType,
1596
+ });
1593
1597
  expect(status).toBe(200);
1594
1598
  });
1595
1599
 
@@ -2,7 +2,14 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import crypto from "node:crypto";
3
3
  import { unlink } from "node:fs/promises";
4
4
  import { z } from "zod";
5
- import { closeDb, createWorkflow, getWorkflowRun, initDb, updateWorkflow } from "../be/db";
5
+ import {
6
+ closeDb,
7
+ createWorkflow,
8
+ getWorkflowRun,
9
+ initDb,
10
+ updateWorkflow,
11
+ upsertSwarmConfig,
12
+ } from "../be/db";
6
13
  import type { Workflow } from "../types";
7
14
  import { startWorkflowExecution } from "../workflows/engine";
8
15
  import { BaseExecutor, type ExecutorResult } from "../workflows/executors/base";
@@ -118,7 +125,12 @@ describe("handleWebhookTrigger", () => {
118
125
  hmac.update(body);
119
126
  const sig = `sha256=${hmac.digest("hex")}`;
120
127
 
121
- const result = await handleWebhookTrigger(workflow.id, body, sig, sig, registry);
128
+ const result = await handleWebhookTrigger(
129
+ workflow.id,
130
+ body,
131
+ { "x-hub-signature-256": sig },
132
+ registry,
133
+ );
122
134
 
123
135
  expect(result.runId).toBeDefined();
124
136
  expect(typeof result.runId).toBe("string");
@@ -138,8 +150,7 @@ describe("handleWebhookTrigger", () => {
138
150
  await handleWebhookTrigger(
139
151
  workflow.id,
140
152
  '{"test":true}',
141
- "sha256=invalid",
142
- "sha256=invalid",
153
+ { "x-hub-signature-256": "sha256=invalid" },
143
154
  registry,
144
155
  );
145
156
  expect(true).toBe(false); // Should not reach here
@@ -155,7 +166,7 @@ describe("handleWebhookTrigger", () => {
155
166
  });
156
167
 
157
168
  try {
158
- await handleWebhookTrigger(workflow.id, '{"test":true}', undefined, undefined, registry);
169
+ await handleWebhookTrigger(workflow.id, '{"test":true}', {}, registry);
159
170
  expect(true).toBe(false);
160
171
  } catch (err) {
161
172
  expect(err).toBeInstanceOf(WebhookError);
@@ -168,13 +179,7 @@ describe("handleWebhookTrigger", () => {
168
179
  triggers: [{ type: "webhook" }],
169
180
  });
170
181
 
171
- const result = await handleWebhookTrigger(
172
- workflow.id,
173
- '{"data":"hello"}',
174
- undefined,
175
- undefined,
176
- registry,
177
- );
182
+ const result = await handleWebhookTrigger(workflow.id, '{"data":"hello"}', {}, registry);
178
183
 
179
184
  expect(result.runId).toBeDefined();
180
185
  const run = getWorkflowRun(result.runId);
@@ -183,13 +188,7 @@ describe("handleWebhookTrigger", () => {
183
188
 
184
189
  test("workflow not found returns 404", async () => {
185
190
  try {
186
- await handleWebhookTrigger(
187
- "00000000-0000-0000-0000-000000000000",
188
- "{}",
189
- undefined,
190
- undefined,
191
- registry,
192
- );
191
+ await handleWebhookTrigger("00000000-0000-0000-0000-000000000000", "{}", {}, registry);
193
192
  expect(true).toBe(false);
194
193
  } catch (err) {
195
194
  expect(err).toBeInstanceOf(WebhookError);
@@ -205,7 +204,7 @@ describe("handleWebhookTrigger", () => {
205
204
  updateWorkflow(workflow.id, { enabled: false });
206
205
 
207
206
  try {
208
- await handleWebhookTrigger(workflow.id, "{}", undefined, undefined, registry);
207
+ await handleWebhookTrigger(workflow.id, "{}", {}, registry);
209
208
  expect(true).toBe(false);
210
209
  } catch (err) {
211
210
  expect(err).toBeInstanceOf(WebhookError);
@@ -214,6 +213,248 @@ describe("handleWebhookTrigger", () => {
214
213
  });
215
214
  });
216
215
 
216
+ // ─── Custom HMAC header + secret refs ───────────────────────
217
+
218
+ describe("handleWebhookTrigger — custom hmacHeader", () => {
219
+ function signRaw(secret: string, body: string): string {
220
+ return crypto.createHmac("sha256", secret).update(body).digest("hex");
221
+ }
222
+
223
+ test("custom hmacHeader (X-Webhook-Signature) is picked up and verified", async () => {
224
+ const secret = "kapso-secret";
225
+ const workflow = makeWorkflow({
226
+ triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
227
+ });
228
+
229
+ const body = '{"event":"message"}';
230
+ // Kapso-style: raw hex, no `sha256=` prefix.
231
+ const result = await handleWebhookTrigger(
232
+ workflow.id,
233
+ body,
234
+ { "x-webhook-signature": signRaw(secret, body) },
235
+ registry,
236
+ );
237
+
238
+ expect(result.runId).toBeDefined();
239
+ expect(getWorkflowRun(result.runId)).not.toBeNull();
240
+ });
241
+
242
+ test("custom hmacHeader lookup is case-insensitive", async () => {
243
+ const secret = "kapso-secret-ci";
244
+ const workflow = makeWorkflow({
245
+ triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
246
+ });
247
+
248
+ const body = '{"event":"ci"}';
249
+ const result = await handleWebhookTrigger(
250
+ workflow.id,
251
+ body,
252
+ { "X-Webhook-Signature": signRaw(secret, body) },
253
+ registry,
254
+ );
255
+
256
+ expect(result.runId).toBeDefined();
257
+ });
258
+
259
+ test("signature on a non-configured header is rejected as missing", async () => {
260
+ const secret = "kapso-secret-2";
261
+ const workflow = makeWorkflow({
262
+ triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
263
+ });
264
+
265
+ const body = '{"event":"x"}';
266
+ // Use a header that is neither the configured one nor a known fallback.
267
+ try {
268
+ await handleWebhookTrigger(
269
+ workflow.id,
270
+ body,
271
+ { "x-some-other-header": signRaw(secret, body) },
272
+ registry,
273
+ );
274
+ expect(true).toBe(false);
275
+ } catch (err) {
276
+ expect(err).toBeInstanceOf(WebhookError);
277
+ expect((err as WebhookError).statusCode).toBe(401);
278
+ }
279
+ });
280
+
281
+ test("fallback header (x-signature) still works without explicit hmacHeader", async () => {
282
+ const secret = "fallback-secret";
283
+ const workflow = makeWorkflow({
284
+ triggers: [{ type: "webhook", hmacSecret: secret }],
285
+ });
286
+
287
+ const body = '{"event":"fallback"}';
288
+ const result = await handleWebhookTrigger(
289
+ workflow.id,
290
+ body,
291
+ { "x-signature": signRaw(secret, body) },
292
+ registry,
293
+ );
294
+
295
+ expect(result.runId).toBeDefined();
296
+ });
297
+
298
+ test("default X-Hub-Signature-256 path still works (no regression)", async () => {
299
+ const secret = "default-header-secret";
300
+ const workflow = makeWorkflow({
301
+ triggers: [{ type: "webhook", hmacSecret: secret }],
302
+ });
303
+
304
+ const body = '{"event":"default"}';
305
+ const sig = `sha256=${signRaw(secret, body)}`;
306
+ const result = await handleWebhookTrigger(
307
+ workflow.id,
308
+ body,
309
+ { "x-hub-signature-256": sig },
310
+ registry,
311
+ );
312
+
313
+ expect(result.runId).toBeDefined();
314
+ });
315
+ });
316
+
317
+ describe("handleWebhookTrigger — hmacSecret references", () => {
318
+ function signRaw(secret: string, body: string): string {
319
+ return crypto.createHmac("sha256", secret).update(body).digest("hex");
320
+ }
321
+
322
+ test("hmacSecret as secret.NAME ref resolves and verifies", async () => {
323
+ const SECRET_VALUE = "resolved-kapso-hmac-value";
324
+ upsertSwarmConfig({
325
+ scope: "global",
326
+ key: "TEST_KAPSO_WEBHOOK_HMAC_SECRET",
327
+ value: SECRET_VALUE,
328
+ isSecret: true,
329
+ });
330
+
331
+ const workflow = makeWorkflow({
332
+ triggers: [
333
+ {
334
+ type: "webhook",
335
+ hmacSecret: "secret.TEST_KAPSO_WEBHOOK_HMAC_SECRET",
336
+ hmacHeader: "X-Webhook-Signature",
337
+ },
338
+ ],
339
+ });
340
+
341
+ const body = '{"event":"secret-ref"}';
342
+ const result = await handleWebhookTrigger(
343
+ workflow.id,
344
+ body,
345
+ { "x-webhook-signature": signRaw(SECRET_VALUE, body) },
346
+ registry,
347
+ );
348
+
349
+ expect(result.runId).toBeDefined();
350
+ expect(getWorkflowRun(result.runId)).not.toBeNull();
351
+ });
352
+
353
+ test("unresolvable secret.NAME ref fails cleanly with a WebhookError", async () => {
354
+ const workflow = makeWorkflow({
355
+ triggers: [{ type: "webhook", hmacSecret: "secret.NONEXISTENT_HMAC_SECRET_12345" }],
356
+ });
357
+
358
+ const body = '{"event":"missing-secret"}';
359
+ try {
360
+ await handleWebhookTrigger(
361
+ workflow.id,
362
+ body,
363
+ { "x-hub-signature-256": "deadbeef" },
364
+ registry,
365
+ );
366
+ expect(true).toBe(false);
367
+ } catch (err) {
368
+ expect(err).toBeInstanceOf(WebhookError);
369
+ expect((err as WebhookError).statusCode).toBe(500);
370
+ }
371
+ });
372
+
373
+ test("a literal hmacSecret is not treated as a reference", async () => {
374
+ const secret = "plain.literal-not-a-ref";
375
+ const workflow = makeWorkflow({
376
+ triggers: [{ type: "webhook", hmacSecret: secret }],
377
+ });
378
+
379
+ const body = '{"event":"literal"}';
380
+ const result = await handleWebhookTrigger(
381
+ workflow.id,
382
+ body,
383
+ { "x-hub-signature-256": signRaw(secret, body) },
384
+ registry,
385
+ );
386
+
387
+ expect(result.runId).toBeDefined();
388
+ });
389
+ });
390
+
391
+ // ─── Trigger payload JSON parsing ───────────────────────────
392
+
393
+ describe("handleWebhookTrigger — triggerData JSON parsing", () => {
394
+ function signRaw(secret: string, body: string): string {
395
+ return crypto.createHmac("sha256", secret).update(body).digest("hex");
396
+ }
397
+
398
+ test("JSON body is parsed and run.triggerData is a deep-equal object", async () => {
399
+ const workflow = makeWorkflow({ triggers: [{ type: "webhook" }] });
400
+ const payload = {
401
+ message: { from: "+34000111222", text: "hi" },
402
+ conversation: { id: "conv-abc-123" },
403
+ };
404
+ const body = JSON.stringify(payload);
405
+
406
+ const result = await handleWebhookTrigger(workflow.id, body, {}, registry);
407
+
408
+ const run = getWorkflowRun(result.runId);
409
+ expect(run).not.toBeNull();
410
+ expect(run!.triggerData).toEqual(payload);
411
+ // Deep paths must be reachable (this is what `{{trigger.message.from}}` needs).
412
+ expect((run!.triggerData as { message: { from: string } }).message.from).toBe("+34000111222");
413
+ });
414
+
415
+ test("signed JSON body: HMAC verified against raw bytes, triggerData parsed to object", async () => {
416
+ const secret = "kapso-deep-secret";
417
+ const workflow = makeWorkflow({
418
+ triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
419
+ });
420
+ // Use whitespace + unsorted keys so any re-serialization would change the bytes.
421
+ const body = '{ "message": {"from":"+1","text":"hi"}, "id":"x" }';
422
+ const sig = signRaw(secret, body);
423
+
424
+ const result = await handleWebhookTrigger(
425
+ workflow.id,
426
+ body,
427
+ { "x-webhook-signature": sig },
428
+ registry,
429
+ );
430
+
431
+ const run = getWorkflowRun(result.runId);
432
+ expect(run).not.toBeNull();
433
+ expect(run!.triggerData).toEqual({ message: { from: "+1", text: "hi" }, id: "x" });
434
+ });
435
+
436
+ test("non-JSON body falls back to the raw string and does not throw", async () => {
437
+ const workflow = makeWorkflow({ triggers: [{ type: "webhook" }] });
438
+ const body = "this is not json at all";
439
+
440
+ const result = await handleWebhookTrigger(workflow.id, body, {}, registry);
441
+
442
+ const run = getWorkflowRun(result.runId);
443
+ expect(run).not.toBeNull();
444
+ expect(run!.triggerData).toBe(body);
445
+ });
446
+
447
+ test("empty body produces a run without throwing", async () => {
448
+ const workflow = makeWorkflow({ triggers: [{ type: "webhook" }] });
449
+
450
+ const result = await handleWebhookTrigger(workflow.id, "", {}, registry);
451
+
452
+ expect(result.runId).toBeDefined();
453
+ const run = getWorkflowRun(result.runId);
454
+ expect(run).not.toBeNull();
455
+ });
456
+ });
457
+
217
458
  // ─── Manual Trigger ─────────────────────────────────────────
218
459
 
219
460
  describe("manual trigger (startWorkflowExecution)", () => {
@@ -13,13 +13,18 @@ export function resolveInputs(input: Record<string, string>): Record<string, str
13
13
  const resolved: Record<string, string> = {};
14
14
 
15
15
  for (const [key, value] of Object.entries(input)) {
16
- resolved[key] = resolveValue(value);
16
+ resolved[key] = resolveInputValue(value);
17
17
  }
18
18
 
19
19
  return resolved;
20
20
  }
21
21
 
22
- function resolveValue(value: string): string {
22
+ /**
23
+ * Resolve a single input value, supporting `${ENV_VAR}` and `secret.NAME`
24
+ * references. A plain string is returned unchanged. Throws if a referenced
25
+ * env var or swarm secret cannot be found.
26
+ */
27
+ export function resolveInputValue(value: string): string {
23
28
  // Env var reference: ${MY_VAR}
24
29
  const envMatch = /^\$\{(.+)\}$/.exec(value);
25
30
  if (envMatch?.[1]) {
@@ -3,19 +3,75 @@ import { getWorkflow, getWorkflowsByScheduleId } from "../be/db";
3
3
  import type { ScheduledTask, TriggerConfig } from "../types";
4
4
  import { startWorkflowExecution } from "./engine";
5
5
  import type { ExecutorRegistry } from "./executors/registry";
6
+ import { resolveInputValue } from "./input";
7
+
8
+ /** Header name used to look up an HMAC signature when the trigger configures none. */
9
+ const DEFAULT_HMAC_HEADER = "X-Hub-Signature-256";
10
+
11
+ /** Fallback header names checked (case-insensitive) after the trigger's configured header. */
12
+ const FALLBACK_HMAC_HEADERS = ["x-hub-signature-256", "x-signature", "x-webhook-signature"];
13
+
14
+ /** A bag of HTTP headers — values may be a string, a string array, or absent. */
15
+ export type HeaderBag = Record<string, string | string[] | undefined>;
16
+
17
+ /** Case-insensitive header lookup; returns the first value when an array is given. */
18
+ function getHeader(headers: HeaderBag, name: string): string | undefined {
19
+ const target = name.toLowerCase();
20
+ for (const [key, value] of Object.entries(headers)) {
21
+ if (key.toLowerCase() === target) {
22
+ return Array.isArray(value) ? value[0] : value;
23
+ }
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ /**
29
+ * Resolve the HMAC signature from request headers. Checks the trigger's
30
+ * configured `hmacHeader` first, then well-known fallback header names.
31
+ */
32
+ function resolveSignature(headers: HeaderBag, hmacHeader: string): string | undefined {
33
+ for (const name of [hmacHeader, ...FALLBACK_HMAC_HEADERS]) {
34
+ const value = getHeader(headers, name);
35
+ if (value) return value;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ /**
41
+ * Resolve the configured `hmacSecret`. Supports `secret.NAME` swarm-secret refs
42
+ * and `${ENV_VAR}` env refs (reusing the workflow input resolver); a plain
43
+ * string is treated as a literal. Resolved per request, never at create time.
44
+ */
45
+ function resolveHmacSecret(raw: string): string {
46
+ if (/^secret\..+$/.test(raw) || /^\$\{.+\}$/.test(raw)) {
47
+ try {
48
+ return resolveInputValue(raw);
49
+ } catch (err) {
50
+ throw new WebhookError(
51
+ `Failed to resolve webhook HMAC secret: ${err instanceof Error ? err.message : String(err)}`,
52
+ 500,
53
+ );
54
+ }
55
+ }
56
+ return raw;
57
+ }
6
58
 
7
59
  /**
8
60
  * Handle an incoming webhook trigger for a workflow.
9
61
  *
10
62
  * 1. Loads the workflow and finds a webhook trigger in `triggers[]`
11
- * 2. If `hmacSecret` is set, verifies HMAC-SHA256 signature
12
- * 3. Starts the workflow execution with the webhook payload
63
+ * 2. If `hmacSecret` is set, resolves the signature header + secret and
64
+ * verifies the HMAC-SHA256 signature against the raw body bytes
65
+ * 3. Parses the raw body as JSON (falling back to the raw string when the
66
+ * body is non-JSON) so downstream `{{trigger.deep.path}}` interpolation
67
+ * can traverse the object — matches the shape produced by the
68
+ * `trigger-workflow` MCP tool.
69
+ * 4. Starts the workflow execution with the parsed payload
13
70
  */
14
71
  export async function handleWebhookTrigger(
15
72
  workflowId: string,
16
73
  payload: unknown,
17
- signature: string | undefined,
18
- signatureHeader: string | undefined,
74
+ headers: HeaderBag,
19
75
  registry: ExecutorRegistry,
20
76
  ): Promise<{ runId: string }> {
21
77
  const workflow = getWorkflow(workflowId);
@@ -31,16 +87,20 @@ export async function handleWebhookTrigger(
31
87
  const webhookTrigger = workflow.triggers.find((t: TriggerConfig) => t.type === "webhook");
32
88
 
33
89
  // If the workflow has a webhook trigger with an hmacSecret, verify the signature
90
+ // against the RAW body bytes — re-serializing would change whitespace / key order
91
+ // and break the HMAC.
34
92
  if (webhookTrigger && webhookTrigger.type === "webhook" && webhookTrigger.hmacSecret) {
35
- if (!signature && !signatureHeader) {
93
+ const hmacHeader = webhookTrigger.hmacHeader || DEFAULT_HMAC_HEADER;
94
+ const signature = resolveSignature(headers, hmacHeader);
95
+ if (!signature) {
36
96
  throw new WebhookError("Missing signature", 401);
37
97
  }
38
98
 
39
- const rawSignature = signatureHeader || signature || "";
99
+ const secret = resolveHmacSecret(webhookTrigger.hmacSecret);
40
100
  const isValid = verifyHmacSignature(
41
- webhookTrigger.hmacSecret,
101
+ secret,
42
102
  typeof payload === "string" ? payload : JSON.stringify(payload),
43
- rawSignature,
103
+ signature,
44
104
  );
45
105
 
46
106
  if (!isValid) {
@@ -48,10 +108,30 @@ export async function handleWebhookTrigger(
48
108
  }
49
109
  }
50
110
 
51
- const runId = await startWorkflowExecution(workflow, payload, registry);
111
+ // Parse the raw body so downstream nodes can interpolate deep paths
112
+ // (e.g. `{{trigger.message.from}}`). A non-JSON body falls back to the raw
113
+ // string so non-JSON webhooks don't break.
114
+ const triggerData = parseTriggerPayload(payload);
115
+
116
+ const runId = await startWorkflowExecution(workflow, triggerData, registry);
52
117
  return { runId };
53
118
  }
54
119
 
120
+ /**
121
+ * If `payload` is a JSON string, parse and return the resulting value;
122
+ * otherwise return it as-is. Empty / non-JSON strings fall back to the raw
123
+ * value so non-JSON webhooks (text/plain, form-encoded, etc.) still produce
124
+ * a usable workflow run.
125
+ */
126
+ function parseTriggerPayload(payload: unknown): unknown {
127
+ if (typeof payload !== "string" || payload.length === 0) return payload;
128
+ try {
129
+ return JSON.parse(payload);
130
+ } catch {
131
+ return payload;
132
+ }
133
+ }
134
+
55
135
  /**
56
136
  * Handle a schedule trigger: find workflows linked to this schedule and execute them.
57
137
  * Returns an array of workflow run IDs. Empty array means no workflows matched