Skip to content

Commit e392c23

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
docs(webhooks): retry dedupe per source, null presence checks, webhook() task options, failed-turn line
Webhooks docs: which sources deduplicate provider retries, `null` presence checks in filters, task options on `webhook()`, the message channels show when an agent turn fails, the now-required `startOn` for Slack agents, and a changelog for the webhooks beta builds. Mono-RevId: 96d44314c377372ee1d26c7a101747498c395295
1 parent 68e906f commit e392c23

10 files changed

Lines changed: 167 additions & 26 deletions

File tree

‎docs/ai-chat/backend.mdx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ Two `chat.agent()` options wire an agent to verified inbound webhooks. `events`
508508
```ts
509509
import { webhooks } from "@trigger.dev/sdk";
510510
import { chat } from "@trigger.dev/sdk/ai";
511-
import { slack, webhookSource } from "@trigger.dev/slack";
511+
import { appMentions, slack, webhookSource } from "@trigger.dev/slack";
512512

513513
const slackEvents = webhooks.endpoint.define({ id: "slack", source: webhookSource() });
514514
const stripe = webhooks.endpoint.define({ id: "stripe", source: webhooks.stripe() });
@@ -525,7 +525,14 @@ export const orderEvents = chat.event({
525525
export const myChat = chat.agent({
526526
id: "my-chat",
527527
events: [orderEvents],
528-
channels: [slack({ id: "support-slack", endpoint: slackEvents, token: process.env.SLACK_BOT_TOKEN! })],
528+
channels: [
529+
slack({
530+
id: "support-slack",
531+
endpoint: slackEvents,
532+
token: process.env.SLACK_BOT_TOKEN!,
533+
startOn: appMentions(),
534+
}),
535+
],
529536
onAction: async ({ action }) => {
530537
// A verified order-events delivery arrives here as an action.
531538
},

‎docs/webhooks/channels.mdx‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,21 @@ List channels on a [`chat.agent`](/ai-chat/overview) alongside (or instead of) `
1515
```ts
1616
import { webhooks } from "@trigger.dev/sdk";
1717
import { chat } from "@trigger.dev/sdk/ai";
18-
import { slack, webhookSource } from "@trigger.dev/slack";
18+
import { appMentions, slack, webhookSource } from "@trigger.dev/slack";
1919
import { anthropic } from "@ai-sdk/anthropic";
2020

2121
const slackEvents = webhooks.endpoint.define({ id: "slack", source: webhookSource() });
2222

2323
export const supportAgent = chat.agent({
2424
id: "support-agent",
25-
channels: [slack({ id: "support-slack", endpoint: slackEvents, token: process.env.SLACK_BOT_TOKEN! })],
25+
channels: [
26+
slack({
27+
id: "support-slack",
28+
endpoint: slackEvents,
29+
token: process.env.SLACK_BOT_TOKEN!,
30+
startOn: appMentions(),
31+
}),
32+
],
2633
run: async ({ messages, streamText }) =>
2734
streamText({ model: anthropic("claude-sonnet-4-5"), messages }),
2835
});
@@ -74,6 +81,25 @@ An event that `inbound` maps to an empty message (no text, and no file or data p
7481

7582
List the connector in `chat.agent({ channels: [mySurface] })`, which subscribes the agent to the endpoint with the id `agentId:connectorId`. Other subscribers on the endpoint keep receiving its deliveries.
7683

84+
## When a turn fails
85+
86+
When a turn fails (the model or provider returns an error, or the turn throws), the channel shows a short line instead of the error: "Something went wrong answering this. Try again." by default, or the connector's `errorMessage`. If the reply had partly streamed, the line goes after that text. The error is logged on the run, and `outbound` receives it as `reply.error`, so you can render a failure differently.
87+
88+
`errorMessage` is a fixed string, or a function that builds the line from the failure. It receives the `error`, the `partialText` streamed before it failed, the triggering `event` (undefined on a turn the channel didn't start), the `runId`, the `chatId`, and the `defaultMessage`. Return nothing, or throw, to fall back to the default line.
89+
90+
```ts
91+
slack({
92+
id: "slack",
93+
endpoint: slackEvents,
94+
token: process.env.SLACK_BOT_TOKEN!,
95+
startOn: appMentions(),
96+
errorMessage: ({ error, runId }) =>
97+
error.message.includes("rate limit")
98+
? "I'm getting a lot of requests right now. Try again in a minute."
99+
: `Something went wrong answering this. Try again, or share ref ${runId} with support.`,
100+
});
101+
```
102+
77103
## Stopping a turn from the channel
78104

79105
A connector can map an event to a control with `onControl`. Returning `{ kind: "stop" }` while a turn is running aborts it through the run's `stopSignal`, exactly like the browser's stop button: the partial reply posts with `stopped: true` and the event is consumed instead of running as a turn. `linear()` and `slack()` map their stop buttons this way; a custom connector might map a `/stop` command:
@@ -92,7 +118,7 @@ When nothing is running, the event is handled by `inbound` as an ordinary messag
92118
Two more hooks let a connector mirror the session into a surface that has its own notion of an agent, the way `slack()` does:
93119

94120
- `lifecycle(state, ctx)` reports `working` when a turn starts (and every 50 minutes while it runs), `waiting` when it pauses on a human decision, `idle` when it ends for any reason, and `closed` on `chat.close()`. `ctx.firstTurn` and `ctx.prompt` are there to title a new conversation. Calls are serialized, so an older report never lands after a newer one.
95-
- `stream(ctx)`, with `delivery: "stream"`, returns a per-turn transport `{ text(delta), progress?(event), end(outcome) }`. The runtime feeds it the reply's markdown deltas and tool progress, then calls `end({ stopped, pending, errorText? })` once. Resolve `{ delivered: true }` when the reply reached the surface, and the runtime does not post it again. Call `ctx.opened(ref)` once the surface has created the stream message: the ref is saved with the session while the turn runs, and if the worker dies mid-stream the next one calls the connector's `endInterruptedStream(ref, ctx)` before it answers, so the old stream does not look like it is still being written.
121+
- `stream(ctx)`, with `delivery: "stream"`, returns a per-turn transport `{ text(delta), progress?(event), end(outcome) }`. The runtime feeds it the reply's markdown deltas and tool progress, then calls `end({ stopped, pending, errorText? })` once, with the connector's error line as `errorText` when the turn failed. Resolve `{ delivered: true }` when the reply reached the surface, and the runtime does not post it again. Call `ctx.opened(ref)` once the surface has created the stream message: the ref is saved with the session while the turn runs, and if the worker dies mid-stream the next one calls the connector's `endInterruptedStream(ref, ctx)` before it answers, so the old stream does not look like it is still being written.
96122

97123
## Bound sessions
98124

‎docs/webhooks/deliveries.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,14 @@ Trigger.dev uses the endpoint and idempotency key to deduplicate incoming events
8585

8686
A retry after the delivery window expires can create another delivery record. If the task-run idempotency window is still active, routing returns the existing run instead of starting another one.
8787

88-
When the source maps a provider event ID, that ID becomes the key. For example, Stripe uses the event body's `id`, and GitHub uses the `X-GitHub-Delivery` header.
88+
When the source maps a provider event ID, that ID becomes the key, so the provider's own retries of an event are recognised as duplicates:
89+
90+
| Key | Sources |
91+
| --- | --- |
92+
| A header | GitHub (`X-GitHub-Delivery`), Svix and Autumn (`svix-id`), Standard Webhooks, Loops and Granola (`webhook-id`), Linear (`Linear-Delivery`), PagerDuty (`X-Webhook-Id`), Jira (`X-Atlassian-Webhook-Identifier`), Zendesk (`X-Zendesk-Webhook-Invocation-Id`), Attio (`Idempotency-Key`), Shopify (`X-Shopify-Webhook-Id`) |
93+
| A body field | Stripe, Notion, Vercel, Intercom, WorkOS, Metronome, Mux, Langfuse and Plain (`id`), Square, Slack and Typeform (`event_id`), Firecrawl (`webhookId`) |
94+
95+
The other named sources (Zoom, Calendly, Sentry, DocuSign, ElevenLabs, Vapi, Retell, Tailscale, Infisical, PlanetScale, Supabase, Discord) use the fallback below.
8996

9097
If the source doesn't map an event ID, the key is derived from the raw body, timestamp, and signature. A retry with a different timestamp or signature can therefore produce a different key. For a [custom source](/webhooks/sources#custom-providers), set `idempotencyField` to a stable event identifier when the provider supplies one.
9198

‎docs/webhooks/filters.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ Paths must have non-empty segments. `event.data.amount` is valid; `event.`, `eve
9898

