Skip to content

Commit cef8c4b

Browse files
ericallamclaude
andcommitted
feat(sdk,slack): route webhooks into agents with chat.event, channels, and human-in-the-loop
Stacked on the webhook() SDK branch. `chat.event()` routes verified deliveries that share a key template to one durable session and delivers them to an agent's `onAction` as a typed envelope. Channels (`chat.channels.custom()` and the new `@trigger.dev/slack` package) turn a chat surface into an agent frontend: inbound messages run as turns, the reply posts back, and a tool without `execute` pauses the turn on human approval controls. The chat.agent test harness gains a recording channel connector. Docs cover session routing, channels, and human-in-the-loop, and the ai-chat reference documents the new `events` and `channels` options. Rebased onto main's transcript-storage and input-router work: interaction hydration goes through `loadContextHook`, a channel record is never steered mid-turn by the pending-messages observer, and a dropped channel delivery (stale interaction or unknown connector) takes the no-op turn path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PPzBQgFgqD8aPQcEYZXty
1 parent a46f330 commit cef8c4b

28 files changed

Lines changed: 3310 additions & 25 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
"@trigger.dev/slack": minor
4+
"trigger.dev": minor
5+
---
6+
7+
Route hosted webhooks into agents, and turn chat surfaces into agent frontends.
8+
9+
- `chat.event({ source, key, type })` routes deliveries that share a `key` to one durable session (per customer, installation, or issue) and delivers them to an agent's `onAction` as a typed envelope.
10+
- Channels turn a chat surface into an agent frontend: `chat.channels.custom({ source, key, inbound, send })`, or the new `@trigger.dev/slack` package's `slack()` (Slack Events API verification, per-thread sessions, `chat.postMessage`/`chat.update` egress, `mentions()`, `startOn`, lifecycle reactions). Inbound messages run as turns and the reply posts back.
11+
- Human-in-the-loop is built in: a tool with no `execute` pauses the turn, the connector posts controls (Slack ships Approve / Deny buttons), and a verified click resolves the tool and resumes the run.
12+
- `chat.createWatchToken(externalId)` mints a read-only token to watch a session from another surface.
13+
- The CLI warns about a `chat.event` no agent lists.

