Skip to content

Commit 6e715da

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(sdk,slack): native Slack agents for chat.agent
`@trigger.dev/slack` turns a `chat.agent` into a native Slack agent. ```ts import { webhooks } from "@trigger.dev/sdk"; import { chat } from "@trigger.dev/sdk/ai"; import { appMentions, slack, slackButton, webhookSource } from "@trigger.dev/slack"; const slackEvents = webhooks.endpoint.define({ id: "slack", source: webhookSource() }); export const support = chat.agent({ id: "support", channels: [ slack({ id: "slack", endpoint: slackEvents, token: process.env.SLACK_BOT_TOKEN!, startOn: appMentions(), interactions: { pickPlan: (call, { button }) => ({ text: "Pick a plan", blocks: [{ type: "actions", elements: (call.input as { plans: string[] }).plans .map((plan) => button({ text: plan, output: { plan } })) }], }), }, }), ], tools: { searchDocs, refundOrder, pickPlan, offerFollowUps }, actionSchema: z.object({ type: z.literal("ask"), question: z.string() }), onAction: async ({ action }) => chat.turn(action.question), pendingMessages: { shouldInject: () => true }, run: async ({ messages, tools, signal, streamText }) => streamText({ model, messages, tools, abortSignal: signal, stopWhen: stepCountIs(10) }), }); ``` Each Slack agent session (a conversation with the agent, or a channel thread) is one durable session. - Status and streaming. The session shows `processing` while a turn runs, is titled from the first prompt, and returns to `active` when it ends. The reply streams natively (`chat.startStream` / `appendStream` / `stopStream`) with tool calls as task cards. `delivery: "final"` posts it once instead. - Approvals and the agent's own buttons. A tool with no `execute` gets Approve and Deny, resolving to `{ approved }`. `approval` replaces those buttons, and `interactions` gives a tool its own UI whose click becomes the tool's output. `slackButton({ text, action })` posted with the surface's `post()` sends `onAction` an action validated against `actionSchema`, with the click itself as `channel.event` (`SlackBlockActions`: who clicked, which message), and `chat.turn(message)` answers it in the thread. Only buttons built this way reach the agent; hand-built ones go to a `webhook()` on the same endpoint. - Stop. Slack's stop button aborts the running turn and keeps the partial reply. A stop while idle runs nothing, and a stop during an approval cancels it. - Messages sent mid-reply get 👀 as soon as they arrive (`receivedReaction`, needs `reactions:write`), then wait and run as the next turn. With `pendingMessages`, `shouldInject` can fold them into the reply being written instead (`metadata.source` is `"channel"`). Stops and approval clicks are never folded in. - Files. Shared images and PDFs reach the model with their message. The transcript keeps the Slack URL and the `streamText` passed to `run()` downloads it with the bot token, once per run. Other types, oversized files and files the app can't read reach the model as a short note. A custom `inbound` keeps this by spreading `slackFileParts(event.event?.files)` into its parts. - Channel threads. `startOn` is required and decides which channel messages start a session: `appMentions()` answers when the agent is @mentioned, from Slack's `app_mention` event with no bot user id, and `mentions(botId)` does the same from message events. `seedFromThread` loads earlier replies. Clicks and stops only resume existing sessions. `filter` and `startOn` are type-checked against the endpoint's event like `webhook()`'s filter, so a typo shows the validator's message in the editor; `mentions()` and filters built at runtime still compile. - Slash commands. The Slack source acknowledges with an empty `200`, and `webhookSource<SlackSlashCommand>()` types an endpoint's command fields. - Resilience. Slack's retries of an event are recognized by its `event_id`, acknowledged, and never run a second turn. Rate limits wait out `Retry-After`. An append with an unknown outcome is never retried; the rest of the reply is posted as its own message. A worker that dies mid-stream has its stream closed with an "Interrupted" note before the recovered turn answers. The docs page ships a copyable `agent_view` manifest (scopes include `files:read` and `reactions:write`). Runtime additions in `@trigger.dev/sdk`, usable by any channel connector: - `lifecycle(state, ctx)` (`working`, `waiting`, `idle`, `closed`, serialized, `working` refreshed every 50 minutes) and `stream(ctx)`, a per-turn `{ text, progress, end }` reply transport. - `onControl()` stops with `whenIdle: "drop"` and `at`, `cancelInteraction()`, and control refs persisted on the channel binding. - `renderInteraction` / `onInteraction` / `finalizeInteraction` for tool answers, and `toAction(event)`, which turns a click into an action turn (`actionSource: "channel"`). - `download(url, { handle })` for files only the surface's credentials can read. A file that fails becomes a note in that turn's messages. A wildcard media type such as `image/*` (Linear uploads have no extension) is replaced with the one the download reports. - Mid-turn channel messages are acknowledged on arrival through the connector's `received(ctx)` hook (Slack reacts, Linear posts a thought), whether or not `pendingMessages` then folds them in. - A channel delivery whose `inbound()` is empty runs no turn, and `chat.turn(message)` appends a user message for the action's turn to answer. `startOn` is required, so an agent in a channel answers only the messages you choose. `appMentions()` answers when the agent is @mentioned, from Slack's `app_mention` event, with no bot user id needed. `SlackRunSurface` types `chat.surface()` on a Slack turn. `ownWorkspaceOnly: true` answers only people from the app's own workspace, dropping Slack Connect guests' messages, mentions and clicks. Tool calls show as one plan in the streamed reply by default, each task titled by the tool's `title` (`tool({ title })`) or its name made readable; `progress: "tasks"` shows one card per call. Mono-RevId: 611181ace2faea48b279a08adca02c1a4e01a003
1 parent d6955ee commit 6e715da