9999
Values are strings in single quotes (`'created'`), numbers (`10000`), booleans (`true`), or `null`. Use a list of these values with `in` and `not in`.
100100

101+
A `null` value checks presence. `event.issue.pull_request != null` matches events that have the field, and `== null` matches events where it is missing or `null`. It works on any field, including fields your event type marks as required, because a missing field compares equal to `null`.
102+
101103
### Comparing two fields
102104

103105
Equality and comparison operators also accept a field path on the right-hand side:

‎docs/webhooks/human-in-the-loop.mdx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Out of the box, `slack()` posts Approve and Deny buttons for any pending no-`exe
4141
```ts trigger/support-agent.ts
4242
import { webhooks } from "@trigger.dev/sdk";
4343
import { chat } from "@trigger.dev/sdk/ai";
44-
import { slack, webhookSource } from "@trigger.dev/slack";
44+
import { appMentions, slack, webhookSource } from "@trigger.dev/slack";
4545
import { tool } from "ai";
4646
import { anthropic } from "@ai-sdk/anthropic";
4747
import { z } from "zod";
@@ -60,7 +60,14 @@ const requestApproval = tool({
6060

6161
export const supportAgent = chat.agent({
6262
id: "support-agent",
63-
channels: [slack({ id: "support-slack", endpoint: slackEvents, token: process.env.SLACK_BOT_TOKEN! })],
63+
channels: [
64+
slack({
65+
id: "support-slack",
66+
endpoint: slackEvents,
67+
token: process.env.SLACK_BOT_TOKEN!,
68+
startOn: appMentions(),
69+
}),
70+
],
6471
tools: { requestApproval },
6572
run: async ({ messages, tools, streamText }) =>
6673
streamText({

‎docs/webhooks/linear.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ linear({ id: "linear", endpoint: linearEvents, token, context: { guidance: false
6767
| Turn starts | An ephemeral `thought` ("Looking into this...") |
6868
| A tool is called | An ephemeral `action` with the tool name and input, then the same action with its `result` |
6969
| The reply is ready | A `response` with the reply as markdown |
70-
| The turn fails | An `error` |
70+
| The turn fails | An `error` saying "Something went wrong answering this. Try again." (`errorMessage` changes it) |
7171
| A tool needs a human decision | An `elicitation` with Approve and Deny options, or the tool's own options from `interactions` (see [Approvals and choices](#approvals-and-choices)) |
7272
| The user picks an option | A `thought` recording the decision ("Approved by Jane.", "Jane chose "High".") |
7373
| A prompt arrives while the agent works | A `thought` ("Got it, I'll take that into account.") as soon as it arrives (see [Prompts sent while the agent is working](#prompts-sent-while-the-agent-is-working)) |
@@ -176,6 +176,7 @@ The first Linear turn binds the session to the agent session ([bound sessions](/
176176
| `approvals` | `{ [toolName]: (input, call) => { title?, body } }` | Custom approval card body per tool. Default lays the tool input out as markdown. |
177177
| `interactions` | `{ [toolName]: (call) => { title?, body, options } }` | A tool's own select card; the chosen option resolves the tool to `{ selected }`. |
178178
| `receivedThought` | `string`, function or `null` | Thought posted as soon as a prompt arrives while the agent is working. Default "Got it, I'll take that into account.", null for none. |
179+
| `errorMessage` | `string \| (ctx) => string` | The error activity's text when a turn fails, fixed or built from the failure ([details](/webhooks/channels#when-a-turn-fails)). Default "Something went wrong answering this. Try again." |
179180
| `ack` | message, `null`, or function | The first thought. Pass `null` to post nothing before the response. |
180181
| `inbound` / `outbound` | functions | Map the event to the turn, and the reply to an activity. |
181182
| `delivery` | `"final"` or `"stream"` | `"final"` (default) posts the response when the turn ends. `"stream"` also posts ephemeral thoughts as the reply streams. |

‎docs/webhooks/overview.mdx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@ Each subscriber has its own [filter](/webhooks/filters), so one delivery can rea
6363
| `endpoint` | The endpoint the delivery arrived on: `id`, `declaredId`, `tenantId`, `externalRef`, and the `metadata` you declared. |
6464
| `ctx` | The same [run context](/context) that regular tasks receive. |
6565

66+
`webhook()` also takes the options a [task](/tasks/overview) does, such as `queue`, `concurrency`, `machine`, `retry`, `maxDuration` and the lifecycle hooks. For example, to handle one delivery at a time:
67+
68+
```ts
69+
export const orders = webhook({
70+
id: "stripe-orders",
71+
endpoint: stripe,
72+
queue: { concurrencyLimit: 1 },
73+
retry: { maxAttempts: 5 },
74+
onEvent: async ({ event }) => {
75+
// ...
76+
},
77+
});
78+
```
79+
80+
The limit covers every delivery the task handles. To process each customer's events in order while different customers run in parallel, route them to an agent with [session routing](/webhooks/session-routing): deliveries with the same key land in one session and are handled one after another.
81+
6682
`webhooks.endpoint.define` also takes a `description` and `metadata`. The metadata is passed to every subscriber as `endpoint.metadata`. The endpoint `id` can't start with `wh_`, which is reserved for the ids Trigger.dev generates.
6783

6884
## How it works

‎docs/webhooks/private-beta.mdx‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,49 @@ pnpm needs 10.17 or later for the `@trigger.dev/*` pattern; on 10.16, list the p
9696

9797
## Stay up to date
9898

99-
Beta versions look like `4.8.0-webhooks.20261015065339`, the next release plus a timestamp. Run the install commands again to move to the newest build. To stay on one build, install that exact version instead of the tag.
99+
Beta versions look like `4.8.0-webhooks.20261020092920`, the next release plus a timestamp. Run the install commands again to move to the newest build. To stay on one build, install that exact version instead of the tag.
100100

101101
When webhooks become generally available they ship in a normal release, and you switch back to `@latest`. You can remove the release age exemption then.
102102

103+
## Changelog
104+
105+
### 4.8.0-webhooks.20261020092920 (October 20)
106+
107+
**Breaking:** `slack()` now requires `startOn`. Without it, every message in every channel the bot was in started a session. `appMentions()` answers when the bot is @mentioned, and needs no bot user id:
108+
109+
```ts
110+
import { appMentions, slack } from "@trigger.dev/slack";
111+
112+
slack({
113+
id: "slack",
114+
endpoint: slackEvents,
115+
token: process.env.SLACK_BOT_TOKEN!,
116+
startOn: appMentions(),
117+
});
118+
```
119+
120+
Add `app_mention` to the app's bot events and the `app_mentions:read` scope, then reinstall the app. See [Slack agents](/webhooks/slack).
121+
122+
New:
123+
124+
- 12 more sources: `webhooks.loops()`, `granola()`, `autumn()`, `metronome()`, `mux()`, `tailscale()`, `langfuse()`, `infisical()`, `planetscale()`, `plain()`, `firecrawl()` and `supabase()`, each with a setup prompt. Supabase database webhooks aren't signed, so `supabase()` checks a bearer token you add as a header.
125+
- Retries from PagerDuty, Jira, Zendesk, Attio, Shopify, Notion, Vercel, Typeform, Intercom, WorkOS and Firecrawl are deduplicated on the provider's own event or delivery id, so a retried event runs once. See [Deliveries](/webhooks/deliveries).
126+
- Filters accept `== null` and `!= null` to check whether a field is present, and `event any ( ... )` matches a payload that is an array of events (Tailscale, SendGrid, HubSpot, Auth0) when any of them matches.
127+
- `webhook()` takes `task()` options such as `queue`, `machine` and `retry`.
128+
- A failed turn in Slack, Linear or a custom channel posts "Something went wrong answering this. Try again." instead of the raw error. `errorMessage` changes it, as a fixed line or a function of the error, run id and event. See [When a turn fails](/webhooks/channels#when-a-turn-fails).
129+
- Slack: tool calls show as one plan in the reply, each task titled by the tool's `title` (`progress: "tasks"` gives one card per call). `ownWorkspaceOnly: true` ignores Slack Connect guests, and `SlackRunSurface` types `chat.surface<SlackRunSurface>()`.
130+
- Dashboard: filter an endpoint's deliveries by status, live or test mode, and delivery id (ours or the provider's).
131+
132+
Fixed:
133+
134+
- Plain and Metronome deliveries verify, with each provider's replay window.
135+
136+
This build also includes what's on `main` since the last one, such as span redaction with `telemetry.spanProcessors`, managing metric dashboards from the API, and `trigger tasks trigger`.
137+
138+
### 4.8.0-webhooks.20261015065339 (October 15)
139+
140+
The first beta build.
141+
103142
## Get started
104143

105144
- [Overview](/webhooks/overview): endpoints, sources, and what can subscribe.

‎docs/webhooks/session-routing.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,14 @@ When the same session is also a [channel](/webhooks/channels) conversation (a Sl
8787
```ts
8888
export const orderAgent = chat.agent({
8989
id: "order-agent",
90-
channels: [slack({ id: "support-slack", endpoint: slackEvents, token: process.env.SLACK_BOT_TOKEN! })],
90+
channels: [
91+
slack({
92+
id: "support-slack",
93+
endpoint: slackEvents,
94+
token: process.env.SLACK_BOT_TOKEN!,
95+
startOn: appMentions(),
96+
}),
97+
],
9198
events: [orderEvents],
9299
onAction: async ({ action }) => {
93100
if (action.type === "order.event") {

0 commit comments

Comments
 (0)