docs/ai-chat/backend.mdx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,37 @@ Custom actions let the frontend send structured commands (undo, rollback, edit,
497497

498498
See [Actions](/ai-chat/actions).
499499

500+
### Webhook events and channels
501+
502+
Two `chat.agent()` options wire an agent to verified inbound webhooks. `events` claims [`chat.event(...)`](/webhooks/session-routing) descriptors: each verified delivery is routed to this agent's session and arrives at `onAction` as an action (not a turn), so [session routing](/webhooks/session-routing) decides which conversation it lands on. `channels` claims channel connectors that turn an external chat surface into a frontend for the agent: an inbound message runs as a turn through `run()` and the reply is posted back. `slack()` ships in `@trigger.dev/slack`, and `chat.channels.custom(...)` builds a connector for any source without a preset.
503+
504+
```ts
505+
import { webhooks } from "@trigger.dev/sdk";
506+
import { chat } from "@trigger.dev/sdk/ai";
507+
import { slack } from "@trigger.dev/slack";
508+
509+
export const orderEvents = chat.event({
510+
id: "order-events",
511+
source: webhooks.stripe(),
512+
key: "{body.data.object.customer}",
513+
type: "order.event",
514+
});
515+
516+
export const myChat = chat.agent({
517+
id: "my-chat",
518+
events: [orderEvents],
519+
channels: [slack({ id: "support-slack", token: process.env.SLACK_BOT_TOKEN! })],
520+
onAction: async ({ action }) => {
521+
// A verified order-events delivery arrives here as an action.
522+
},
523+
run: async (payload) => {
524+
// Inbound Slack messages run here as normal turns.
525+
},
526+
});
527+
```
528+
529+
See [session routing](/webhooks/session-routing) and [channels](/webhooks/channels). For the interactive approvals layer, where a turn pauses on a human decision (buttons in the thread) and resumes on the click, see [human-in-the-loop](/webhooks/human-in-the-loop).
530+
500531
### Chat history
501532

502533
Imperative API for reading and modifying the accumulated message history. Works from any hook (`onAction`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `hydrateMessages`) or from `run()` and AI SDK tools.

docs/ai-chat/reference.mdx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ Options for `chat.agent()`.
4848
| `hydrateMessages` | `(event: HydrateMessagesEvent) => UIMessage[] \| Promise<UIMessage[]>` || **Deprecated.** Load message history from backend, replacing the linear accumulator. Use `loadContext` on a `storage` instead; cannot be combined with `storage`. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages) |
4949
| `actionSchema` | `TaskSchema` || Schema for validating custom actions sent via `transport.sendAction()`. See [Actions](/ai-chat/actions) |
5050
| `onAction` | `(event: ActionEvent) => Promise<void \| ActionTurn> \| void \| ActionTurn` || Handle custom actions. Actions are state edits: only `hydrateMessages` (or a storage's `loadContext`) + `onAction` fire. Return `chat.turn()` to run a turn on the edited history, or nothing for an edit only. See [Actions](/ai-chat/actions) |
51+
| `events` | `ChatEvent[]` || Webhook event descriptors (from `chat.event()`) whose verified deliveries are routed to this agent as actions and handled in `onAction`. See [session routing](/webhooks/session-routing). |
52+
| `channels` | `ChannelConnector[]` || Channel connectors (for example `slack()`) that turn an external chat surface into a frontend for the agent: inbound messages run as turns and the reply posts back. See [channels](/webhooks/channels). |
5153
| `onTurnStart` | `(event: TurnStartEvent) => Promise<void> \| void` || Fires every turn before `run()` |
5254
| `onBeforeTurnComplete` | `(event: BeforeTurnCompleteEvent) => Promise<void> \| void` || Fires after response but before stream closes. Includes `writer`. |
5355
| `onTurnComplete` | `(event: TurnCompleteEvent) => Promise<void> \| void` || Fires after each turn completes (stream closed) |
@@ -536,6 +538,8 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
536538
| Method | Description |
537539
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
538540
| `chat.agent(options)` | Create a chat agent |
541+
| `chat.event(options)` | Declare an inbound webhook event descriptor an agent claims via `chat.agent({ events })`. See [session routing](/webhooks/session-routing). |
542+
| `chat.channels.custom(options)` | Create a generic chat-frontend channel over any verified webhook source (you supply the egress). The `slack()` preset ships in `@trigger.dev/slack`. See [channels](/webhooks/channels). |
539543
| `chat.createSession(payload, options)` | Create an async iterator for chat turns |
540544
| `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) |
541545
| `chat.pipeAndCapture(source, options?)` | Pipe and capture the response; returns `{ message, status, error }` |
@@ -568,6 +572,42 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
568572
| `chat.withUIMessage(config?)` | Returns a [ChatBuilder](/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. See [Types](/ai-chat/types) |
569573
| `chat.withClientData({ schema })` | Returns a [ChatBuilder](/ai-chat/types#chatbuilder) with a fixed client data schema. See [Types](/ai-chat/types#typed-client-data-with-chatwithclientdata) |
570574

575+
## `chat.event`
576+
577+
Declare an inbound webhook event that an agent claims via [`events`](#chatagentoptions) on `chat.agent()`. It is a descriptor only, with no handler: it names a [source](/webhooks/sources) to verify, a `key` template that resolves each delivery to a durable [session](/ai-chat/sessions), and an optional `type` label (defaults to the descriptor `id`). Verified deliveries are routed to that session and arrive at `onAction` as a `{ type, event, source, headers, deliveryId }` envelope, not as a chat turn. See [session routing](/webhooks/session-routing).
578+
579+
```ts
580+
import { webhooks } from "@trigger.dev/sdk";
581+
import { chat } from "@trigger.dev/sdk/ai";
582+
583+
export const orderEvents = chat.event({
584+
id: "order-events",
585+
source: webhooks.stripe(),
586+
key: "{body.data.object.customer}",
587+
type: "order.event",
588+
});
589+
```
590+
591+
## `chat.channels.custom`
592+
593+
Create a generic chat-frontend channel over any verified [source](/webhooks/sources), claimed via [`channels`](#chatagentoptions) on `chat.agent()`. You supply the session `key`, the `inbound` map from event to turn message, and your own `send` egress that posts the reply back, so the whole round-trip is under your control. Inbound messages run as normal turns and the reply is posted back. The `slack()` preset ships in `@trigger.dev/slack` and wires the egress for you. See [channels](/webhooks/channels), and the interactive approvals layer at [human-in-the-loop](/webhooks/human-in-the-loop).
594+
595+
```ts
596+
import { webhooks } from "@trigger.dev/sdk";
597+
import { chat } from "@trigger.dev/sdk/ai";
598+
599+
export const mySurface = chat.channels.custom({
600+
id: "my-surface",
601+
source: webhooks.custom<MyEvent>({ /* verifier config */ }),
602+
key: "{body.conversationId}",
603+
inbound: (event) => event.text,
604+
send: async (message, ctx) => {
605+
const ref = await postToMySurface(ctx.event, message.text, ctx.previousRef);
606+
return { ref };
607+
},
608+
});
609+
```
610+
571611
## `chat.withUIMessage`
572612

573613
Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. Chain `.withClientData()`, hook methods, and `.agent()`.

docs/docs.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,10 @@
159159
"webhooks/sources",
160160
"webhooks/connect",
161161
"webhooks/deliveries",
162-
"webhooks/filters"
162+
"webhooks/filters",
163+
"webhooks/session-routing",
164+
"webhooks/channels",
165+
"webhooks/human-in-the-loop"
163166
]
164167
},
165168
{

docs/webhooks/channels.mdx

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
---
2+
title: "Channels (chat frontends)"
3+
description: "Point Slack (or any chat surface) at an agent: messages become turns and replies post back."
4+
sidebarTitle: "Channels"
5+
---
6+
7+
A [session route](/webhooks/session-routing) delivers a verified event to an agent as an [action](/ai-chat/actions): the agent reacts, and the response is a side effect. A **channel** is the other half: the webhook IS the chat surface. Inbound messages become **turns** (the normal `run()` loop), and the agent's reply is posted **back** to the surface. A Slack thread becomes a real conversation with the agent, exactly like the browser chat, just a different frontend.
8+
9+
List channels on a [`chat.agent`](/ai-chat/overview) alongside (or instead of) `events`:
10+
11+
```ts
12+
import { chat } from "@trigger.dev/sdk/ai";
13+
import { slack } from "@trigger.dev/slack";
14+
import { streamText } from "ai";
15+
import { anthropic } from "@ai-sdk/anthropic";
16+
17+
export const supportAgent = chat.agent({
18+
id: "support-agent",
19+
channels: [slack({ id: "support-slack", token: process.env.SLACK_BOT_TOKEN! })],
20+
run: async ({ messages }) => streamText({ model: anthropic("claude-sonnet-4-5"), messages }),
21+
});
22+
```
23+
24+
The `run()` loop is unchanged: the agent does not know or care that it is talking to Slack. One verified Slack message in a thread is routed to a durable [session](/ai-chat/sessions) keyed to that thread, run as a turn, and the reply is posted into the thread.
25+
26+
## Slack
27+
28+
`slack()` (from `@trigger.dev/slack`) is a channel connector: it verifies inbound Slack events, maps a message to the turn, and posts the reply back with `chat.postMessage` / `chat.update`.
29+
30+
<Steps>
31+
<Step title="Create a Slack app">
32+
Create an app at [api.slack.com/apps](https://api.slack.com/apps). Add the `chat:write` and `channels:history` bot scopes (`chat:write` posts replies; `channels:history` receives the `message.channels` events), then install it to your workspace to get a bot token (`xoxb-...`). If you add scopes after installing, reinstall the app to apply them.
33+
</Step>
34+
<Step title="Deploy the agent + connect the endpoint">
35+
Deploying registers a hosted [endpoint](/webhooks/connect) for the channel. Set its signing secret to your Slack app's **Signing Secret**, and pass the bot token as `token`.
36+
</Step>
37+
<Step title="Subscribe to events">
38+
In the app's **Event Subscriptions**, set the request URL to the endpoint's webhook URL. Slack sends a one-time `url_verification` handshake, which the endpoint answers automatically. Subscribe the bot to `message.channels`, then invite the bot to the channel (`/invite @yourapp`).
39+
</Step>
40+
</Steps>
41+
42+
By default `slack()` keys one session per thread, strips the leading bot mention from the message, posts an "on it..." placeholder while the agent works, and edits it to the answer. Override any of that:
43+
44+
```ts
45+
slack({
46+
id: "support-slack",
47+
token: process.env.SLACK_BOT_TOKEN!,
48+
// ignore anything but questions (composed with the built-in self-message guard)
49+
filter: "event.event.text contains '?'",
50+
inbound: (e) => e.event?.text ?? "",
51+
outbound: (reply) => ({ text: reply.text }),
52+
ack: (e) => ({ text: "thinking..." }), // pass `null` to post only the final answer
53+
});
54+
```
55+
56+
<Note>
57+
`slack()` always drops the bot's own messages (and their edits) before they reach the agent, so the
58+
agent never replies to itself. A multi-workspace app can pass a `token` resolver keyed on the event's
59+
team instead of a single string.
60+
</Note>
61+
62+
### Summoning with a mention
63+
64+
By default `slack()` starts (or resumes) a session for every non-bot message in a subscribed channel. To make the agent respond only when it is @mentioned, pass `startOn` with the `mentions` helper. The first mention in a thread starts the session, and the agent then follows the rest of the thread without needing to be mentioned again.
65+
66+
```ts
67+
import { slack, mentions } from "@trigger.dev/slack";
68+
69+
slack({
70+
id: "support-slack",
71+
token: process.env.SLACK_BOT_TOKEN!,
72+
startOn: mentions("U012BOT"), // your bot's user id (pass several for multiple bots)
73+
});
74+
```
75+
76+
### Reacting to messages
77+
78+
`slack()` can add an emoji reaction to the triggering message to signal progress. Set `reactions` with any of `working`, `done`, and `error`: the connector adds `working` when the turn starts, swaps it to `done` when the turn finishes, and reacts with `error` if it fails. This needs the `reactions:write` scope.
79+
80+
```ts
81+
slack({
82+
id: "support-slack",
83+
token: process.env.SLACK_BOT_TOKEN!,
84+
reactions: { working: "eyes", done: "white_check_mark", error: "warning" },
85+
});
86+
```
87+
88+
### Options
89+
90+
| Option | Type | Description |
91+
| --- | --- | --- |
92+
| `id` | `string` | Connector id, unique per agent. |
93+
| `token` | `string` or resolver | Bot token (`xoxb-...`), or a function of the event's team for multi-workspace apps. |
94+
| `key` | `string` | Session [key](/webhooks/session-routing) template. Defaults to one session per thread. |
95+
| `filter` | `string` | Extra [filter](/webhooks/filters), composed with the built-in self-message guard. |
96+
| `startOn` | `string` | Only start a session when the event matches (see `mentions`). Existing sessions always resume. |
97+
| `ack` | message, `null`, or function | Placeholder posted while the agent works. Pass `null` to post only the final answer. |
98+
| `reactions` | `{ working?, done?, error? }` | Lifecycle emoji reactions on the triggering message. |
99+
| `inbound` / `outbound` | functions | Map the Slack event to the turn, and the reply to a Slack message. |
100+
| `delivery` | `"final"` or `"stream"` | `"final"` (default) posts a placeholder and edits it to the answer. `"stream"` edits live as the reply streams. |
101+
| `apiBaseUrl` | `string` | Override the Slack Web API base, for testing against a mock. |
102+
103+
## Approvals and interactive controls
104+
105+
An agent on a channel can pause a turn to get a human decision, approving a refund or confirming a deletion, and resume once someone clicks a button in the thread. `slack()` renders Approve / Deny buttons for you and collapses them to the decision once clicked. See [human-in-the-loop](/webhooks/human-in-the-loop).
106+
107+
## Any surface: `chat.channels.custom`
108+
109+
For a surface without a preset, `chat.channels.custom` is the generic connector. You supply the [source](/webhooks/sources) to verify, the session `key`, the `inbound` map, and the egress `send`:
110+
111+
```ts
112+
import { chat } from "@trigger.dev/sdk/ai";
113+
import { webhooks } from "@trigger.dev/sdk";
114+
115+
const mySurface = chat.channels.custom({
116+
id: "my-surface",
117+
source: webhooks.custom<MyEvent>({ /* verifier config */ }),
118+
key: "{body.conversationId}",
119+
inbound: (e) => e.text,
120+
outbound: (reply) => (reply.text ? { text: reply.text } : null), // null posts nothing
121+
send: async (message, ctx) => {
122+
const ref = await postToMySurface(ctx.event, message.text, ctx.previousRef);
123+
return { ref }; // an existing ref means edit-in-place on the next turn
124+
},
125+
});
126+
```
127+
128+
`send` is called to post the reply. `ctx.previousRef` is the ref you returned last time, so streaming or a follow-up edits the same message instead of posting a new one. Return `null` from `outbound` to stay silent (a tool-only turn, say).
129+
130+
## Channels vs events
131+
132+
Both are inbound surfaces on a `chat.agent`, and an agent can list both:
133+
134+
- [`events`](/webhooks/session-routing) (`chat.event`): the webhook is a signal. Delivered to `onAction`; the agent acts, no reply is sent back.
135+
- `channels` (`slack`, `chat.channels.custom`): the webhook is a chat frontend. Delivered as a turn to `run()`; the reply is posted back.

docs/webhooks/filters.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description: "Gate which verified webhook deliveries run, with a type-safe filte
44
sidebarTitle: "Filters"
55
---
66

7-
By default every verified delivery runs your `onEvent`. A **filter** is a server-side predicate that decides whether a delivery is routed at all. A delivery that does not match is still received and recorded, it just does not run anything.
7+
By default every verified delivery runs your `onEvent` (or routes to a [session](/webhooks/session-routing)). A **filter** is a server-side predicate that decides whether a delivery is routed at all. A delivery that does not match is still received and recorded, it just does not run anything.
88

99
Filtering happens at the endpoint, before any run is triggered, so a filtered-out event costs you nothing.
1010

@@ -49,6 +49,8 @@ A path reads from one of three namespaces:
4949
- `header.*`: an inbound request header, matched case-insensitively, for example `header.x-github-event`.
5050
- `webhook.*`: endpoint metadata (`webhook.source`, `webhook.id`, `webhook.deliveryId`, and for per-tenant endpoints `webhook.externalRef` / `webhook.tenantId`).
5151

52+
The [session routing](/webhooks/session-routing) key template addresses this same parsed body, but spells it `{body.*}` rather than `event.*`.
53+
5254
### Operators
5355

5456
| Operator | Meaning |
@@ -93,3 +95,7 @@ export const onGithub = webhook({
9395
onEvent: async ({ event }) => {},
9496
});
9597
```
98+
99+
## Filtering a session route
100+
101+
A `filter` works the same way on [`chat.event`](/webhooks/session-routing): a non-matching delivery is recorded `FILTERED` and never reaches the session.

0 commit comments

Comments
 (0)