Skip to content

Commit b927b15

Browse files
committed
feat(slack): @trigger.dev/slack channel provider
Ship Slack as a real chat frontend for agents. `slack({ token, ... })` (from the new @trigger.dev/slack package) verifies inbound Slack events, routes a thread's messages to a durable session, runs them as turns, and posts the reply back with chat.postMessage then chat.update. Package boundary: the branded provider ships as its own package and is imported by name (chat.agent({ channels: [slack({...})] })), keeping Slack-specific HTTP + token handling out of the core SDK and setting the pattern for future providers. The SDK keeps the channels vocabulary (the channels option, the connector shape, chat.channels.custom); the inbound-only chat.channels.slack stub moves into the package. slack() supplies: the Slack v0 HMAC verifier + the url_verification handshake, a per-thread key with a {thread_ts || ts} fallback so thread-starting messages don't drop, the real chat.postMessage/update egress (bot token, re-resolve on auth failure, surfaced not_in_channel), and a mandatory self-message filter (drops bot posts + their message_changed edits, and dedupes app_mention) so the agent never replies to itself. token accepts a string or an event-keyed resolver for multi-workspace. Unreleased feature, so no released-API impact.
1 parent bfb2c33 commit b927b15

12 files changed

Lines changed: 784 additions & 131 deletions

File tree

.changeset/webhook-channels.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
"@trigger.dev/slack": patch
5+
"trigger.dev": patch
6+
---
7+
8+
Add channels: chat frontends for agents. A `chat.agent` can now take `channels: [...]` alongside `events: [...]`. A channel is a bidirectional chat surface (Slack, etc.): a verified inbound event is routed to a durable per-key session and run as a turn (via the connector's `inbound()` mapper), and the agent's reply is posted back to the surface (`outbound()` + `send()`), so a thread is a real conversation with the agent. Egress runs in-run; "final" mode posts an ack placeholder at turn start and edits it to the answer at turn complete.
9+
10+
`chat.channels.custom({ source, key, inbound, outbound, send, ack, filter, delivery })` is the generic connector (bring your own source and egress). The new `@trigger.dev/slack` package ships `slack({ token, ... })`: the Slack Events API verifier, the `url_verification` handshake, a per-thread session key (with a `thread_ts || ts` fallback for thread starts), the real `chat.postMessage`/`chat.update` egress, and a mandatory self-message filter so the bot never replies to its own posts.
11+
12+
Two supporting engine capabilities landed generically: session key templates gain a first-non-empty fallback operator (`{a || b}`), and verifier artifacts can declare a synchronous handshake (used for Slack `url_verification`, reusable for Discord PING).

docs/docs.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@
5959
"webhooks/connect",
6060
"webhooks/deliveries",
6161
"webhooks/filters",
62-
"webhooks/session-routing"
62+
"webhooks/session-routing",
63+
"webhooks/channels"
6364
]
6465
},
6566
{

docs/webhooks/channels.mdx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
15+
export const supportAgent = chat.agent({
16+
id: "support-agent",
17+
channels: [slack({ id: "support-slack", token: process.env.SLACK_BOT_TOKEN! })],
18+
run: async ({ messages }) => streamText({ model: anthropic("claude-sonnet-4-5"), messages }),
19+
});
20+
```
21+
22+
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.
23+
24+
## Slack
25+
26+
`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`.
27+
28+
<Steps>
29+
<Step title="Create a Slack app">
30+
Create an app at [api.slack.com/apps](https://api.slack.com/apps). Add the `chat:write` bot scope and install it to your workspace to get a bot token (`xoxb-...`).
31+
</Step>
32+
<Step title="Deploy the agent + connect the endpoint">
33+
Deploying registers a hosted [ingress endpoint](/webhooks/connect) for the channel. Set its signing secret to your Slack app's **Signing Secret**, and pass the bot token as `token`.
34+
</Step>
35+
<Step title="Subscribe to events">
36+
In the app's **Event Subscriptions**, set the request URL to the endpoint's ingress 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`).
37+
</Step>
38+
</Steps>
39+
40+
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:
41+
42+
```ts
43+
slack({
44+
id: "support-slack",
45+
token: process.env.SLACK_BOT_TOKEN!,
46+
// ignore anything but questions (composed with the built-in self-message guard)
47+
filter: "event.event.text contains '?'",
48+
inbound: (e) => e.event?.text ?? "",
49+
outbound: (reply) => ({ text: reply.text }),
50+
ack: (e) => ({ text: "thinking..." }), // pass `null` to post only the final answer
51+
});
52+
```
53+
54+
<Note>
55+
`slack()` always drops the bot's own messages (and their edits) before they reach the agent, so the
56+
agent never replies to itself. A multi-workspace app can pass a `token` resolver keyed on the event's
57+
team instead of a single string.
58+
</Note>
59+
60+
## Any surface: `chat.channels.custom`
61+
62+
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`:
63+
64+
```ts
65+
import { chat } from "@trigger.dev/sdk/ai";
66+
import { webhooks } from "@trigger.dev/sdk";
67+
68+
const mySurface = chat.channels.custom({
69+
id: "my-surface",
70+
source: webhooks.custom<MyEvent>({ /* verifier config */ }),
71+
key: "{body.conversationId}",
72+
inbound: (e) => e.text,
73+
outbound: (reply) => (reply.text ? { text: reply.text } : null), // null posts nothing
74+
send: async (message, ctx) => {
75+
const ref = await postToMySurface(ctx.event, message.text, ctx.previousRef);
76+
return { ref }; // an existing ref means edit-in-place on the next turn
77+
},
78+
});
79+
```
80+
81+
`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).
82+
83+
## Channels vs events
84+
85+
Both are inbound surfaces on a `chat.agent`, and an agent can list both:
86+
87+
- [`events`](/webhooks/session-routing) (`chat.event`): the webhook is a signal. Delivered to `onAction`; the agent acts, no reply is sent back.
88+
- `channels` (`slack`, `chat.channels.custom`): the webhook is a chat frontend. Delivered as a turn to `run()`; the reply is posted back.

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,31 @@ describe("quantifiers", () => {
171171
);
172172
});
173173
});
174+
175+
// The @trigger.dev/slack self-message loop guard: route only human message events, drop the bot's own
176+
// posts (bot_id present) and their edits (message_changed). Proves the loop is closed.
177+
describe("slack self-message guard", () => {
178+
const GUARD =
179+
"event.event.type == 'message' && event.event.bot_id == null && event.event.subtype not in ['message_changed','message_deleted','bot_message']";
180+
const ev = (event: Record<string, unknown>): Partial<FilterContext> => ({ event: { event } });
181+
182+
it("routes a human message in a thread", () => {
183+
expect(evaluateFilter(parseFilter(GUARD), ctx(ev({ type: "message", channel: "C1", ts: "1" }))).match).toBe(true);
184+
});
185+
186+
it("drops the bot's own post (bot_id present)", () => {
187+
expect(
188+
evaluateFilter(parseFilter(GUARD), ctx(ev({ type: "message", bot_id: "B123", text: "reply" }))).match
189+
).toBe(false);
190+
});
191+
192+
it("drops an edit re-delivery (message_changed subtype)", () => {
193+
expect(
194+
evaluateFilter(parseFilter(GUARD), ctx(ev({ type: "message", subtype: "message_changed" }))).match
195+
).toBe(false);
196+
});
197+
198+
it("drops an app_mention duplicate (dedupes to the message event)", () => {
199+
expect(evaluateFilter(parseFilter(GUARD), ctx(ev({ type: "app_mention", text: "hey" }))).match).toBe(false);
200+
});
201+
});