24 files changed

Lines changed: 9518 additions & 271 deletions

‎.changeset/hosted-webhook-slack.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
"@trigger.dev/slack": minor
4+
---
5+
6+
Add `@trigger.dev/slack`: turn a `chat.agent` into a native Slack agent. Each DM or channel thread is a durable session, the reply streams into the thread with the turn's tool calls shown as a plan, approvals are Approve and Deny buttons, and Slack's stop button stops the turn.
7+
8+
```ts
9+
import { appMentions, slack, webhookSource } from "@trigger.dev/slack";
10+
11+
const slackEvents = webhooks.endpoint.define({ id: "slack", source: webhookSource() });
12+
13+
export const support = chat.agent({
14+
id: "support",
15+
channels: [
16+
slack({
17+
id: "slack",
18+
endpoint: slackEvents,
19+
token: process.env.SLACK_BOT_TOKEN!,
20+
startOn: appMentions(),
21+
}),
22+
],
23+
run: async ({ messages, signal, streamText }) =>
24+
streamText({ model, messages, abortSignal: signal }),
25+
});
26+
```
27+
28+
`startOn` decides which channel messages start a session (`appMentions()` answers when the agent is @mentioned). Images and PDFs people share reach the model, messages sent while the agent is answering can be folded into the reply, tools can post their own buttons, and `ownWorkspaceOnly` ignores Slack Connect guests.

