Skip to content

Commit d6955ee

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(sdk,cli,core): shared webhook endpoints, webhook(), chat.event, channels and waiters
Webhooks now attach to shared endpoints. Declare a provider URL once with `webhooks.endpoint.define({ id, source })`, then subscribe `webhook()` tasks, `chat.event` sessions and channel connectors to it with `{ endpoint }`, each with its own filter. Handlers receive the endpoint the delivery arrived on, and a filter like `event.type == 'checkout.session.completed'` narrows a union event such as `Stripe.Event`, so the handler gets the selected event's type without a cast. Runs can wait for a webhook: `webhooks.waiter.create(endpoint, { match, filter, timeout })` registers a waiter that the next matching delivery resumes, `webhooks.waiter.wait()` suspends the run until then, and `webhooks.waiter.cancel()` gives it up. Match paths are type-checked against the endpoint's event, and limit, filter and cancel failures surface as typed errors. A `chat.event` action carries the delivery's `whd_` id as `deliveryId` and the provider's id as `externalDeliveryId`. A custom source can set how the endpoint answers the provider: `webhooks.custom(config, { response: { acceptedBody: "empty" } })` acknowledges with an empty `200`, for providers that show the response to a user. New named sources: `webhooks.loops()`, `webhooks.granola()`, `webhooks.autumn()`, `webhooks.metronome()`, `webhooks.mux()`, `webhooks.tailscale()`, `webhooks.langfuse()`, `webhooks.infisical()`, `webhooks.planetscale()`, `webhooks.plain()`, `webhooks.firecrawl()` and `webhooks.supabase()`. Supabase database webhooks aren't signed, so `supabase()` checks a bearer token you add under the webhook's HTTP headers. Retries from PagerDuty, Jira, Zendesk, Attio, Shopify, Notion, Vercel, Typeform, Intercom, WorkOS and Firecrawl are now deduplicated on the provider's own event or delivery id, so a retried event no longer runs twice. Filters accept `== null` and `!= null` for presence checks, `webhook()` takes `task()` options such as `queue` and `machine`, and a failed channel turn posts a short error line instead of the error text (`errorMessage` sets it, as a fixed line or a function of the error, run id and event). `plain()` and `metronome()` reject deliveries more than five minutes old, matching each provider's own guidance. For providers that batch events into an array payload, a filter can match any of them: `event any ( type == 'nodeCreated' )`. Mono-RevId: 7a60e96ad18a52bfb51e58a0a91885cf143c1e41
1 parent 342c2fc commit d6955ee

78 files changed

Lines changed: 8957 additions & 268 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
"trigger.dev": minor
4+
---
5+
6+
Route verified webhooks into agent sessions with `chat.event({ endpoint, key })`, and connect external chat surfaces with `chat.channels.custom({ endpoint })`. Both subscribe to a shared webhook endpoint, so an agent and your other webhook tasks can use the same provider URL. Channels support reply delivery, session binding, conversation seeding, and lifecycle reactions, and a failed turn posts a short line instead of the error (`errorMessage` sets it, as a string or a function of the failure). Use `chat.createWatchToken()` to watch a session from another surface.
7+
8+
A connector can also stream replies natively (`stream()`), mirror the session's state into the surface (`lifecycle()`), show tool calls as they run (`progress()`), stop a running turn from the surface (`onControl()`), post approvals and tool controls and resume the run from a verified click, download files only the surface can read (`download()`), and acknowledge or fold in messages that arrive mid-turn (`received()`, `pendingMessages`).
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@trigger.dev/core": minor
3+
"@trigger.dev/sdk": minor
4+
"trigger.dev": minor
5+
---
6+
7+
Add hosted webhooks: declare an endpoint once with `webhooks.endpoint.define({ id, source })` and Trigger.dev hosts its URL, verifies every request, and routes each event to the endpoint's subscribers. Subscribe tasks with `webhook({ endpoint, filter, onEvent })`, each with its own type-safe filter, and pause a run until a matching webhook arrives with `webhooks.waiter.create()` and `webhooks.waiter.wait()`.
8+
9+
```ts
10+
export const stripe = webhooks.endpoint.define({ id: "stripe", source: webhooks.stripe() });
11+
12+
export const fulfill = webhook({
13+
id: "fulfill",
14+
endpoint: stripe,
15+
filter: "event.type == 'checkout.session.completed'",
16+
onEvent: async ({ event }) => {},
17+
});
18+
19+
const waiter = await webhooks.waiter.create(stripe, {
20+
match: { "event.data.object.metadata.orderId": order.id },
21+
});
22+
const { event } = await webhooks.waiter.wait(waiter).unwrap();
23+
```
24+
25+
Sources cover presets (`webhooks.stripe()`, `webhooks.github()`, `webhooks.svix()`, `webhooks.discord()`, and others), named providers (`webhooks.clerk()`, `webhooks.shopify()`, and more) and `webhooks.custom<T>(config)`, and most carry a setup prompt an AI coding agent can follow to register the endpoint. Retries are deduplicated on the provider's own event or delivery id where it sends one. Filters, keys and waiter `match` paths share the `event.`, `header.` and `webhook.` namespaces, and filters accept `== null` / `!= null` presence checks and `event any (...)` over a payload that is an array of events. `webhook()` takes `task()` options such as `queue`, `machine` and `retry`. The HTTP API lists endpoints and deliveries, replays deliveries, and creates and cancels waiters, and both it and the MCP server can read an endpoint's setup prompt and generate or store its signing secret.