packages/slack/package.json

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
{
2+
"name": "@trigger.dev/slack",
3+
"version": "4.5.0-rc.7",
4+
"description": "Slack chat frontend (channel) for trigger.dev agents",
5+
"license": "MIT",
6+
"publishConfig": {
7+
"access": "public"
8+
},
9+
"repository": {
10+
"type": "git",
11+
"url": "https://github.com/triggerdotdev/trigger.dev",
12+
"directory": "packages/slack"
13+
},
14+
"type": "module",
15+
"files": [
16+
"dist"
17+
],
18+
"tshy": {
19+
"selfLink": false,
20+
"main": true,
21+
"module": true,
22+
"project": "./tsconfig.src.json",
23+
"exports": {
24+
"./package.json": "./package.json",
25+
".": "./src/index.ts"
26+
},
27+
"sourceDialects": [
28+
"@triggerdotdev/source"
29+
]
30+
},
31+
"scripts": {
32+
"clean": "rimraf dist .tshy .tshy-build .turbo",
33+
"build": "tshy && pnpm run update-version",
34+
"dev": "tshy --watch",
35+
"typecheck": "tsc --noEmit -p tsconfig.src.json",
36+
"test": "vitest",
37+
"update-version": "tsx ../../scripts/updateVersion.ts",
38+
"check-exports": "attw --pack ."
39+
},
40+
"dependencies": {
41+
"@trigger.dev/core": "workspace:4.5.0-rc.7"
42+
},
43+
"peerDependencies": {
44+
"@trigger.dev/sdk": "workspace:^4.5.0-rc.7"
45+
},
46+
"devDependencies": {
47+
"@arethetypeswrong/cli": "^0.15.4",
48+
"@trigger.dev/sdk": "workspace:4.5.0-rc.7",
49+
"rimraf": "6.0.1",
50+
"tshy": "^3.0.2",
51+
"tsx": "4.17.0",
52+
"vitest": "^2.1.9"
53+
},
54+
"engines": {
55+
"node": ">=18.20.0"
56+
},
57+
"exports": {
58+
"./package.json": "./package.json",
59+
".": {
60+
"import": {
61+
"@triggerdotdev/source": "./src/index.ts",
62+
"types": "./dist/esm/index.d.ts",
63+
"default": "./dist/esm/index.js"
64+
},
65+
"require": {
66+
"types": "./dist/commonjs/index.d.ts",
67+
"default": "./dist/commonjs/index.js"
68+
}
69+
}
70+
},
71+
"main": "./dist/commonjs/index.js",
72+
"types": "./dist/commonjs/index.d.ts",
73+
"module": "./dist/esm/index.js"
74+
}