‎apps/webapp/app/components/webhookEndpoints/WebhooksBetaAccess.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const fulfillOrder = webhook({
3939

4040
const agentExample = `import { webhooks } from "@trigger.dev/sdk";
4141
import { chat } from "@trigger.dev/sdk/ai";
42-
import { slack, webhookSource } from "@trigger.dev/slack";
42+
import { appMentions, slack, webhookSource } from "@trigger.dev/slack";
4343
4444
const slackEvents = webhooks.endpoint.define({
4545
id: "slack",
@@ -53,6 +53,7 @@ export const support = chat.agent({
5353
id: "slack",
5454
endpoint: slackEvents,
5555
token: process.env.SLACK_BOT_TOKEN!,
56+
startOn: appMentions(),
5657
}),
5758
],
5859
run: async ({ messages, signal, streamText }) =>

‎internal-packages/webhook-engine/src/engine/verification/verify.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createHmac, generateKeyPairSync, sign as cryptoSign, type KeyObject } from "node:crypto";
22
import { WebhookVerifierConfig } from "@trigger.dev/core/v3";
3+
import { webhookProviderConfigs } from "@trigger.dev/core/webhooks";
34
import { describe, expect, it } from "vitest";
45
import { parseEventBody } from "./derive.js";
56
import { verify, verifyWithSecrets } from "./index.js";
@@ -335,6 +336,52 @@ describe("hmac verifier (config-driven)", () => {
335336
});
336337
});
337338

339+
describe("provider retries", () => {
340+
function slackAttempt(body: string, sentAtS: number, retryNum?: number) {
341+
const sig = createHmac("sha256", "whsec_test_secret")
342+
.update(`v0:${sentAtS}:${body}`)
343+
.digest("hex");
344+
return input({
345+
rawBytes: bytes(body),
346+
nowMs: sentAtS * 1000,
347+
headers: {
348+
"x-slack-signature": `v0=${sig}`,
349+
"x-slack-request-timestamp": String(sentAtS),
350+
...(retryNum
351+
? { "x-slack-retry-num": String(retryNum), "x-slack-retry-reason": "http_timeout" }
352+
: {}),
353+
},
354+
});
355+
}
356+
357+
it("gives a Slack event and its retries one key", () => {
358+
const body = JSON.stringify({
359+
type: "event_callback",
360+
team_id: "T1",
361+
event_id: "Ev0MDYA7HL",
362+
event: { type: "message", channel: "D1", ts: "1700000000.000100", text: "hi" },
363+
});
364+
const slack = cfg(webhookProviderConfigs.slack.config());
365+
const first = verify(slack, slackAttempt(body, NOW_S));
366+
const retry = verify(slack, slackAttempt(body, NOW_S + 61, 1));
367+
expect(first.ok && retry.ok).toBe(true);
368+
expect(first.idempotencyKey).toBe("Ev0MDYA7HL");
369+
expect(retry.idempotencyKey).toBe(first.idempotencyKey);
370+
});
371+
372+
it("decodes a Slack click and keys it on the delivery when it has no event_id", () => {
373+
const click = { type: "block_actions", actions: [{ action_id: "trigger_tool_1" }] };
374+
const body = `payload=${encodeURIComponent(JSON.stringify(click))}`;
375+
const slack = cfg(webhookProviderConfigs.slack.config());
376+
const first = verify(slack, slackAttempt(body, NOW_S));
377+
const second = verify(slack, slackAttempt(body, NOW_S + 1));
378+
expect(first.ok).toBe(true);
379+
expect(first.parsedEvent).toEqual(click);
380+
expect(first.idempotencyKey).toMatch(/^[0-9a-f]{64}$/);
381+
expect(second.idempotencyKey).not.toBe(first.idempotencyKey);
382+
});
383+
});
384+
338385
describe("asymmetric verifier (config-driven)", () => {
339386
it("verifies a Discord-style Ed25519 signature with a raw-hex public key", () => {
340387
const { publicKey, privateKey } = generateKeyPairSync("ed25519");

‎packages/core/src/v3/webhooks/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ function hmacVerifierConfig(opts: {
141141
) & { unit?: "seconds" | "milliseconds"; format?: "unix" | "iso8601" | "http-date" };
142142
toleranceSeconds?: number;
143143
idempotencyField?: WebhookHmacConfig["idempotencyField"];
144+
formPayload?: WebhookHmacConfig["formPayload"];
144145
}): WebhookHmacConfig {
145146
const signature: WebhookHmacConfig["signature"] = {};
146147
if (opts.itemSeparator) signature.itemSeparator = opts.itemSeparator;
@@ -182,6 +183,7 @@ function hmacVerifierConfig(opts: {
182183
};
183184
}
184185
if (opts.idempotencyField) config.idempotencyField = opts.idempotencyField;
186+
if (opts.formPayload) config.formPayload = opts.formPayload;
185187

186188
return config;
187189
}
@@ -204,6 +206,8 @@ export const webhookProviderConfigs = {
204206
signingTemplate: "v0:{timestamp}:{body}",
205207
timestamp: { from: "header", name: "x-slack-request-timestamp" },
206208
toleranceSeconds: 300,
209+
idempotencyField: { from: "body", name: "event_id" },
210+
formPayload: { field: "payload" },
207211
}),
208212
},
209213
zoom: {

‎packages/slack/package.json‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{
2+
"name": "@trigger.dev/slack",
3+
"version": "4.7.6",
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+
"exclude": [
24+
"./src/**/*.test.ts"
25+
],
26+
"exports": {
27+
"./package.json": "./package.json",
28+
".": "./src/index.ts"
29+
},
30+
"sourceDialects": [
31+
"@triggerdotdev/source"
32+
]
33+
},
34+
"scripts": {
35+
"clean": "rimraf dist .tshy .tshy-build .turbo",
36+
"build": "tshy && pnpm run update-version",
37+
"dev": "tshy --watch",
38+
"typecheck": "tsc --noEmit -p tsconfig.src.json",
39+
"test": "vitest",
40+
"update-version": "tsx ../../scripts/updateVersion.ts",
41+
"check-exports": "attw --pack ."
42+
},
43+
"dependencies": {
44+
"@trigger.dev/core": "workspace:4.7.6"
45+
},
46+
"peerDependencies": {
47+
"@trigger.dev/sdk": "workspace:^4.7.6"
48+
},
49+
"devDependencies": {
50+
"@arethetypeswrong/cli": "^0.18.5",
51+
"@trigger.dev/sdk": "workspace:4.7.6",
52+
"@types/node": "^24.13.3",
53+
"rimraf": "6.0.1",
54+
"tshy": "^3.0.2",
55+
"tsx": "4.17.0"
56+
},
57+
"engines": {
58+
"node": ">=18.20.0"
59+
},
60+
"exports": {
61+
"./package.json": "./package.json",
62+
".": {
63+
"import": {
64+
"@triggerdotdev/source": "./src/index.ts",
65+
"types": "./dist/esm/index.d.ts",
66+
"default": "./dist/esm/index.js"
67+
},
68+
"require": {
69+
"types": "./dist/commonjs/index.d.ts",
70+
"default": "./dist/commonjs/index.js"
71+
}
72+
}
73+
},
74+
"main": "./dist/commonjs/index.js",
75+
"types": "./dist/commonjs/index.d.ts",
76+
"module": "./dist/esm/index.js"
77+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { spawnSync } from "node:child_process";
2+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { join, resolve } from "node:path";
4+
import { describe, expect, it } from "vitest";
5+
6+
/**
7+
* slack()'s `filter` and `startOn` are checked against the endpoint's event type, and an invalid one
8+
* shows the validator's `✖ ...` message. These compile a consumer file with tsc and assert the
9+
* diagnostic text at each call, since `@ts-expect-error` passes on any error.
10+
*/
11+
12+
const packageRoot = resolve(__dirname, "..");
13+
const repoRoot = resolve(packageRoot, "../..");
14+
15+
const HEADER = `
16+
import { webhooks } from "@trigger.dev/sdk";
17+
import { chat } from "@trigger.dev/sdk/ai";
18+
import { mentions, slack, webhookSource, type SlackRunSurface } from "../src/index.js";
19+
20+
const endpoint = webhooks.endpoint.define({ id: "slack-types", source: webhookSource() });
21+
const token = "xoxb-test";
22+
`;
23+
24+
const CASES: Array<{ name: string; code: string; message?: string }> = [
25+
{
26+
name: "filter with an unknown field",
27+
code: `slack({ startOn: "event.event.type == 'message'", id: "a", endpoint, token, filter: "event.event.nope == 'x'" });`,
28+
message: "✖ unknown field: event.event.nope",
29+
},
30+
{
31+
name: "filter with unbalanced parens",
32+
code: `slack({ startOn: "event.event.type == 'message'", id: "b", endpoint, token, filter: "( event.event.channel == 'C1'" });`,
33+
message: '✖ unbalanced parens (missing \\")\\")',
34+
},
35+
{
36+
name: "startOn with an unknown field",
37+
code: `slack({ id: "c", endpoint, token, startOn: "event.event.txt contains 'deploy'" });`,
38+
message: "✖ unknown field: event.event.txt",
39+
},
40+
{
41+
name: "valid filters compile",
42+
code: `slack({ id: "d", endpoint, token, filter: "event.event.channel == 'C1' && event.team_id == 'T1'", startOn: "event.event.text contains '<@U012BOT>'" });`,
43+
},
44+
{
45+
name: "mentions() and filters built at runtime compile",
46+
code: `const channels: string = ["C1", "C2"].map((c) => \`event.event.channel == '\${c}'\`).join(" || "); slack({ id: "e", endpoint, token, filter: channels, startOn: mentions("U012BOT") });`,
47+
},
48+
{
49+
name: "omitted filter compiles",
50+
code: `slack({ startOn: "event.event.type == 'message'", id: "f", endpoint, token });`,
51+
},
52+
{
53+
name: "chat.surface<SlackRunSurface>() types the Slack helpers",
54+
code: `void (async () => { const surface = chat.surface<SlackRunSurface>(); await surface?.post({ text: "hi" }); await surface?.react("eyes"); })();`,
55+
},
56+
{
57+
name: "startOn is required",
58+
code: `slack({ id: "g", endpoint, token });`,
59+
message: "startOn",
60+
},
61+
];
62+
63+
function diagnosticsByLine(): Map<number, string[]> {
64+
const dir = mkdtempSync(join(packageRoot, ".filter-types-"));
65+
try {
66+
writeFileSync(join(dir, "consumer.ts"), `${HEADER}\n${CASES.map((c) => c.code).join("\n")}\n`);
67+
writeFileSync(
68+
join(dir, "tsconfig.json"),
69+
JSON.stringify({
70+
extends: join(packageRoot, "tsconfig.src.json"),
71+
compilerOptions: { noEmit: true, composite: false, incremental: false, rootDir: repoRoot },
72+
include: ["consumer.ts"],
73+
exclude: [],
74+
})
75+
);
76+
const result = spawnSync(
77+
join(repoRoot, "node_modules/.bin/tsc"),
78+
["-p", dir, "--pretty", "false"],
79+
{ encoding: "utf8" }
80+
);
81+
const byLine = new Map<number, string[]>();
82+
let last: number | undefined;
83+
for (const line of `${result.stdout}${result.stderr}`.split("\n")) {
84+
const match = /^(.*?)\((\d+),\d+\): error TS\d+: (.*)$/.exec(line);
85+
if (match) {
86+
last = match[1]!.endsWith("consumer.ts") ? Number(match[2]) - 1 : -1;
87+
byLine.set(last, [...(byLine.get(last) ?? []), match[3]!]);
88+
} else if (last !== undefined && line.startsWith(" ")) {
89+
byLine.get(last)!.push(line.trim());
90+
}
91+
}
92+
return byLine;
93+
} finally {
94+
rmSync(dir, { recursive: true, force: true });
95+
}
96+
}
97+
98+
describe("slack() filter types", () => {
99+
const headerLines = `${HEADER}\n`.split("\n").length - 1;
100+
let byLine: Map<number, string[]>;
101+
const at = (index: number) => byLine.get(headerLines + index) ?? [];
102+
103+
it("compiles the consumer file", () => {
104+
byLine = diagnosticsByLine();
105+
const outside = [...byLine.entries()].filter(
106+
([line]) => line < headerLines || line >= headerLines + CASES.length
107+
);
108+
expect(outside).toEqual([]);
109+
}, 120_000);
110+
111+
for (const [index, testCase] of CASES.entries()) {
112+
it(testCase.name, () => {
113+
if (testCase.message) {
114+
expect(at(index).join("\n")).toContain(testCase.message);
115+
} else {
116+
expect(at(index)).toEqual([]);
117+
}
118+
});
119+
}
120+
});

0 commit comments

Comments
 (0)