‎apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
resolveSessionWithWriterFallback,
1111
} from "~/services/realtime/sessions.server";
1212
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
13-
import { stripClientWebhookActionSource } from "~/services/realtime/sanitizeSessionInput.server";
13+
import { stripClientIngressFields } from "~/services/realtime/sanitizeSessionInput.server";
1414
import {
1515
claimSessionStreamPart,
1616
releaseSessionStreamPart,
@@ -150,7 +150,7 @@ const { action, loader } = createActionApiRoute(
150150

151151
let part = await request.text();
152152
if (params.io === "in") {
153-
part = stripClientWebhookActionSource(part);
153+
part = stripClientIngressFields(part);
154154
}
155155

156156
const clientPartId = request.headers.get("X-Part-Id");

‎apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,13 @@ const { action } = createActionApiRoute(
3636
},
3737
},
3838
async ({ params, authentication }) => {
39-
// `.out` is the agent→client channel. Only PRIVATE (secret key) auth —
40-
// i.e. the agent run itself — may initialize it. Session-scoped JWTs carry
41-
// `write:sessions:<key>` for `.in`; without this gate they could obtain
42-
// credentials to forge assistant chunks on their own session's `.out`.
43-
if (params.io === "out" && authentication.type !== "PRIVATE") {
44-
return new Response("Initializing the out channel requires secret key authentication", {
39+
// Only PRIVATE (secret key) auth may initialize a channel: the S2 credentials
40+
// this returns write raw records. Session-scoped JWTs carry
41+
// `write:sessions:<key>`; with them they could forge assistant chunks on
42+
// `.out`, or skip the `.in` append route's removal of ingress-only fields.
43+
// Clients write `.in` through that append route.
44+
if (authentication.type !== "PRIVATE") {
45+
return new Response("Initializing a session channel requires secret key authentication", {
4546
status: 403,
4647
});
4748
}

‎apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.append.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { requireUserId } from "~/services/session.server";
1818
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
1919
import { engine } from "~/v3/runEngine.server";
2020
import { ServiceValidationError } from "~/v3/services/common.server";
21+
import { stripClientIngressFields } from "~/services/realtime/sanitizeSessionInput.server";
2122

2223
const ParamsSchema = z.object({
2324
session: z.string(),
@@ -106,7 +107,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
106107

107108
const addressingKey = canonicalSessionAddressingKey(session, sessionParam);
108109

109-
const part = await request.text();
110+
const body = await request.text();
111+
const part = io === "in" ? stripClientIngressFields(body) : body;
110112
const partId = request.headers.get("X-Part-Id") ?? nanoid(7);
111113

112114
const [appendError, appendSeq] = await tryCatch(
Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
/**
2-
* Strip a client-forged `actionSource: "webhook"` from a session `.in` append part.
2+
* Strip the fields only the hosted webhook ingress may set from a session `.in` append part.
33
*
4-
* Only the hosted webhook ingress may claim webhook trust, and it appends server-side rather than
5-
* through the client append route. A client with session write access could otherwise send a record
6-
* carrying `actionSource: "webhook"`, which the run loop uses to skip action-schema validation. We
7-
* downgrade it here (delete the field) so the record is validated as a normal client action.
4+
* The ingress appends server-side, never through a client append route. A client with session write
5+
* access could otherwise send a record carrying `actionSource: "webhook"`, which the run loop uses to
6+
* skip action-schema validation, or a `channelEvent`, which the run loop treats as a verified provider
7+
* delivery and hands to the channel connector. We delete both, so an action is validated as a normal
8+
* client action and a message is a normal client message.
89
*
9-
* The part is always parsed rather than string-matched: JSON allows the property name to be spelled
10+
* The part is always parsed rather than string-matched: JSON allows a property name to be spelled
1011
* with escapes (`"action\u0053ource"`), which a substring check misses and `JSON.parse` canonicalizes
1112
* to the same key the runtime reads. A part that does not parse is passed through unchanged; the
1213
* runtime rejects it as malformed.
1314
*/
14-
export function stripClientWebhookActionSource(part: string): string {
15+
export function stripClientIngressFields(part: string): string {
1516
let record: unknown;
1617
try {
1718
record = JSON.parse(part);
@@ -20,14 +21,15 @@ export function stripClientWebhookActionSource(part: string): string {
2021
}
2122
if (!record || typeof record !== "object") return part;
2223

23-
const payload = (record as { payload?: unknown }).payload;
24+
const payload = (record as { payload?: unknown }).payload as Record<string, unknown> | undefined;
2425
if (!payload || typeof payload !== "object") return part;
25-
if (!Object.hasOwn(payload, "actionSource")) return part;
2626

27-
if ((payload as { actionSource?: unknown }).actionSource === "webhook") {
28-
delete (payload as { actionSource?: unknown }).actionSource;
29-
return JSON.stringify(record);
30-
}
27+
const forgedWebhookAction =
28+
Object.hasOwn(payload, "actionSource") && payload.actionSource === "webhook";
29+
const forgedChannelEvent = Object.hasOwn(payload, "channelEvent");
30+
if (!forgedWebhookAction && !forgedChannelEvent) return part;
3131

32-
return part;
32+
if (forgedWebhookAction) delete payload.actionSource;
33+
if (forgedChannelEvent) delete payload.channelEvent;
34+
return JSON.stringify(record);
3335
}

‎apps/webapp/test/auth-api.e2e.full.test.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3013,7 +3013,7 @@ describe("API", () => {
30133013
expect(res.status).not.toBe(403);
30143014
});
30153015

3016-
it("PUT with write:sessions (was a superScope): auth passes", async () => {
3016+
it("PUT with write:sessions: authorized, but raw channel credentials need a secret key", async () => {
30173017
const server = getTestServer();
30183018
const seed = await seedTestEnvironment(server.prisma);
30193019
const session = await seedTestApiSession(server.prisma, seed.environment);
@@ -3022,6 +3022,20 @@ describe("API", () => {
30223022
method: "PUT",
30233023
headers: { Authorization: `Bearer ${jwt}` },
30243024
});
3025+
expect(res.status).toBe(403);
3026+
expect(await res.text()).toBe(
3027+
"Initializing a session channel requires secret key authentication"
3028+
);
3029+
});
3030+
3031+
it("PUT with a secret key: passes", async () => {
3032+
const server = getTestServer();
3033+
const seed = await seedTestEnvironment(server.prisma);
3034+
const session = await seedTestApiSession(server.prisma, seed.environment);
3035+
const res = await server.webapp.fetch(ioPath(session.friendlyId, "in"), {
3036+
method: "PUT",
3037+
headers: { Authorization: `Bearer ${seed.apiKey}` },
3038+
});
30253039
expect(res.status).not.toBe(401);
30263040
expect(res.status).not.toBe(403);
30273041
});
Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { describe, expect, it } from "vitest";
2-
import { stripClientWebhookActionSource } from "~/services/realtime/sanitizeSessionInput.server";
2+
import { stripClientIngressFields } from "~/services/realtime/sanitizeSessionInput.server";
33

44
const record = (payload: Record<string, unknown>) => JSON.stringify({ kind: "message", payload });
55

6-
describe("stripClientWebhookActionSource", () => {
6+
describe("stripClientIngressFields", () => {
77
it("removes a client-forged webhook actionSource so the action is validated normally", () => {
88
const forged = record({
99
chatId: "c1",
@@ -12,33 +12,55 @@ describe("stripClientWebhookActionSource", () => {
1212
action: { type: "refund", amount: 9999 },
1313
});
1414

15-
const cleaned = JSON.parse(stripClientWebhookActionSource(forged));
15+
const cleaned = JSON.parse(stripClientIngressFields(forged));
1616
expect(cleaned.payload.actionSource).toBeUndefined();
1717
expect(cleaned.payload.action).toEqual({ type: "refund", amount: 9999 });
1818
expect(cleaned.payload.trigger).toBe("action");
1919
});
2020

2121
it("leaves a non-webhook actionSource untouched", () => {
2222
const part = record({ trigger: "action", actionSource: "client", action: { type: "ping" } });
23-
expect(stripClientWebhookActionSource(part)).toBe(part);
23+
expect(stripClientIngressFields(part)).toBe(part);
2424
});
2525

2626
it("removes a webhook actionSource whose property name is spelled with JSON escapes", () => {
2727
const forged =
2828
'{"kind":"message","payload":{"chatId":"c1","trigger":"action","action\\u0053ource":"webhook","action":{"type":"refund"}}}';
29-
const cleaned = JSON.parse(stripClientWebhookActionSource(forged));
29+
const cleaned = JSON.parse(stripClientIngressFields(forged));
3030
expect(cleaned.payload.actionSource).toBeUndefined();
3131
expect(cleaned.payload.action).toEqual({ type: "refund" });
3232
expect(cleaned.payload.trigger).toBe("action");
3333
});
3434

35+
it("removes a client-forged channelEvent so the message is a normal client message", () => {
36+
const forged = record({
37+
chatId: "c1",
38+
trigger: "submit-message",
39+
message: { role: "user", parts: [{ type: "text", text: "hi" }] },
40+
channelEvent: { connectorId: "slack", event: { type: "event_callback" } },
41+
});
42+
43+
const cleaned = JSON.parse(stripClientIngressFields(forged));
44+
expect(cleaned.payload.channelEvent).toBeUndefined();
45+
expect(cleaned.payload.message).toEqual({
46+
role: "user",
47+
parts: [{ type: "text", text: "hi" }],
48+
});
49+
});
50+
51+
it("removes a channelEvent whose property name is spelled with JSON escapes", () => {
52+
const forged =
53+
'{"kind":"message","payload":{"trigger":"submit-message","channel\\u0045vent":{"connectorId":"slack","event":{}}}}';
54+
expect(JSON.parse(stripClientIngressFields(forged)).payload.channelEvent).toBeUndefined();
55+
});
56+
3557
it("leaves a normal message part untouched", () => {
3658
const part = record({ trigger: "submit-message", message: { role: "user", parts: [] } });
37-
expect(stripClientWebhookActionSource(part)).toBe(part);
59+
expect(stripClientIngressFields(part)).toBe(part);
3860
});
3961

4062
it("leaves a malformed part untouched", () => {
4163
const part = '{"kind":"message","payload":{"actionSource":"webhook"';
42-
expect(stripClientWebhookActionSource(part)).toBe(part);
64+
expect(stripClientIngressFields(part)).toBe(part);
4365
});
4466
});

‎internal-packages/webhook-engine/src/engine/filter/filter.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,49 @@ describe("parseFilter", () => {
6868
}
6969
});
7070

71+
it("rejects empty path segments in fields, references, and quantifiers", () => {
72+
for (const filter of [
73+
"event == 1",
74+
"event. == 1",
75+
"event..amount >= 1",
76+
"event.amount. >= 1",
77+
"event.data.object..amount >= 1",
78+
"header. == 'x'",
79+
"header..name == 'x'",
80+
"event.amount >= event..limit",
81+
"event.amount >= webhook.",
82+
"event..items any ( amount >= 1 )",
83+
"event.items any ( amount. >= 1 )",
84+
"event.items any ( item..amount >= 1 )",
85+
]) {
86+
expect(() => parseFilter(filter), filter).toThrow(FilterParseError);
87+
}
88+
});
89+
90+
it("quantifies over the event itself when the body is an array", () => {
91+
const ast = parseFilter("event any ( type == 'nodeCreated' )");
92+
expect(ast).toEqual({
93+
kind: "quantifier",
94+
mode: "any",
95+
path: "event",
96+
clause: { path: "type", op: "eq", value: "nodeCreated" },
97+
});
98+
const event = [{ type: "nodeNeedsApproval" }, { type: "nodeCreated" }];
99+
const ctx = { event, headers: {}, webhook: {} };
100+
expect(evaluateFilter(ast, ctx).match).toBe(true);
101+
expect(evaluateFilter(parseFilter("event all ( type == 'nodeCreated' )"), ctx).match).toBe(
102+
false
103+
);
104+
expect(evaluateFilter(ast, { ...ctx, event: { type: "nodeCreated" } }).match).toBe(false);
105+
for (const filter of [
106+
"event == 'x'",
107+
"header any ( type == 'x' )",
108+
"webhook any ( id == 'x' )",
109+
]) {
110+
expect(() => parseFilter(filter), filter).toThrow(FilterParseError);
111+
}
112+
});
113+
71114
it("enforces the soft clause cap", () => {
72115
const big = Array.from({ length: 60 }, () => "event.action == 'created'").join(" && ");
73116
expect(() => parseFilter(big)).toThrow(/too complex/);

‎internal-packages/webhook-engine/src/engine/filter/parse.ts‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ const WORD_OPS: Record<string, FilterOp> = {
2121
};
2222
const NAMESPACES = new Set(["event", "header", "webhook"]);
2323

24+
function validatePath(path: string, namespaced = true): void {
25+
const segments = path.split(".");
26+
if (segments.some((segment) => segment.length === 0) || (namespaced && segments.length < 2)) {
27+
throw new FilterParseError(`invalid field path "${path}"`);
28+
}
29+
if (namespaced && !NAMESPACES.has(segments[0])) {
30+
throw new FilterParseError(`field must start with event./header./webhook., got "${path}"`);
31+
}
32+
}
33+
2434
type Tok = {
2535
t:
2636
| "lparen"
@@ -148,24 +158,24 @@ export function parseFilter(input: string): FilterAst {
148158
const pathTok = next();
149159
if (!pathTok || pathTok.t !== "word") throw new FilterParseError("expected a field path");
150160
const path = pathTok.v;
151-
if (!NAMESPACES.has(path.split(".")[0])) {
152-
throw new FilterParseError(`field must start with event./header./webhook., got "${path}"`);
153-
}
154161
// Quantifier: `arrPath any|all ( subPath op value )` — sub-clause resolves against each element.
155162
const q = peek();
156-
if (q?.t === "word" && (q.v === "any" || q.v === "all")) {
163+
const mode = q?.t === "word" && (q.v === "any" || q.v === "all") ? q.v : undefined;
164+
if (!(mode && path === "event")) validatePath(path);
165+
if (mode) {
157166
next();
158-
if (next()?.t !== "lparen") throw new FilterParseError(`expected "(" after "${q.v}"`);
167+
if (next()?.t !== "lparen") throw new FilterParseError(`expected "(" after "${mode}"`);
159168
const subPathTok = next();
160169
if (!subPathTok || subPathTok.t !== "word") {
161170
throw new FilterParseError("expected a field path inside the quantifier");
162171
}
172+
validatePath(subPathTok.v, false);
163173
const subOp = parseOp(subPathTok.v);
164174
const subValue = parseScalar();
165175
if (next()?.t !== "rparen") throw new FilterParseError('expected ")"');
166176
return {
167177
kind: "quantifier",
168-
mode: q.v,
178+
mode,
169179
path,
170180
clause: { path: subPathTok.v, op: subOp, value: subValue },
171181
};
@@ -175,6 +185,7 @@ export function parseFilter(input: string): FilterAst {
175185
const operand = peek();
176186
if (operand?.t === "word" && NAMESPACES.has(operand.v.split(".")[0])) {
177187
next();
188+
validatePath(operand.v);
178189
return { kind: "clause", path, op, valueRef: operand.v };
179190
}
180191
return { kind: "clause", path, op, value: parseValue() };

0 commit comments

Comments
 (0)