packages/slack/src/index.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { slack, type SlackMessageEvent } from "./index.js";
3+
4+
const messageEvent = (over: Partial<NonNullable<SlackMessageEvent["event"]>> = {}): SlackMessageEvent => ({
5+
type: "event_callback",
6+
event: { type: "message", channel: "C9", ts: "1699999999.0001", text: "hi", ...over },
7+
});
8+
9+
describe("slack channel", () => {
10+
it("default inbound strips a leading bot mention", () => {
11+
const c = slack({ id: "s1", token: "xoxb-t" });
12+
expect(c.inbound(messageEvent({ text: "<@U123> hello there" }))).toBe("hello there");
13+
expect(c.inbound(messageEvent({ text: "plain" }))).toBe("plain");
14+
});
15+
16+
it("composes the self-message guard with a user filter", () => {
17+
const guardOnly = slack({ id: "s2", token: "t" });
18+
expect(guardOnly.filter).toContain("event.event.type == 'message'");
19+
expect(guardOnly.filter).toContain("event.event.bot_id == null");
20+
expect(guardOnly.filter).toContain("message_changed");
21+
22+
const withUser = slack({ id: "s3", token: "t", filter: "event.event.channel == 'C1'" });
23+
expect(withUser.filter).toContain("&& (event.event.channel == 'C1')");
24+
});
25+
26+
it("keys one session per thread with a thread_ts||ts fallback", () => {
27+
const c = slack({ id: "s4", token: "t" });
28+
expect(c.key).toBe("{body.team_id}:{body.event.channel}:{body.event.thread_ts || body.event.ts}");
29+
});
30+
31+
it("send posts then edits, threading the ref and using the bot token", async () => {
32+
const calls: Array<{ url: string; body: Record<string, unknown>; auth: unknown }> = [];
33+
vi.stubGlobal(
34+
"fetch",
35+
vi.fn(async (url: string, init: { body: string; headers: Record<string, string> }) => {
36+
calls.push({ url, body: JSON.parse(init.body), auth: init.headers.authorization });
37+
return { json: async () => ({ ok: true, ts: "1700000000.0001" }) };
38+
})
39+
);
40+
41+
const c = slack({ id: "s5", token: "xoxb-secret", apiBaseUrl: "https://mock.slack" });
42+
const event = messageEvent();
43+
44+
const ackRes = await c.send!({ text: "on it..." }, {
45+
event,
46+
deliveryId: "d1",
47+
mode: "final",
48+
final: false,
49+
});
50+
expect(ackRes.ref).toBe("1700000000.0001");
51+
expect(calls[0]?.url).toBe("https://mock.slack/chat.postMessage");
52+
expect(calls[0]?.auth).toBe("Bearer xoxb-secret");
53+
expect(calls[0]?.body.channel).toBe("C9");
54+
expect(calls[0]?.body.thread_ts).toBe("1699999999.0001");
55+
56+
await c.send!({ text: "done" }, {
57+
event,
58+
deliveryId: "d1",
59+
previousRef: ackRes.ref,
60+
mode: "final",
61+
final: true,
62+
});
63+
expect(calls[1]?.url).toBe("https://mock.slack/chat.update");
64+
expect(calls[1]?.body.ts).toBe("1700000000.0001");
65+
expect(calls[1]?.body.text).toBe("done");
66+
67+
vi.unstubAllGlobals();
68+
});
69+
70+
it("send throws when the bot is not in the channel", async () => {
71+
vi.stubGlobal(
72+
"fetch",
73+
vi.fn(async () => ({ json: async () => ({ ok: false, error: "not_in_channel" }) }))
74+
);
75+
const c = slack({ id: "s6", token: "t", apiBaseUrl: "https://mock.slack" });
76+
await expect(
77+
c.send!({ text: "x" }, { event: messageEvent(), deliveryId: "d", mode: "final", final: true })
78+
).rejects.toThrow(/not_in_channel/);
79+
vi.unstubAllGlobals();
80+
});
81+
});

0 commit comments

Comments
 (0)