feat(sdk,slack): webhook sources, agent channels, and human-in-the-loop

This commit is contained in:
Eric Allam
2026-08-08 07:19:38 +01:00
parent c0b84595a3
commit cf566adb81
26 changed files with 2940 additions and 12 deletions
+14
View File
@@ -0,0 +1,14 @@
---
"@trigger.dev/core": minor
"@trigger.dev/sdk": minor
"@trigger.dev/slack": minor
"trigger.dev": minor
---
Add hosted webhooks: receive and verify provider webhooks as a task, with no ingress or verification code of your own.
- `webhook()` declares an endpoint that routes a verified, typed event to an `onEvent` handler. Choose a source with a preset (`webhooks.stripe()`, `webhooks.github()`, and others) or `webhooks.custom<T>(config)`. Declared webhooks are discovered like tasks and synced to a hosted URL on deploy.
- `filter` gates which deliveries run, using a type-safe expression checked against the event at author time (`event.`/`header.`/`webhook.` paths, `&&`/`||`, comparison and `in`/`contains` operators, field-to-field comparison, and array quantifiers). A non-matching delivery is still recorded, not routed.
- `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.
- 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. 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.
- HTTP API for listing webhook endpoints and deliveries, plus rotate-secret, enable/disable, and replay.
+31
View File
@@ -470,6 +470,37 @@ Custom actions let the frontend send structured commands (undo, rollback, edit,
See [Actions](/ai-chat/actions).
### Webhook events and channels
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.
```ts
import { webhooks } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
import { slack } from "@trigger.dev/slack";
export const orderEvents = chat.event({
id: "order-events",
source: webhooks.stripe(),
key: "{body.data.object.customer}",
type: "order.event",
});
export const myChat = chat.agent({
id: "my-chat",
events: [orderEvents],
channels: [slack({ id: "support-slack", token: process.env.SLACK_BOT_TOKEN! })],
onAction: async ({ action }) => {
// A verified order-events delivery arrives here as an action.
},
run: async (payload) => {
// Inbound Slack messages run here as normal turns.
},
});
```
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).
### Chat history
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.
+40
View File
@@ -47,6 +47,8 @@ Options for `chat.agent()`.
| `hydrateMessages` | `(event: HydrateMessagesEvent) => UIMessage[] \| Promise<UIMessage[]>` | — | Load message history from backend, replacing the linear accumulator. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages) |
| `actionSchema` | `TaskSchema` | — | Schema for validating custom actions sent via `transport.sendAction()`. See [Actions](/ai-chat/actions) |
| `onAction` | `(event: ActionEvent) => Promise<unknown> \| unknown` | — | Handle custom actions. Actions are not turns — only `hydrateMessages` + `onAction` fire. Return a `StreamTextResult` (or `string` / `UIMessage`) for a model response; return `void` for side-effect-only. See [Actions](/ai-chat/actions) |
| `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). |
| `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). |
| `onTurnStart` | `(event: TurnStartEvent) => Promise<void> \| void` | — | Fires every turn before `run()` |
| `onBeforeTurnComplete` | `(event: BeforeTurnCompleteEvent) => Promise<void> \| void` | — | Fires after response but before stream closes. Includes `writer`. |
| `onTurnComplete` | `(event: TurnCompleteEvent) => Promise<void> \| void` | — | Fires after each turn completes (stream closed) |
@@ -501,6 +503,8 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
| Method | Description |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `chat.agent(options)` | Create a chat agent |
| `chat.event(options)` | Declare an inbound webhook event descriptor an agent claims via `chat.agent({ events })`. See [session routing](/webhooks/session-routing). |
| `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). |
| `chat.createSession(payload, options)` | Create an async iterator for chat turns |
| `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) |
| `chat.pipeAndCapture(source, options?)` | Pipe and capture the response; returns `{ message, status, error }` |
@@ -530,6 +534,42 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`.
| `chat.withUIMessage(config?)` | Returns a [ChatBuilder](/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. See [Types](/ai-chat/types) |
| `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) |
## `chat.event`
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).
```ts
import { webhooks } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
export const orderEvents = chat.event({
id: "order-events",
source: webhooks.stripe(),
key: "{body.data.object.customer}",
type: "order.event",
});
```
## `chat.channels.custom`
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).
```ts
import { webhooks } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
export const mySurface = chat.channels.custom({
id: "my-surface",
source: webhooks.custom<MyEvent>({ /* verifier config */ }),
key: "{body.conversationId}",
inbound: (event) => event.text,
send: async (message, ctx) => {
const ref = await postToMySurface(ctx.event, message.text, ctx.previousRef);
return { ref };
},
});
```
## `chat.withUIMessage`
Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. Chain `.withClientData()`, hook methods, and `.agent()`.
+13
View File
@@ -148,6 +148,19 @@
}
]
},
{
"group": "Webhooks",
"pages": [
"webhooks/overview",
"webhooks/sources",
"webhooks/connect",
"webhooks/deliveries",
"webhooks/filters",
"webhooks/session-routing",
"webhooks/channels",
"webhooks/human-in-the-loop"
]
},
{
"group": "Configuration",
"pages": [
+133
View File
@@ -0,0 +1,133 @@
---
title: "Channels (chat frontends)"
description: "Point Slack (or any chat surface) at an agent: messages become turns and replies post back."
sidebarTitle: "Channels"
---
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.
List channels on a [`chat.agent`](/ai-chat/overview) alongside (or instead of) `events`:
```ts
import { chat } from "@trigger.dev/sdk/ai";
import { slack } from "@trigger.dev/slack";
export const supportAgent = chat.agent({
id: "support-agent",
channels: [slack({ id: "support-slack", token: process.env.SLACK_BOT_TOKEN! })],
run: async ({ messages }) => streamText({ model: anthropic("claude-sonnet-4-5"), messages }),
});
```
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.
## Slack
`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`.
<Steps>
<Step title="Create a Slack app">
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-...`).
</Step>
<Step title="Deploy the agent + connect the endpoint">
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`.
</Step>
<Step title="Subscribe to events">
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`).
</Step>
</Steps>
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:
```ts
slack({
id: "support-slack",
token: process.env.SLACK_BOT_TOKEN!,
// ignore anything but questions (composed with the built-in self-message guard)
filter: "event.event.text contains '?'",
inbound: (e) => e.event?.text ?? "",
outbound: (reply) => ({ text: reply.text }),
ack: (e) => ({ text: "thinking..." }), // pass `null` to post only the final answer
});
```
<Note>
`slack()` always drops the bot's own messages (and their edits) before they reach the agent, so the
agent never replies to itself. A multi-workspace app can pass a `token` resolver keyed on the event's
team instead of a single string.
</Note>
### Summoning with a mention
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.
```ts
import { slack, mentions } from "@trigger.dev/slack";
slack({
id: "support-slack",
token: process.env.SLACK_BOT_TOKEN!,
startOn: mentions("U012BOT"), // your bot's user id (pass several for multiple bots)
});
```
### Reacting to messages
`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.
```ts
slack({
id: "support-slack",
token: process.env.SLACK_BOT_TOKEN!,
reactions: { working: "eyes", done: "white_check_mark", error: "warning" },
});
```
### Options
| Option | Type | Description |
| --- | --- | --- |
| `id` | `string` | Connector id, unique per agent. |
| `token` | `string` or resolver | Bot token (`xoxb-...`), or a function of the event's team for multi-workspace apps. |
| `key` | `string` | Session [key](/webhooks/session-routing) template. Defaults to one session per thread. |
| `filter` | `string` | Extra [filter](/webhooks/filters), composed with the built-in self-message guard. |
| `startOn` | `string` | Only start a session when the event matches (see `mentions`). Existing sessions always resume. |
| `ack` | message, `null`, or function | Placeholder posted while the agent works. Pass `null` to post only the final answer. |
| `reactions` | `{ working?, done?, error? }` | Lifecycle emoji reactions on the triggering message. |
| `inbound` / `outbound` | functions | Map the Slack event to the turn, and the reply to a Slack message. |
| `delivery` | `"final"` or `"stream"` | `"final"` (default) posts a placeholder and edits it to the answer. `"stream"` edits live as the reply streams. |
| `apiBaseUrl` | `string` | Override the Slack Web API base, for testing against a mock. |
## Approvals and interactive controls
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).
## Any surface: `chat.channels.custom`
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`:
```ts
import { chat } from "@trigger.dev/sdk/ai";
import { webhooks } from "@trigger.dev/sdk";
const mySurface = chat.channels.custom({
id: "my-surface",
source: webhooks.custom<MyEvent>({ /* verifier config */ }),
key: "{body.conversationId}",
inbound: (e) => e.text,
outbound: (reply) => (reply.text ? { text: reply.text } : null), // null posts nothing
send: async (message, ctx) => {
const ref = await postToMySurface(ctx.event, message.text, ctx.previousRef);
return { ref }; // an existing ref means edit-in-place on the next turn
},
});
```
`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).
## Channels vs events
Both are inbound surfaces on a `chat.agent`, and an agent can list both:
- [`events`](/webhooks/session-routing) (`chat.event`): the webhook is a signal. Delivered to `onAction`; the agent acts, no reply is sent back.
- `channels` (`slack`, `chat.channels.custom`): the webhook is a chat frontend. Delivered as a turn to `run()`; the reply is posted back.
+35
View File
@@ -0,0 +1,35 @@
---
title: "Connecting a provider"
description: "Point a provider at the webhook URL and set the signing secret."
sidebarTitle: "Connecting a provider"
---
When you deploy (or run `dev`), each webhook task gets an **endpoint** with a unique, unguessable webhook URL. Open the webhook in the dashboard, go to **Endpoints**, and open the endpoint to find its **Connect** panel.
<Steps>
<Step title="Copy the webhook URL">
Copy it from the endpoint's Connect panel. On Trigger.dev Cloud it looks like
`https://webhooks.trigger.dev/webhooks/v1/ingest/<id>`. A self-hosted instance serves it from that
instance's own base URL. This is what you give the provider as its webhook destination.
</Step>
<Step title="Set the signing secret">
A webhook can't accept deliveries until its signing secret is set. Until then every request is
rejected. There are two flows, and the Connect panel shows the right one for the provider:
- **The provider generates the secret** (Stripe, Svix): copy it from the provider and paste it
into **Set secret**.
- **You choose the secret** (GitHub, or a service you control): click **Generate secret** and
Trigger.dev mints a strong secret and shows it once. Paste that into the provider's webhook config.
</Step>
<Step title="Point the provider at the webhook URL">
Add the webhook URL as the destination in your provider's dashboard. The Connect panel
shows the exact signature scheme (header, algorithm, signing string) the provider should use.
</Step>
</Steps>
<Warning>
The signing secret is stored encrypted and is never shown again after it's set. To rotate it,
use **Rotate secret** (or **Regenerate**) and update the provider with the new value.
</Warning>
Once a provider is sending events, watch them arrive on the [Deliveries](/webhooks/deliveries) page, which also explains what an [endpoint](/webhooks/deliveries#endpoints) is.
+48
View File
@@ -0,0 +1,48 @@
---
title: "Deliveries and endpoints"
description: "Observe inbound webhook requests, the runs they trigger, and their payloads in the dashboard."
sidebarTitle: "Deliveries & endpoints"
---
The dashboard surfaces two concepts under the **Webhooks** section.
## Deliveries
A delivery is a single inbound request that passed verification. The **Deliveries** page lists every
delivery across all your webhooks (much like the Runs page), and you can filter by webhook, status,
delivery id, or run id.
Open a delivery to see:
- Its **status** and the **run** it triggered (linked).
- The verified **event payload** and the inbound **request headers**, on separate tabs.
- The external delivery id, idempotency key, and timestamps.
<Note>
Duplicate deliveries are deduplicated automatically. The idempotency key is the provider's event id
(e.g. the Stripe event id, or GitHub's `X-GitHub-Delivery`), so a provider retry of the same event
resolves to the original delivery and won't trigger a second run.
</Note>
## Endpoints
An endpoint is the connection instance for a webhook: its webhook URL, signing-secret state,
verification scheme, and delivery history. Each webhook's **Endpoints** tab lists its endpoints (a
declared webhook has one), and opening an endpoint shows its [Connect panel](/webhooks/connect) and
its scoped deliveries.
## What happens to a request
<Steps>
<Step title="Verify">
The signature, timestamp, and idempotency key are checked. A failure returns `400` and records
nothing.
</Step>
<Step title="Record">
A verified request becomes a delivery, with its parsed event and headers stored.
</Step>
<Step title="Route">
The delivery is routed to your webhook task, which runs and calls `onEvent`. The delivery's status
reflects that run's outcome.
</Step>
</Steps>
+99
View File
@@ -0,0 +1,99 @@
---
title: "Filtering deliveries"
description: "Gate which verified webhook deliveries run, with a type-safe filter checked against the event."
sidebarTitle: "Filters"
---
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.
Filtering happens at the endpoint, before any run is triggered, so a filtered-out event costs you nothing.
## Adding a filter
Pass a `filter` string to `webhook()`. It is a small expression checked, at build time, against the event shape from your [source](/webhooks/sources#typing-the-event):
```ts
import { webhook, webhooks } from "@trigger.dev/sdk";
export const onOrder = webhook({
id: "orders",
source: webhooks.stripe(),
// only route succeeded payment intents over $100
filter: "event.type == 'payment_intent.succeeded' && event.data.object.amount >= 10000",
onEvent: async ({ event }) => {
// only runs for deliveries that matched
},
});
```
The filter is type-safe: referencing a field that does not exist, or comparing it to the wrong kind of literal, is a compile error, not a runtime surprise.
## What a non-match does
A delivery that does not match is **not dropped**. It still returns `200` to the provider and is recorded as a [delivery](/webhooks/deliveries) with the status `FILTERED` and a reason naming the clause that failed (and the value it saw). It just never triggers a run. This keeps a filtered delivery auditable: you can see in the dashboard that it arrived and why it was not routed.
<Note>
If a filter throws while evaluating (for example, a malformed event), the delivery is routed rather
than dropped. Filters fail open so a filter bug never silently swallows real events.
</Note>
## The expression language
A filter is one or more `path operator value` clauses combined with `&&` and `||` (use parentheses to group).
### Paths
A path reads from one of three namespaces:
- `event.*`: the verified, parsed request body, for example `event.data.object.amount`.
- `header.*`: an inbound request header, matched case-insensitively, for example `header.x-github-event`.
- `webhook.*`: endpoint metadata (`webhook.source`, `webhook.id`, `webhook.deliveryId`, and for per-tenant endpoints `webhook.externalRef` / `webhook.tenantId`).
The [session routing](/webhooks/session-routing) key template addresses this same parsed body, but spells it `{body.*}` rather than `event.*`.
### Operators
| Operator | Meaning |
| --- | --- |
| `==` `!=` | equality |
| `>` `<` `>=` `<=` | numeric comparison |
| `in` `not in` | membership in a list, for example `event.type in ['a','b']` |
| `startsWith` `endsWith` `contains` | string matching |
Values are strings in single quotes (`'created'`), numbers (`10000`), booleans (`true`), or a list for `in` / `not in`.
### Comparing two fields
The right-hand side can be another path instead of a literal, so you can compare two fields of the same event:
```ts
filter: "event.billing.country == event.shipping.country";
```
### Matching inside a list
`any` and `all` quantify over an array, testing a sub-path on each element:
```ts
// route only if at least one line item has a positive quantity
filter: "event.items any ( quantity > 0 )";
```
### Spacing
The type checker reads the filter as a token stream, so a couple of spots are strict about spacing: keep `in` / `not in` lists unspaced (`['a','b']`, not `[ 'a', 'b' ]`) and put spaces around the quantifier parentheses (`any ( ... )`).
To match only certain event types, write a clause against the field that carries the type: `event.type` for Stripe / Svix / Square / Discord, or the `x-github-event` header for GitHub (the filter DSL can read a `header.` namespace too):
```ts
export const onGithub = webhook({
id: "github",
source: webhooks.github(),
filter: "header.x-github-event in ['issues','pull_request']",
onEvent: async ({ event }) => {},
});
```
## Filtering a session route
A `filter` works the same way on [`chat.event`](/webhooks/session-routing): a non-matching delivery is recorded `FILTERED` and never reaches the session.
+143
View File
@@ -0,0 +1,143 @@
---
title: "Human-in-the-loop"
sidebarTitle: "Human-in-the-loop"
description: "Pause an agent mid-turn to get a human decision from the channel (Slack approve/deny), then resume with the answer."
---
**An agent on a [channel](/webhooks/channels) can pause a turn to get a human decision, then resume once someone answers in the thread.** It posts controls into the conversation and picks up where it left off once someone clicks, with the decision merged into the turn. This is the channel counterpart of browser [human-in-the-loop](/ai-chat/patterns/human-in-the-loop): the same no-`execute` tool, but the controls live in Slack (or your own surface) instead of a React component. The [`slack()`](/webhooks/channels) connector ships Approve / Deny buttons out of the box.
## How it works
The building block is a tool with no `execute` function. When the model calls it, the turn completes with the tool call still pending instead of resolving it. Over a channel the framework then takes over the round-trip:
```mermaid
sequenceDiagram
participant U as User
participant S as Slack
participant A as Agent run
U->>S: Message in a thread
S->>A: Verified delivery starts a turn
A->>A: Model calls requestApproval (no execute)
A->>S: renderInteraction posts Approve / Deny
Note over A: Turn completes, run suspends (no compute while waiting)
U->>S: Clicks a button
S->>A: Signed block_actions callback to the same webhook URL
A->>A: onInteraction resolves the pending tool
A->>S: finalizeInteraction collapses the buttons
A->>A: Run resumes, model continues
A->>S: Reply posts back to the thread
```
Because it is a no-`execute` pause, the run suspends while it waits rather than holding compute. A human can take minutes or days to decide without burning compute or hitting [`maxDuration`](/runs/max-duration). The mechanics are the same as the browser case, covered in [human-in-the-loop](/ai-chat/patterns/human-in-the-loop#duration-and-cost-while-paused).
## Slack approvals
Out of the box, `slack()` posts Approve / Deny buttons for any pending no-`execute` tool. A click resolves to `{ approved: boolean }` and the buttons collapse to the decision. You define the tool and list the channel on the agent:
```ts trigger/support-agent.ts
import { chat } from "@trigger.dev/sdk/ai";
import { slack } from "@trigger.dev/slack";
import { streamText, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
// No execute: calling this pauses the turn for a human decision.
const requestApproval = tool({
description:
"Request human approval before a sensitive or irreversible action. " +
"Call this and stop; you will receive { approved: boolean }, then proceed or decline.",
inputSchema: z.object({
action: z.string().describe("A short description of the action needing approval"),
}),
});
export const supportAgent = chat.agent({
id: "support-agent",
channels: [slack({ id: "support-slack", token: process.env.SLACK_BOT_TOKEN! })],
tools: { requestApproval },
run: async ({ messages, tools }) =>
streamText({
model: anthropic("claude-sonnet-4-5"),
messages,
tools,
system:
"Before any sensitive or irreversible action (refunds, cancellations, deletions), " +
"call requestApproval and wait. If approved, confirm it is done; if denied, decline.",
}),
});
```
Button clicks arrive on a different Slack API than messages, so there is one extra setup step beyond [connecting the channel](/webhooks/channels):
<Steps>
<Step title="Turn on Interactivity">
In the Slack app's **Interactivity & Shortcuts**, enable interactivity and set the Request URL to the **same** webhook URL you used for events. Slack posts button clicks there as a signed `block_actions` callback, which the endpoint verifies and routes to the paused run.
</Step>
<Step title="Confirm the bot scope">
Posting and editing the controls uses the `chat:write` scope you already added for replies. No extra scope is needed to collapse the buttons after a decision.
</Step>
</Steps>
When the model calls `requestApproval`, the bot posts "Approval needed" with the action and Approve / Deny buttons. Clicking **Approve** resolves the tool to `{ approved: true }`, the buttons collapse to "Approved by @you", and the agent continues and posts the outcome. **Deny** resolves `{ approved: false }`.
## Customizing the controls
For [`chat.channels.custom`](/webhooks/channels), or to override the Slack defaults, three hooks own the interaction round-trip:
<ParamField body="renderInteraction" type="(pending, ctx) => ChannelMessage | null">
Map the pending tool call(s) to the controls you post. `pending` is a list of `{ toolCallId, toolName, input }`. Return `null` to skip posting controls.
</ParamField>
<ParamField body="onInteraction" type="(event) => { toolCallId, output } | null">
Map a verified callback event to a resolution. The `output` is stitched onto the pending tool call matched by `toolCallId` and the run resumes. Return `null` to treat the event as a normal message instead (a new turn).
</ParamField>
<ParamField body="finalizeInteraction" type="(event, resolution) => void">
Collapse the controls after a decision so they cannot be clicked again. Best effort: a failure here is logged and the run still resumes.
</ParamField>
Slack encodes the decision in each button's `value` as `${toolCallId}::approve|deny` so `onInteraction` can resolve the exact call, and `finalizeInteraction` posts to the interaction's `response_url` with `replace_original` to swap the buttons for the outcome. On a custom surface you choose the encoding and how you edit the controls away.
```ts
chat.channels.custom({
id: "my-surface",
source: webhooks.custom<MyEvent>({ /* verifier config */ }),
key: "{body.conversationId}",
inbound: (e) => e.text,
send: async (message, ctx) => ({ ref: await postToMySurface(ctx.event, message) }),
renderInteraction: (pending) => ({
text: `Approve: ${pending[0].input.action}?`,
buttons: pending.map((c) => [`${c.toolCallId}:yes`, `${c.toolCallId}:no`]),
}),
onInteraction: (e) => {
const [toolCallId, choice] = e.buttonValue.split(":");
return { toolCallId, output: { approved: choice === "yes" } };
},
finalizeInteraction: async (e) => {
await editMySurface(e.messageRef, "Decision recorded");
},
});
```
The resolved `output` is whatever your tool expects to receive. The Slack default resolves `{ approved: boolean }`; if your tool needs a richer answer, supply your own `onInteraction` that returns the shape your tool reads.
## The reply after a decision
When the turn resumes, the channel reply shows the agent's answer, not the text it produced before pausing. The framework posts the assistant text generated after the tool call, so a preamble like "I'll need approval first" is not concatenated onto the final "done" message. The approval prompt itself already lives in the collapsed controls.
## Next steps
<CardGroup cols={2}>
<Card title="Channels" icon="comments" href="/webhooks/channels">
Point Slack or any surface at an agent so messages become turns.
</Card>
<Card title="Browser human-in-the-loop" icon="hand" href="/ai-chat/patterns/human-in-the-loop">
The same no-execute pause, rendered in a React frontend.
</Card>
<Card title="Sessions" icon="layer-group" href="/ai-chat/sessions">
The durable per-conversation state a channel turn runs on.
</Card>
<Card title="Filters" icon="filter" href="/webhooks/filters">
Gate which verified deliveries reach the agent.
</Card>
</CardGroup>
+96
View File
@@ -0,0 +1,96 @@
---
title: "Webhooks overview"
description: "Receive and verify webhooks from external providers as a task, with a hosted webhook URL."
sidebarTitle: "Overview"
---
A webhook is a task that runs when an external provider (Stripe, GitHub, Svix, your own service, …) sends an HTTP request. Trigger.dev gives each webhook a hosted webhook URL, verifies the incoming request's signature, and routes the verified event to your task's `onEvent` handler.
You don't host an endpoint yourself, and you don't write verification code: you declare which provider the webhook is from, point the provider at the webhook URL, and set the signing secret.
## Defining a webhook task
A webhook is created with `webhook()`. It takes an `id`, a `source` (which provider, and how to verify it), and an `onEvent` handler:
```ts
import { webhook, webhooks } from "@trigger.dev/sdk";
export const onStripeEvent = webhook({
id: "stripe-events",
source: webhooks.stripe(),
onEvent: async ({ event, headers, ctx }) => {
// `event` is the verified, parsed body
console.log("Received", event.type, event.id);
// `headers` is a standard Web Headers object
console.log(headers.get("stripe-signature"));
// `ctx` is the usual run context
console.log(ctx.run.id);
},
});
```
`onEvent` receives:
- **`event`**: the verified request body, parsed from JSON and typed by the source (see [Typing the event](/webhooks/sources#typing-the-event)).
- **`headers`**: the inbound request headers as a Web [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object (case-insensitive `.get()` / `.has()`).
- **`ctx`**: the run context, the same one regular tasks receive.
<Note>
A webhook is a first-class task kind. It runs on a real run (with retries, logs, and everything else
tasks get), and shows up in the dashboard alongside your other tasks.
</Note>
## How it works
<Steps>
<Step title="Declare the webhook">
Define a `webhook()` with a `source`. The source is a provider preset (like `webhooks.stripe()`)
or a `webhooks.custom()` config. See [Sources and verification](/webhooks/sources).
</Step>
<Step title="Connect a provider">
Deploying the webhook creates an endpoint with a hosted webhook URL. Set its signing secret and
point your provider at the URL. See [Connecting a provider](/webhooks/connect).
</Step>
<Step title="Receive verified events">
Each inbound request is verified, recorded as a delivery, and routed to a run that calls your
`onEvent`. See [Deliveries and endpoints](/webhooks/deliveries).
</Step>
</Steps>
## Beyond fan-out
A few things build on the basic model:
- **[Filters](/webhooks/filters)** gate which deliveries run. A non-matching delivery is recorded but never triggers a run.
- **[Session routing](/webhooks/session-routing)** sends deliveries that share a key to one durable session (per customer, installation, or issue) instead of a fresh run each time.
- **[Channels](/webhooks/channels)** turn a webhook into a chat frontend: inbound messages become agent turns, and the agent's reply posts back to the surface.
- **[Human-in-the-loop](/webhooks/human-in-the-loop)** adds approvals and interactive controls over a channel, like Slack approve and deny buttons.
<CardGroup cols={2}>
<Card title="Sources and verification" icon="shield-check" href="/webhooks/sources">
Provider presets, custom verification, and typing the event.
</Card>
<Card title="Connecting a provider" icon="plug" href="/webhooks/connect">
The webhook URL and signing secret.
</Card>
<Card title="Deliveries and endpoints" icon="inbox" href="/webhooks/deliveries">
Observe inbound requests in the dashboard.
</Card>
<Card title="Filters" icon="filter" href="/webhooks/filters">
Route only the deliveries you care about.
</Card>
<Card title="Session routing" icon="arrows-to-dot" href="/webhooks/session-routing">
Route deliveries to a durable per-key session.
</Card>
<Card title="Channels" icon="comments" href="/webhooks/channels">
Turn a webhook into a chat frontend for an agent.
</Card>
<Card title="Human-in-the-loop" icon="user-check" href="/webhooks/human-in-the-loop">
Approvals and interactive controls over a channel.
</Card>
<Card title="Scheduled tasks" icon="clock" href="/tasks/scheduled">
The other declarative task trigger.
</Card>
</CardGroup>
+96
View File
@@ -0,0 +1,96 @@
---
title: "Routing to a session"
description: "Route verified webhook deliveries to a durable per-key session instead of a fresh run."
sidebarTitle: "Session routing"
---
A plain [`webhook()`](/webhooks/overview) runs a fresh, stateless run for every delivery. Sometimes you want the opposite: deliveries that share a key (a customer, an installation, an issue) should land on **one durable [session](/ai-chat/sessions)** and be handled in order, with state carried across them. That is what session routing does.
Instead of a handler, you declare a `chat.event` and list it on an agent. The verified delivery is routed to a find-or-created session and arrives as an [action](/ai-chat/actions).
## Declaring a chat event
`chat.event` describes the routing only: the [source](/webhooks/sources) to verify, a `key` that identifies the session, and a `type` label for the delivered action. It has no handler.
```ts
import { webhooks } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
export const orderEvents = chat.event({
id: "order-events",
source: webhooks.stripe(),
// one session per customer
key: "{body.data.object.customer}",
type: "order.event",
});
```
### The key
The `key` is what makes routing durable: two deliveries that resolve to the same key reach the same session; a different key gets its own. It is a template string, evaluated server-side at ingest, whose `{...}` placeholders read three namespaces:
- `{body.*}`: the parsed body (a bare `{customer}` defaults to the body namespace).
- `{webhook.*}`: endpoint metadata (`externalRef`, `tenantId`, `id`, `source`, `deliveryId`).
- `{header.name}`: an inbound header.
[Filter](/webhooks/filters) expressions read this same parsed body, but spell it `event.*` rather than `{body.*}`.
Compose several placeholders into one key: `"{webhook.externalRef}-{body.issue.id}"`. Each placeholder is checked against the event type at build time, so a bad field is a red squiggle on the `key` line, not a runtime surprise.
### The `type` label
`type` is a name you choose for the delivered action. It flows straight through to `action.type` on the envelope, so your handler can tell webhook actions apart from each other and from browser actions. It is optional and defaults to the descriptor `id`.
## Handling deliveries on an agent
List the descriptor on a [`chat.agent`](/ai-chat/overview) (or a session agent). The delivery arrives at `onAction`, not as a chat message:
```ts
import { chat } from "@trigger.dev/sdk/ai";
import { orderEvents } from "./order-events";
export const orderAgent = chat.agent({
id: "order-agent",
events: [orderEvents],
onAction: async ({ action }) => {
switch (action.type) {
case "order.event":
// action.event is the verified Stripe event, fully typed
console.log("order for", action.event.data.object.customer);
break;
}
},
run: async ({ messages, signal }) => {
/* ... */
},
});
```
`action.type` is a closed union of the `type` labels of the events you listed, and each arm narrows `action.event` to that event's payload type. The full envelope is `{ type, event, source, headers, deliveryId }`.
Because it is an [action](/ai-chat/actions), the handler is not a turn: it can mutate session state and, if you return a `streamText` result, produce a model response. Webhook actions bypass your `actionSchema` (their shape is fixed and already typed).
<Note>
The descriptor never names the agent, and the agent references the descriptor, so there is no
circular dependency. Whichever agent lists it becomes the routing target.
</Note>
## One endpoint per agent
Listing a descriptor on an agent creates that agent's own endpoint, with its own [webhook URL and signing secret](/webhooks/connect). If two agents list the same descriptor, you get two endpoints, each routing to its own agent, and you point the provider at both URLs.
An exported `chat.event` that no agent lists routes nothing. `trigger dev` warns about it (it is not an error, since declaring the descriptor before wiring the agent is a normal step), so wire it onto an agent once you are ready.
## Filtering
A [`filter`](/webhooks/filters) works here exactly as on a fan-out webhook: a non-matching delivery is recorded `FILTERED` and never reaches the session.
```ts
export const orderEvents = chat.event({
id: "order-events",
source: webhooks.stripe(),
key: "{body.data.object.customer}",
type: "order.event",
filter: "event.type == 'payment_intent.succeeded'",
});
```
+115
View File
@@ -0,0 +1,115 @@
---
title: "Sources and verification"
description: "Provider presets, custom verification config, and typing the webhook event."
sidebarTitle: "Sources & verification"
---
A webhook's `source` tells Trigger.dev which provider the request is from and how to verify it. Use a built-in preset, or `webhooks.custom()` for a provider without one.
## Presets
Built-in presets know the provider's signature scheme, so you don't configure anything:
<CodeGroup>
```ts Stripe
import { webhook, webhooks } from "@trigger.dev/sdk";
export const stripeWebhook = webhook({
id: "stripe",
source: webhooks.stripe(),
onEvent: async ({ event }) => {
if (event.type === "payment_intent.succeeded") {
// ...
}
},
});
```
```ts GitHub
export const githubWebhook = webhook({
id: "github",
source: webhooks.github(),
onEvent: async ({ event, headers }) => {
// GitHub puts the event type in a header
console.log(headers.get("x-github-event"));
},
});
```
```ts Svix
// Also covers Clerk, Resend, and other Svix-powered providers
export const svixWebhook = webhook({
id: "svix",
source: webhooks.svix(),
onEvent: async ({ event }) => {
// ...
},
});
```
</CodeGroup>
The available presets are `stripe()`, `github()`, `svix()`, `square()`, and `discord()`.
## Custom providers
For a provider without a preset, `webhooks.custom()` describes the scheme as data. For example, an HMAC-SHA256 signature over the raw body, in a custom header:
```ts
export const customWebhook = webhook({
id: "custom",
source: webhooks.custom<{ id: string; message: string }>({
scheme: "hmac",
algorithm: "sha256",
encoding: "hex",
signatureHeader: "x-webhook-signature",
signingString: "raw",
idempotencyField: { from: "body", name: "id" },
}),
onEvent: async ({ event }) => {
console.log(event.message);
},
});
```
<Tip>
Reach for a preset first; drop to `custom()` for the long tail. A custom config can almost always
express a provider's scheme without any code.
</Tip>
## Typing the event
Presets ship a sensible default event type, and they're generic, so you can plug in the provider's official type for full type-safety and autocomplete:
```ts
import type Stripe from "stripe";
import { webhooks } from "@trigger.dev/sdk";
// `event` is now the full, discriminated Stripe.Event union
webhooks.stripe<Stripe.Event>();
```
For `webhooks.custom<T>()`, pass your own event type as `T`.
<Note>
The event is typed but not re-validated against that type at runtime: once the signature is
verified, the body is trusted (the same model the official provider SDKs use). If you want runtime
validation, validate `event` inside `onEvent`.
</Note>
## How verification works
Every inbound request is verified before your task runs. Trigger.dev checks the signature, the
timestamp (for replay protection, where the provider supplies one), and derives the idempotency key
from the provider's event id. A request that fails verification gets a `400` and never creates a run.
Presets handle this for you. Under the hood, every scheme is one of:
- **`hmac`**: HMAC over the raw body or a templated signing string, signature in a header. The header can be a raw value, a prefixed one (like GitHub's `sha256=…`), or a structured one (like Stripe's `t=…,v1=…`).
- **`shared-secret`**: a static token compared in a header, bearer, basic auth, or the body.
- **`url-secret`**: a secret in the URL path or query string.
- **`asymmetric`**: public-key signatures (Ed25519, ECDSA, RSA). You store the provider's public key instead of a shared secret.
Once a request is verified, see [Connecting a provider](/webhooks/connect) for how to point the
provider at the webhook URL and set the secret.
+12 -1
View File
@@ -32,7 +32,7 @@ import type { CliApiClient } from "../apiClient.js";
import { copySkillFolders } from "../build/bundleSkills.js";
import type { DevCommandOptions } from "../commands/dev.js";
import { DevRunController } from "../entryPoints/dev-run-controller.js";
import { cliLink, prettyError } from "../utilities/cliOutput.js";
import { cliLink, prettyError, prettyWarning } from "../utilities/cliOutput.js";
import { devBranchPathSegment } from "../utilities/devBranch.js";
import { eventBus } from "../utilities/eventBus.js";
import { resolveLocalEnvVars } from "../utilities/localEnvVars.js";
@@ -382,6 +382,16 @@ class DevSupervisor implements WorkerRuntime {
return;
}
// Non-blocking nudge: a chat.event that no agent lists routes nothing. Common (and fine)
// mid-development while you wire up the chat.agent, so warn rather than fail.
const unclaimedSessionWebhooks = backgroundWorker.manifest.unclaimedSessionWebhooks ?? [];
if (unclaimedSessionWebhooks.length > 0) {
prettyWarning(
`Unclaimed chat.event: ${unclaimedSessionWebhooks.join(", ")}`,
"Not listed on any agent's `events: [...]`, so deliveries won't be routed anywhere. Add each to a chat.agent once you wire it up."
);
}
const sourceFiles = resolveSourceFiles(manifest.sources, backgroundWorker.manifest.tasks);
const backgroundWorkerBody: CreateBackgroundWorkerRequestBody = {
@@ -391,6 +401,7 @@ class DevSupervisor implements WorkerRuntime {
cliPackageVersion: manifest.cliPackageVersion,
tasks: backgroundWorker.manifest.tasks,
prompts: backgroundWorker.manifest.prompts,
webhooks: backgroundWorker.manifest.webhooks,
queues: backgroundWorker.manifest.queues,
contentHash: manifest.contentHash,
sourceFiles,
@@ -195,6 +195,8 @@ await sendMessageInCatalog(
tasks,
prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()),
skills: resourceCatalog.listSkillManifests(),
webhooks: resourceCatalog.listWebhookManifests(),
unclaimedSessionWebhooks: resourceCatalog.listUnclaimedSessionWebhooks(),
queues: resourceCatalog.listQueueManifests(),
configPath: buildManifest.configPath,
runtime: buildManifest.runtime,
@@ -191,6 +191,8 @@ await sendMessageInCatalog(
tasks,
prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()),
skills: resourceCatalog.listSkillManifests(),
webhooks: resourceCatalog.listWebhookManifests(),
unclaimedSessionWebhooks: resourceCatalog.listUnclaimedSessionWebhooks(),
queues: resourceCatalog.listQueueManifests(),
configPath: buildManifest.configPath,
runtime: buildManifest.runtime,
+76
View File
@@ -0,0 +1,76 @@
{
"name": "@trigger.dev/slack",
"version": "4.5.0-rc.7",
"description": "Slack chat frontend (channel) for trigger.dev agents",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/triggerdotdev/trigger.dev",
"directory": "packages/slack"
},
"type": "module",
"files": [
"dist"
],
"tshy": {
"selfLink": false,
"main": true,
"module": true,
"project": "./tsconfig.src.json",
"exclude": [
"./src/**/*.test.ts"
],
"exports": {
"./package.json": "./package.json",
".": "./src/index.ts"
},
"sourceDialects": [
"@triggerdotdev/source"
]
},
"scripts": {
"clean": "rimraf dist .tshy .tshy-build .turbo",
"build": "tshy && pnpm run update-version",
"dev": "tshy --watch",
"typecheck": "tsc --noEmit -p tsconfig.src.json",
"test": "vitest",
"update-version": "tsx ../../scripts/updateVersion.ts",
"check-exports": "attw --pack ."
},
"dependencies": {
"@trigger.dev/core": "workspace:4.5.0-rc.7"
},
"peerDependencies": {
"@trigger.dev/sdk": "workspace:^4.5.0-rc.7"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@trigger.dev/sdk": "workspace:4.5.0-rc.7",
"rimraf": "6.0.1",
"tshy": "^3.0.2",
"tsx": "4.17.0"
},
"engines": {
"node": ">=18.20.0"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"@triggerdotdev/source": "./src/index.ts",
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.js"
}
}
},
"main": "./dist/commonjs/index.js",
"types": "./dist/commonjs/index.d.ts",
"module": "./dist/esm/index.js"
}
+307
View File
@@ -0,0 +1,307 @@
import { describe, expect, it, vi } from "vitest";
import { mentions, slack, toSlackMrkdwn, type SlackMessageEvent } from "./index.js";
const messageEvent = (
over: Partial<NonNullable<SlackMessageEvent["event"]>> = {}
): SlackMessageEvent => ({
type: "event_callback",
event: { type: "message", channel: "C9", ts: "1699999999.0001", text: "hi", ...over },
});
describe("slack channel", () => {
it("default inbound strips a leading bot mention", () => {
const c = slack({ id: "s1", token: "xoxb-t" });
expect(c.inbound(messageEvent({ text: "<@U123> hello there" }))).toBe("hello there");
expect(c.inbound(messageEvent({ text: "plain" }))).toBe("plain");
});
it("composes the self-message guard with a user filter, and always admits interactivity", () => {
const guardOnly = slack({ id: "s2", token: "t" });
expect(guardOnly.filter).toContain("event.event.type == 'message'");
expect(guardOnly.filter).toContain("event.event.bot_id == null");
expect(guardOnly.filter).toContain(
"event.event.subtype in [null, 'file_share', 'thread_broadcast']"
);
expect(guardOnly.filter).toContain("event.type == 'block_actions'");
const withUser = slack({ id: "s3", token: "t", filter: "event.event.channel == 'C1'" });
expect(withUser.filter).toContain("&& (event.event.channel == 'C1')");
expect(withUser.filter).toContain("event.type == 'block_actions'");
});
it("keys one session per thread, converging message events and interactivity", () => {
const c = slack({ id: "s4", token: "t" });
expect(c.key).toBe(
"{body.team_id || body.team.id}:{body.event.channel || body.container.channel_id}:{body.event.thread_ts || body.event.ts || body.container.thread_ts || body.container.message_ts}"
);
});
it("renderInteraction produces Block Kit approve/deny buttons carrying the toolCallId", () => {
const c = slack({ id: "s-hitl", token: "t" });
const msg = c.renderInteraction?.(
[{ toolCallId: "call-1", toolName: "requestApproval", input: { amount: 50 } }],
{
event: messageEvent(),
deliveryId: "d1",
}
);
const values = (msg?.blocks as any[]).flatMap((b) => b.elements ?? []).map((e: any) => e.value);
expect(values).toContain("call-1::approve");
expect(values).toContain("call-1::deny");
});
it("onInteraction resolves a block_actions click to a tool output; ignores messages", () => {
const c = slack({ id: "s-hitl2", token: "t" });
const approve = c.onInteraction?.({
type: "block_actions",
actions: [{ value: "call-9::approve" }],
} as never);
expect(approve).toEqual({ toolCallId: "call-9", output: { approved: true } });
const deny = c.onInteraction?.({
type: "block_actions",
actions: [{ value: "call-9::deny" }],
} as never);
expect(deny).toEqual({ toolCallId: "call-9", output: { approved: false } });
expect(c.onInteraction?.(messageEvent())).toBeNull();
});
it("finalizeInteraction replaces the controls via response_url, dropping the buttons", async () => {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init: { body: string }) => {
calls.push({ url, body: JSON.parse(init.body) });
return { json: async () => ({ ok: true }) };
})
);
const c = slack({ id: "s-fin", token: "t" });
await c.finalizeInteraction?.(
{
type: "block_actions",
user: { id: "U42" },
response_url: "https://hooks.slack.test/r/1",
actions: [{ value: "call-1::approve" }],
message: {
blocks: [
{ type: "section", text: { type: "mrkdwn", text: "Approval needed" } },
{ type: "actions", elements: [{ type: "button", value: "call-1::approve" }] },
],
},
} as never,
{ toolCallId: "call-1", output: { approved: true } }
);
expect(calls[0]?.url).toBe("https://hooks.slack.test/r/1");
expect(calls[0]?.body.replace_original).toBe(true);
const types = (calls[0]?.body.blocks as Array<{ type: string }>).map((b) => b.type);
expect(types).not.toContain("actions");
expect(JSON.stringify(calls[0]?.body.blocks)).toContain("Approved");
vi.unstubAllGlobals();
});
it("finalizeInteraction is a no-op without a response_url", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const c = slack({ id: "s-fin2", token: "t" });
await c.finalizeInteraction?.(
{ type: "block_actions", actions: [{ value: "x::deny" }] } as never,
{ toolCallId: "x", output: { approved: false } }
);
expect(fetchMock).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("passes startOn through verbatim (not composed with the guard)", () => {
const none = slack({ id: "s5", token: "t" });
expect(none.startOn).toBeUndefined();
const summon = slack({ id: "s6", token: "t", startOn: mentions("U012BOT") });
expect(summon.startOn).toBe(
"(event.event.text contains '<@U012BOT>' || event.event.text contains '<@U012BOT|')"
);
});
it("default ack varies text on crash recovery", () => {
const c = slack({ id: "s7", token: "t" });
expect(c.ack?.(messageEvent(), { recovered: false })).toEqual({ text: "on it..." });
expect(c.ack?.(messageEvent(), { recovered: true })).toEqual({
text: "picking this back up...",
});
});
it("ack: null disables the placeholder", () => {
const c = slack({ id: "s8", token: "t", ack: null });
expect(c.ack).toBeUndefined();
});
it("a custom ack receives the recovery ctx", () => {
const c = slack({
id: "s9",
token: "t",
ack: (_e, ctx) => ({ text: ctx.recovered ? "resuming" : "starting" }),
});
expect(c.ack?.(messageEvent(), { recovered: false })).toEqual({ text: "starting" });
expect(c.ack?.(messageEvent(), { recovered: true })).toEqual({ text: "resuming" });
});
it("toSlackMrkdwn converts common markdown to Slack mrkdwn", () => {
expect(toSlackMrkdwn("**bold**")).toBe("*bold*");
expect(toSlackMrkdwn("__bold__")).toBe("*bold*");
expect(toSlackMrkdwn("## Heading")).toBe("*Heading*");
expect(toSlackMrkdwn("- one\n- two")).toBe("• one\n• two");
expect(toSlackMrkdwn("[docs](https://trigger.dev)")).toBe("<https://trigger.dev|docs>");
expect(toSlackMrkdwn("~~gone~~")).toBe("~gone~");
// A real model reply: heading + bold + bullets in one string.
expect(toSlackMrkdwn("## Help\n\nI can do **stuff**:\n- a\n- b")).toBe(
"*Help*\n\nI can do *stuff*:\n• a\n• b"
);
});
it("mentions() builds a mention predicate for one or many bot ids", () => {
expect(mentions("U1")).toBe(
"(event.event.text contains '<@U1>' || event.event.text contains '<@U1|')"
);
expect(mentions("U1", "U2")).toContain("<@U1>");
expect(mentions("U1", "U2")).toContain("<@U2|");
expect(() => mentions()).toThrow(/at least one/);
});
it("send posts then edits, threading the ref and using the bot token", async () => {
const calls: Array<{ url: string; body: Record<string, unknown>; auth: unknown }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init: { body: string; headers: Record<string, string> }) => {
calls.push({ url, body: JSON.parse(init.body), auth: init.headers.authorization });
return { json: async () => ({ ok: true, ts: "1700000000.0001" }) };
})
);
const c = slack({ id: "s5", token: "xoxb-secret", apiBaseUrl: "https://mock.slack" });
const event = messageEvent();
const ackRes = await c.send!(
{ text: "on it..." },
{
event,
deliveryId: "d1",
mode: "final",
final: false,
}
);
expect(ackRes.ref).toBe("1700000000.0001");
expect(calls[0]?.url).toBe("https://mock.slack/chat.postMessage");
expect(calls[0]?.auth).toBe("Bearer xoxb-secret");
expect(calls[0]?.body.channel).toBe("C9");
expect(calls[0]?.body.thread_ts).toBe("1699999999.0001");
await c.send!(
{ text: "done" },
{
event,
deliveryId: "d1",
previousRef: ackRes.ref,
mode: "final",
final: true,
}
);
expect(calls[1]?.url).toBe("https://mock.slack/chat.update");
expect(calls[1]?.body.ts).toBe("1700000000.0001");
expect(calls[1]?.body.text).toBe("done");
vi.unstubAllGlobals();
});
it("send targets the thread from a block_actions payload (HITL resume egress)", async () => {
const calls: Array<{ body: Record<string, unknown> }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init: { body: string }) => {
calls.push({ body: JSON.parse(init.body) });
return { json: async () => ({ ok: true, ts: "1700000000.9" }) };
})
);
const c = slack({ id: "s-resume", token: "t", apiBaseUrl: "https://mock.slack" });
const interaction = {
type: "block_actions",
team: { id: "T1" },
container: {
type: "message",
channel_id: "C42",
thread_ts: "1699999999.0001",
message_ts: "1700000000.5",
},
actions: [{ value: "call-1::approve" }],
};
await c.send!(
{ text: "refund approved and processed" },
{
event: interaction as never,
deliveryId: "d-resume",
mode: "final",
final: true,
}
);
expect(calls[0]?.body.channel).toBe("C42");
expect(calls[0]?.body.thread_ts).toBe("1699999999.0001");
vi.unstubAllGlobals();
});
it("send throws when the bot is not in the channel", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => ({ ok: false, error: "not_in_channel" }) }))
);
const c = slack({ id: "s6", token: "t", apiBaseUrl: "https://mock.slack" });
await expect(
c.send!({ text: "x" }, { event: messageEvent(), deliveryId: "d", mode: "final", final: true })
).rejects.toThrow(/not_in_channel/);
vi.unstubAllGlobals();
});
it("react adds/removes an emoji on the triggering message (colons stripped)", async () => {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init: { body: string }) => {
calls.push({ url, body: JSON.parse(init.body) });
return { json: async () => ({ ok: true }) };
})
);
const c = slack({
id: "s7",
token: "xoxb-secret",
apiBaseUrl: "https://mock.slack",
reactions: { working: "eyes", done: "white_check_mark" },
});
expect(c.reactions).toEqual({ working: "eyes", done: "white_check_mark" });
await c.react!({ name: "eyes" }, { event: messageEvent(), deliveryId: "d1" });
expect(calls[0]?.url).toBe("https://mock.slack/reactions.add");
expect(calls[0]?.body).toMatchObject({
channel: "C9",
timestamp: "1699999999.0001",
name: "eyes",
});
await c.react!(
{ name: ":white_check_mark:", remove: true },
{ event: messageEvent(), deliveryId: "d1" }
);
expect(calls[1]?.url).toBe("https://mock.slack/reactions.remove");
expect(calls[1]?.body.name).toBe("white_check_mark");
vi.unstubAllGlobals();
});
it("react swallows already_reacted (idempotent)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => ({ ok: false, error: "already_reacted" }) }))
);
const c = slack({ id: "s8", token: "t", apiBaseUrl: "https://mock.slack" });
await expect(
c.react!({ name: "eyes" }, { event: messageEvent(), deliveryId: "d1" })
).resolves.toBeUndefined();
vi.unstubAllGlobals();
});
});
+409
View File
@@ -0,0 +1,409 @@
import { chat } from "@trigger.dev/sdk/ai";
import type {
ChannelAckCtx,
ChannelConnector,
ChannelInteractionCtx,
ChannelInteractionResolution,
ChannelMessage,
ChannelMessageInput,
ChannelPendingToolCall,
ChannelReaction,
ChannelReactCtx,
ChannelReactions,
ChannelReply,
ChannelSendCtx,
} from "@trigger.dev/sdk/ai";
import type {
WebhookHandshakeConfig,
WebhookHmacConfig,
WebhookSource,
} from "@trigger.dev/core/v3";
// A minimal Slack Events API envelope; pass your own event type for fuller typing.
export type SlackMessageEvent = {
type: string;
event_id?: string;
team_id?: string;
event?: {
type: string;
subtype?: string;
text?: string;
user?: string;
channel?: string;
ts?: string;
thread_ts?: string;
bot_id?: string;
};
};
// Slack signs `X-Slack-Signature: v0=<hex>` over `v0:{timestamp}:{body}`; the timestamp rides in
// `X-Slack-Request-Timestamp`. You paste the Slack signing secret as the endpoint's signing secret.
const SLACK_VERIFIER: WebhookHmacConfig = {
scheme: "hmac",
algorithm: "sha256",
encoding: "hex",
signatureHeader: "x-slack-signature",
signature: { fieldSeparator: "=", field: "v0" },
timestamp: {
source: { from: "header", name: "x-slack-request-timestamp" },
toleranceSeconds: 300,
},
signingString: { template: "v0:{timestamp}:{body}" },
idempotencyField: { from: "body", name: "event_id" },
formPayload: { field: "payload" },
};
// Slack's Event Subscriptions url_verification handshake: echo the challenge, do not record/route.
const SLACK_HANDSHAKE: WebhookHandshakeConfig = {
matchPath: "type",
matchValue: "url_verification",
respondPath: "challenge",
};
/**
* One session per Slack thread, for BOTH message events and block_actions interactivity (a button click
* on the in-thread ack). thread_ts is only on replies, so a thread-STARTING message falls back to ts;
* interactivity carries the same thread via `container.thread_ts` / `container.message_ts`. The `||`
* operator resolves to the first non-empty path, so both surfaces converge on one externalId.
*/
const DEFAULT_KEY =
"{body.team_id || body.team.id}:{body.event.channel || body.container.channel_id}:{body.event.thread_ts || body.event.ts || body.container.thread_ts || body.container.message_ts}";
/**
* Mandatory loop guard for MESSAGE events: `bot_id == null` drops the agent's own posts (no reply loop);
* the subtype allowlist keeps real user messages (absent subtype matches `null`) and drops system/edit
* events. Interactivity (block_actions) is a separate surface, admitted via INTERACTIVITY_PASS.
*/
const SELF_MESSAGE_GUARD =
"event.event.type == 'message' && event.event.bot_id == null && event.event.subtype in [null, 'file_share', 'thread_broadcast']";
/** Interactivity callbacks (button clicks) always pass the loop guard; onInteraction resolves them. */
const INTERACTIVITY_PASS = "event.type == 'block_actions'";
const SLACK_API_BASE_URL = "https://slack.com/api";
export type SlackToken<TEvent> = string | ((event: TEvent) => string | Promise<string>);
export type SlackChannelOptions<TEvent> = {
id: string;
/** Bot token (xoxb-...). A string for one workspace, or a resolver keyed on the event's team_id. */
token: SlackToken<TEvent>;
/** Session key template. Defaults to one session per thread. */
key?: string;
/** Map a Slack event to the turn's message. Defaults to the message text with the bot mention stripped. */
inbound?: (event: TEvent) => ChannelMessageInput;
/** Map the agent's reply to a Slack message. Defaults to the reply text (null posts nothing). */
outbound?: (reply: ChannelReply) => ChannelMessage | null;
/** Placeholder posted while the agent works, then edited to the answer. `null` posts only the answer. */
ack?: ((event: TEvent, ctx: ChannelAckCtx) => ChannelMessage | null) | null;
/** Extra server-side filter, composed AND with the mandatory self-message guard. */
filter?: string;
/**
* Only start a NEW session when an event matches this filter; existing threads always resume. Use it
* to summon the bot on mention, then continue the thread silently, e.g.
* `startOn: "event.event.text contains '<@U012BOT>'"` (your bot's user id).
*/
startOn?: string;
/** "final" (default): ack then one edit. "stream": debounced live edits (fast-follow). */
delivery?: "final" | "stream";
/**
* Lifecycle emoji reactions on the user's message (names without colons, e.g. "eyes"): `working` is
* added while the turn runs and swapped to `done`, or `error` on failure. Needs the `reactions:write`
* scope. The agent can also react itself via `run({ channel })`.
*/
reactions?: ChannelReactions<TEvent>;
/** Override the Slack Web API base URL (for testing against a mock). */
apiBaseUrl?: string;
};
/**
* Build a predicate that matches when the bot is @mentioned, for `startOn` (summon-on-mention) or
* `filter`. Pass your bot's user id(s) from the Slack app (they start with `U`); matches both the plain
* `<@U012BOT>` and labelled `<@U012BOT|name>` mention forms:
* `slack({ id, token, startOn: mentions("U012BOT") })`.
*/
export function mentions(...botUserIds: string[]): string {
const ids = botUserIds.filter(Boolean);
if (ids.length === 0) throw new Error("mentions() requires at least one bot user id");
const clauses = ids.flatMap((id) => [
`event.event.text contains '<@${id}>'`,
`event.event.text contains '<@${id}|'`,
]);
return `(${clauses.join(" || ")})`;
}
/**
* Slack as a chat frontend for an agent. List on `chat.agent({ channels: [slack({...})] })`: verified
* Slack messages in a thread are routed to a durable per-thread session and run as turns, and the reply
* is posted back to the thread. Set the endpoint's signing secret to your Slack signing secret; pass the
* bot token as `token`. Subscribe the app to `message.channels` (and invite the bot to the channel).
*/
export function slack<TEvent = SlackMessageEvent>(
options: SlackChannelOptions<TEvent>
): ChannelConnector<TEvent> {
const apiBaseUrl = options.apiBaseUrl ?? SLACK_API_BASE_URL;
const source: WebhookSource<TEvent> = {
provider: "slack",
verifier: { kind: "config", config: SLACK_VERIFIER, handshake: SLACK_HANDSHAKE },
secretProvisioning: "integrator",
};
const messageFilter = options.filter
? `${SELF_MESSAGE_GUARD} && (${options.filter})`
: SELF_MESSAGE_GUARD;
const filter = `${INTERACTIVITY_PASS} || (${messageFilter})`;
const ack =
options.ack === null
? undefined
: (options.ack ??
((_event: TEvent, ctx: ChannelAckCtx) => ({
text: ctx.recovered ? "picking this back up..." : "on it...",
})));
return chat.channels.custom<WebhookSource<TEvent>>({
id: options.id,
source,
key: options.key ?? DEFAULT_KEY,
inbound: options.inbound ?? (defaultSlackInbound as (event: TEvent) => ChannelMessageInput),
outbound: options.outbound ?? defaultSlackOutbound,
ack,
send: makeSlackSend(options.token, apiBaseUrl),
renderInteraction: defaultSlackRenderInteraction as (
pending: ChannelPendingToolCall[],
ctx: ChannelInteractionCtx<TEvent>
) => ChannelMessage | null,
onInteraction: defaultSlackOnInteraction as (
event: TEvent
) => ChannelInteractionResolution | null,
finalizeInteraction: defaultSlackFinalizeInteraction as (
event: TEvent,
resolution: ChannelInteractionResolution
) => Promise<void>,
// Composed at runtime (guard + optional user filter), so it bypasses the literal-only filter
// validator; the user's `filter` arg was already validated on the way in.
filter: filter as never,
startOn: options.startOn as never,
delivery: options.delivery ?? "final",
react: makeSlackReact(options.token, apiBaseUrl),
reactions: options.reactions,
});
}
/**
* Default HITL controls: render each pending human-decision tool as a Block Kit approve/deny pair. The
* button `value` carries `${toolCallId}::${decision}` so `onInteraction` can resolve the exact tool.
*/
function defaultSlackRenderInteraction(
pending: ChannelPendingToolCall[],
_ctx: ChannelInteractionCtx<unknown>
): ChannelMessage | null {
const call = pending[0];
if (!call) return null;
const detail = call.input !== undefined ? "\n```" + safeStringify(call.input) + "```" : "";
return {
text: `Approval needed: ${call.toolName}`,
blocks: [
{
type: "section",
text: { type: "mrkdwn", text: `*Approval needed* for \`${call.toolName}\`${detail}` },
},
{
type: "actions",
elements: [
{
type: "button",
action_id: "trigger_hitl_approve",
style: "primary",
text: { type: "plain_text", text: "Approve" },
value: `${call.toolCallId}::approve`,
},
{
type: "button",
action_id: "trigger_hitl_deny",
style: "danger",
text: { type: "plain_text", text: "Deny" },
value: `${call.toolCallId}::deny`,
},
],
},
],
};
}
/**
* Default interaction resolver: a `block_actions` button click resolves the tool named in its `value`
* (`${toolCallId}::approve|deny`) to `{ approved }`. Any non-interactivity event returns null (the
* normal message path handles it).
*/
function defaultSlackOnInteraction(event: unknown): ChannelInteractionResolution | null {
const payload = event as { type?: string; actions?: Array<{ value?: string }> };
if (payload?.type !== "block_actions") return null;
const action = (payload.actions ?? []).find(
(a) => typeof a?.value === "string" && a.value.includes("::")
);
if (!action?.value) return null;
const [toolCallId, decision] = action.value.split("::");
if (!toolCallId || (decision !== "approve" && decision !== "deny")) return null;
return { toolCallId, output: { approved: decision === "approve" } };
}
/**
* After a decision, collapse the controls via the interaction's `response_url` (Slack's documented path:
* the click gets a bare 200 ack, then `response_url` accepts `replace_original` for up to 30 minutes).
* Keeps the original context blocks, drops the `actions` block, and appends the outcome, so the buttons
* can't be clicked again. No-op when the payload carries no `response_url` (e.g. a synthetic test event).
*/
async function defaultSlackFinalizeInteraction(
event: unknown,
resolution: ChannelInteractionResolution
): Promise<void> {
const payload = event as {
response_url?: string;
user?: { id?: string };
message?: { blocks?: unknown[] };
};
const responseUrl = payload?.response_url;
if (!responseUrl) return;
const approved = (resolution.output as { approved?: boolean } | undefined)?.approved === true;
const decision = approved ? "Approved" : "Denied";
const icon = approved ? ":white_check_mark:" : ":x:";
const who = payload.user?.id ? ` by <@${payload.user.id}>` : "";
const original = Array.isArray(payload.message?.blocks) ? payload.message!.blocks : [];
const kept = original.filter((b) => (b as { type?: string })?.type !== "actions");
const blocks = [
...kept,
{ type: "context", elements: [{ type: "mrkdwn", text: `${icon} *${decision}*${who}` }] },
];
await fetch(responseUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ replace_original: true, text: `${decision}${who}`, blocks }),
});
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
// Strip a leading bot mention (`<@U123> hi` -> `hi`) so the agent sees the plain text.
function defaultSlackInbound(event: SlackMessageEvent): string {
return (event.event?.text ?? "").replace(/^\s*<@[A-Z0-9]+>\s*/i, "");
}
/**
* Convert common GitHub-flavored markdown (what a model emits) to Slack mrkdwn: `**bold**` -> `*bold*`,
* `#` headings -> bold, `-`/`*` bullets -> `•`, `[t](url)` -> `<url|t>`, `~~s~~` -> `~s~`. Applied by the
* default outbound; a custom `outbound` controls its own formatting (call this from it if you want it).
* Note: single-asterisk `*italic*` is left as-is, so it renders bold in Slack (rare in model output).
*/
export function toSlackMrkdwn(md: string): string {
return md
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<$2|$1>")
.replace(/^[ \t]{0,3}#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm, "*$1*")
.replace(/\*\*([^*\n]+)\*\*/g, "*$1*")
.replace(/__([^_\n]+)__/g, "*$1*")
.replace(/~~([^~\n]+)~~/g, "~$1~")
.replace(/^([ \t]*)[-*+][ \t]+/gm, "$1• ");
}
function defaultSlackOutbound(reply: ChannelReply): ChannelMessage | null {
return reply.text ? { text: toSlackMrkdwn(reply.text) } : null;
}
function makeSlackSend<TEvent>(token: SlackToken<TEvent>, apiBaseUrl: string) {
return async (
message: ChannelMessage,
ctx: ChannelSendCtx<TEvent>
): Promise<{ ref?: string }> => {
const event = ctx.event as SlackMessageEvent & {
container?: { channel_id?: string; thread_ts?: string; message_ts?: string };
channel?: { id?: string };
};
const channel = event.event?.channel ?? event.container?.channel_id ?? event.channel?.id;
const threadTs =
event.event?.thread_ts ??
event.event?.ts ??
event.container?.thread_ts ??
event.container?.message_ts;
const resolve = () => (typeof token === "function" ? token(ctx.event) : token);
let botToken = await resolve();
const blocks = (message as { blocks?: unknown }).blocks;
const rich = Array.isArray(blocks) && blocks.length > 0 ? { blocks } : {};
const post = async () =>
ctx.previousRef
? slackApi(apiBaseUrl, "chat.update", botToken, {
channel,
ts: ctx.previousRef,
text: message.text,
...rich,
})
: slackApi(apiBaseUrl, "chat.postMessage", botToken, {
channel,
thread_ts: threadTs,
text: message.text,
...rich,
});
let result = await post();
// Re-resolve once on an auth error (token rotation) when a resolver was supplied.
if (!result.ok && typeof token === "function" && isAuthError(result.error)) {
botToken = await resolve();
result = await post();
}
if (!result.ok) {
// not_in_channel / channel_not_found is the common one: the bot isn't in the channel. Surface it.
throw new Error(
`slack ${ctx.previousRef ? "chat.update" : "chat.postMessage"} failed: ${result.error}`
);
}
return { ref: ctx.previousRef ?? result.ts };
};
}
function isAuthError(error: string | undefined): boolean {
return error === "invalid_auth" || error === "token_revoked" || error === "account_inactive";
}
// Add/remove an emoji reaction on the triggering Slack message (needs the reactions:write scope).
function makeSlackReact<TEvent>(token: SlackToken<TEvent>, apiBaseUrl: string) {
return async (reaction: ChannelReaction, ctx: ChannelReactCtx<TEvent>): Promise<void> => {
const event = ctx.event as SlackMessageEvent;
const channel = event.event?.channel;
const timestamp = event.event?.ts;
const name = reaction.name.replace(/^:|:$/g, "");
if (!channel || !timestamp || !name) return;
const botToken = typeof token === "function" ? await token(ctx.event) : token;
const method = reaction.remove ? "reactions.remove" : "reactions.add";
const result = await slackApi(apiBaseUrl, method, botToken, { channel, timestamp, name });
// already_reacted / no_reaction are benign idempotent outcomes; surface anything else.
if (!result.ok && result.error !== "already_reacted" && result.error !== "no_reaction") {
throw new Error(`slack ${method} failed: ${result.error}`);
}
};
}
async function slackApi(
baseUrl: string,
method: string,
token: string,
body: Record<string, unknown>
): Promise<{ ok: boolean; ts?: string; error?: string }> {
const res = await fetch(`${baseUrl}/${method}`, {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8",
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
return (await res.json()) as { ok: boolean; ts?: string; error?: string };
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../.configs/tsconfig.base.json",
"references": [
{
"path": "./tsconfig.src.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"include": ["./src/**/*.ts"],
"exclude": ["./src/**/*.test.ts"],
"compilerOptions": {
"isolatedDeclarations": false,
"composite": true,
"sourceMap": true,
"customConditions": ["@triggerdotdev/source"],
"types": ["node"]
}
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["**/*.test.ts"],
globals: true,
},
});
+739 -8
View File
@@ -36,6 +36,14 @@ import {
type TaskWithSchema,
TRIGGER_CONTROL_SUBTYPE,
type StreamWriteResult,
type AnyChatEvent,
type ChatEventActions,
type ValidatedWebhookKey,
type ValidateWebhookFilter,
type WebhookVerifierArtifact,
type WebhookSecretProvisioning,
type AnyWebhookSource,
type InferWebhookEvent,
} from "@trigger.dev/core/v3";
import type {
FinishReason,
@@ -50,6 +58,7 @@ import type {
JSONSchema7,
Schema,
} from "ai";
import { chatEvent, normalizeKeyString } from "./webhooks.js";
// Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
// ESM-only `ai@7` (see ../imports/ai-runtime.ts).
import { type Attributes, trace } from "@opentelemetry/api";
@@ -171,6 +180,17 @@ const chatSessionHandleKey = locals.create<SessionHandle>("chat.sessionHandle");
// metadata reads this directly rather than the Session handle id.
const chatExternalIdKey = locals.create<string>("chat.externalId");
/**
* Set at boot when a continuation run inherited an interrupted turn (a
* partial assistant reply on `session.out`). Consumed by the first channel
* turn's ack so the re-emitted placeholder reads as a recovery resume rather
* than a silent duplicate. Boxed so the ack site can flip it to false.
* @internal
*/
const chatChannelRecoveryPendingKey = locals.create<{ value: boolean }>(
"chat.channelRecoveryPending"
);
/**
* S2 seq_num of the most recent `turn-complete` control record written by
* this worker. Read by `writeTurnCompleteChunk` to know what to trim back
@@ -1243,6 +1263,41 @@ function createChatAccessToken<TTask extends AnyTask>(
return auth.createTriggerPublicToken(taskId as string, { expirationTime: "24h" });
}
/**
* Mint a read-only, session-scoped token for WATCHING a chat session by its `externalId`.
*
* A session is addressed by `externalId`, so the same session is reachable from any surface that
* knows it: a web `useChat` client and a Slack thread converge on one session when they share an
* `externalId` (for a channel, the connector's `key` template produces it). This token carries
* `read:sessions:{externalId}` only (no write): the holder can subscribe to the session's `.out`
* stream (observe the conversation, hydrating history from the snapshot) but cannot append to `.in`.
*
* Use it for a dashboard viewer or a cross-surface watcher of a Slack-thread session. Run server-side
* (needs the secret key). To CONTINUE a session from another surface (drive it, not just watch), mint
* a read+write token instead ({@link createChatStartSessionAction}).
*
* @example
* ```ts
* // actions.ts
* "use server";
* import { chat } from "@trigger.dev/sdk/ai";
*
* export const watchChat = (externalId: string) => chat.createWatchToken(externalId);
* ```
*/
function createChatWatchToken(
externalId: string,
options?: { tokenTTL?: string }
): Promise<string> {
if (!externalId) {
throw new Error("chat.createWatchToken: externalId is required (the session addressing key).");
}
return auth.createPublicToken({
scopes: { read: { sessions: externalId } },
expirationTime: options?.tokenTTL ?? "1h",
});
}
// ---------------------------------------------------------------------------
// Chat transport helpers — backend side
// ---------------------------------------------------------------------------
@@ -1532,6 +1587,11 @@ export type ChatTaskRunPayload<
* `tools` option on `chat.agent`). Empty object when no `tools` were declared.
*/
tools: TTools;
/**
* Present only for a channel-delivered turn whose connector supports reactions. Lets the agent
* react to the triggering message to signal meaning (e.g. `channel.react("white_check_mark")`).
*/
channel?: ChannelRunSurface;
};
// Input streams for bidirectional chat communication
@@ -4642,12 +4702,434 @@ export type ChatResumeEvent<TClientData = unknown, TUIM extends UIMessage = UIMe
clientData?: TClientData;
};
// ── Channels: a webhook that IS the chat frontend (Slack, etc.), delivered as a TURN not an action ──
// A connector registers an agent-scoped endpoint that delivers each verified event as a channel
// message; the run applies inbound() to the raw event to get the turn's message. The loop guard is the
// server-side `filter` (an ignored event never becomes a delivery), so inbound is a pure mapper.
// The reply round-trips (egress) in-run: outbound() maps the turn's reply to a channel message, send()
// posts/edits it. delivery "final" posts an ack at turn start then edits it to the answer at turn end.
export type ChannelMessageInput = string | UIMessage;
// The channel message a provider posts. Framework requires `text`; providers extend (Slack adds blocks).
export type ChannelMessage = { text: string; [key: string]: unknown };
// What outbound() receives: enough to decide what (or whether, null) to post. For "stream", `text` is
// the accumulated text so far.
export type ChannelReply = {
text: string;
message: UIMessage;
final: boolean;
stopped: boolean;
error?: unknown;
};
export type ChannelSendCtx<TEvent = unknown> = {
event: TEvent;
deliveryId: string;
previousRef?: string;
mode: "final" | "stream";
final: boolean;
};
export type ChannelAckCtx = {
/**
* True when this ack is being re-posted because the run is recovering an
* interrupted turn after a crash/continuation. The prior run's message ref
* is gone, so egress re-emits into the thread as a fresh message; a
* connector can vary the placeholder text to read as a deliberate resume
* ("picking this back up...") rather than a silent duplicate.
*/
recovered: boolean;
};
/**
* A tool call the turn paused on, awaiting a human decision (HITL). `renderInteraction` maps these to
* the controls posted in the thread; the callback resolves one by `toolCallId`.
*/
export type ChannelPendingToolCall = { toolCallId: string; toolName: string; input?: unknown };
/**
* A verified interaction callback resolved to a tool output. `onInteraction` returns this to resume the
* paused run: the framework stitches `output` onto the pending tool part (by `toolCallId`) and continues.
*/
export type ChannelInteractionResolution = { toolCallId: string; output: unknown };
export type ChannelInteractionCtx<TEvent = unknown> = { event: TEvent; deliveryId: string };
// A reaction to add (or remove) on the triggering message. `name` is a provider emoji id (e.g. "eyes").
export type ChannelReaction = { name: string; remove?: boolean };
export type ChannelReactCtx<TEvent = unknown> = { event: TEvent; deliveryId: string };
// A lifecycle reaction choice: one emoji name, an array (one picked at random per turn), or a resolver
// of the event returning either (null/undefined to skip). Names are provider emoji ids without colons.
export type ChannelReactionChoice<TEvent = unknown> =
| string
| string[]
| ((
event: TEvent
) => string | string[] | null | undefined | Promise<string | string[] | null | undefined>);
// Lifecycle reactions the run loop applies to the user's message around a turn (add working at start,
// swap to done at complete, error on failure). Any subset; requires the connector's `react`.
export type ChannelReactions<TEvent = unknown> = {
working?: ChannelReactionChoice<TEvent>;
done?: ChannelReactionChoice<TEvent>;
error?: ChannelReactionChoice<TEvent>;
};
// Handed to run() for a channel-delivered turn (when the connector supports reactions), so the agent can
// react to the triggering message to signal meaning. Best-effort: a failed reaction never throws.
export type ChannelRunSurface = {
react: (name: string) => Promise<void>;
unreact: (name: string) => Promise<void>;
};
// Reserved for 2c: resolve a provider credential keyed by the incoming event's installation (team_id),
// not the connector (one connector serves many Slack workspaces). The in-run send() calls it at post time.
export type ResolveChannelToken<TEvent = unknown> = (event: TEvent) => Promise<string>;
declare const channelEventPhantom: unique symbol;
export interface ChannelConnector<TEvent = unknown> {
id: string;
source: string;
key: string; // compiled canonical key template
verifierArtifact: WebhookVerifierArtifact;
secretProvisioning?: WebhookSecretProvisioning;
filter?: string;
// Gate session creation: a resolved key with no session is only started when the event matches this
// filter (existing sessions always resume). Absent => every routed event can start one.
startOn?: string;
inbound: (event: TEvent) => ChannelMessageInput;
// Egress (optional; a channel can be inbound-only). Fires only when `send` is set.
outbound?: (reply: ChannelReply) => ChannelMessage | null;
ack?: (event: TEvent, ctx: ChannelAckCtx) => ChannelMessage | null; // placeholder posted at turn start ("final" mode)
send?: (message: ChannelMessage, ctx: ChannelSendCtx<TEvent>) => Promise<{ ref?: string }>;
/** HITL: controls posted when a turn pauses on a human-decision tool (a tool with no `execute`). */
renderInteraction?: (
pending: ChannelPendingToolCall[],
ctx: ChannelInteractionCtx<TEvent>
) => ChannelMessage | null;
/**
* HITL: map a verified callback event to a tool resolution. Non-null resumes the paused run; null
* means treat the event as a normal inbound message (a new turn).
*/
onInteraction?: (event: TEvent) => ChannelInteractionResolution | null;
/**
* HITL: finalize the posted controls once a decision is made (edit them away, show the outcome), so
* the buttons can't be clicked again. Called with the same verified callback event when
* `onInteraction` resolves, before the run resumes. Best-effort: a throw is logged and the run continues.
*/
finalizeInteraction?: (
event: TEvent,
resolution: ChannelInteractionResolution
) => Promise<void> | void;
delivery: "final" | "stream"; // "final" (ack + edit) is v1; "stream" (debounced edits) is a fast-follow
// Add/remove a reaction on the triggering message. Powers lifecycle reactions + run()'s channel surface.
react?: (reaction: ChannelReaction, ctx: ChannelReactCtx<TEvent>) => Promise<void>;
reactions?: ChannelReactions<TEvent>;
readonly [channelEventPhantom]?: TEvent;
}
export type AnyChannelConnector = ChannelConnector<any>;
/**
* A generic chat-frontend channel over any verified source. Unlike `slack()`, you supply the egress
* `send` yourself (post/edit the reply back to your surface), so the whole round-trip is under your
* control. Use for providers without a preset, or to test the channel round-trip end to end.
*/
export function chatChannelCustom<
TSource extends AnyWebhookSource,
const TKey extends string = string,
const TFilter extends string = string,
const TStartOn extends string = string,
>(options: {
id: string;
source: TSource;
key: ValidatedWebhookKey<InferWebhookEvent<TSource>, TKey>;
inbound: (event: InferWebhookEvent<TSource>) => ChannelMessageInput;
outbound?: (reply: ChannelReply) => ChannelMessage | null;
ack?: (event: InferWebhookEvent<TSource>, ctx: ChannelAckCtx) => ChannelMessage | null;
send?: (
message: ChannelMessage,
ctx: ChannelSendCtx<InferWebhookEvent<TSource>>
) => Promise<{ ref?: string }>;
renderInteraction?: (
pending: ChannelPendingToolCall[],
ctx: ChannelInteractionCtx<InferWebhookEvent<TSource>>
) => ChannelMessage | null;
onInteraction?: (event: InferWebhookEvent<TSource>) => ChannelInteractionResolution | null;
finalizeInteraction?: (
event: InferWebhookEvent<TSource>,
resolution: ChannelInteractionResolution
) => Promise<void> | void;
react?: (
reaction: ChannelReaction,
ctx: ChannelReactCtx<InferWebhookEvent<TSource>>
) => Promise<void>;
reactions?: ChannelReactions<InferWebhookEvent<TSource>>;
filter?: TFilter & ValidateWebhookFilter<InferWebhookEvent<TSource>, TFilter>;
// Only start a new session when the event matches this filter (existing sessions always resume).
startOn?: TStartOn & ValidateWebhookFilter<InferWebhookEvent<TSource>, TStartOn>;
delivery?: "final" | "stream";
}): ChannelConnector<InferWebhookEvent<TSource>> {
const {
id,
source,
key,
inbound,
outbound,
ack,
send,
renderInteraction,
onInteraction,
finalizeInteraction,
react,
reactions,
filter,
startOn,
delivery,
} = options;
resourceCatalog.registerDeclaredSessionWebhook(id);
return {
id,
source: source.provider,
key: normalizeKeyString(key as string),
verifierArtifact: source.verifier,
secretProvisioning: source.secretProvisioning,
filter,
startOn,
inbound,
outbound,
ack,
send,
renderInteraction,
onInteraction,
finalizeInteraction,
react,
reactions,
delivery: delivery ?? "final",
} as ChannelConnector<InferWebhookEvent<TSource>>;
}
const chatChannels = {
/** A generic chat-frontend channel over any source, with your own egress. See {@link chatChannelCustom}. */
custom: chatChannelCustom,
};
// Turn a channel connector's inbound() result into a user UIMessage for the turn.
function toUserUIMessage(input: ChannelMessageInput, messageId: string): UIMessage {
if (typeof input !== "string") return input;
return { id: messageId, role: "user", parts: [{ type: "text", text: input }] } as UIMessage;
}
/**
* The assistant's closing text for a channel reply: the text after the last tool part in the message,
* so a pre-tool preamble (e.g. the HITL "I'll need approval first" line, or a "let me look that up")
* is dropped and only the answer is posted. Falls back to all text when there's no trailing text or no
* tool parts (an ordinary turn), so a plain reply is unchanged.
*/
function channelReplyText(message: UIMessage): string {
const parts = (message.parts ?? []) as any[];
const isTool = (p: any) => {
const t = p?.type;
return typeof t === "string" && (t.startsWith("tool-") || t === "dynamic-tool");
};
let lastToolIdx = -1;
parts.forEach((p, i) => {
if (isTool(p)) lastToolIdx = i;
});
const textFrom = (from: number) =>
parts
.slice(from)
.map((p) =>
p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : ""
)
.join("");
const trailing = textFrom(lastToolIdx + 1);
return trailing.trim().length > 0 ? trailing : textFrom(0);
}
/** Tool parts of a UIMessage awaiting a human answer (`input-available`), for `renderInteraction`. */
function pendingToolCallsInMessage(message: UIMessage): ChannelPendingToolCall[] {
const out: ChannelPendingToolCall[] = [];
for (const part of (message.parts ?? []) as any[]) {
if (!part || typeof part !== "object") continue;
const type = part.type;
const isTool =
typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
if (!isTool || part.state !== "input-available") continue;
const toolName =
type === "dynamic-tool" ? String(part.toolName ?? "") : String(type).slice("tool-".length);
if (typeof part.toolCallId === "string")
out.push({ toolCallId: part.toolCallId, toolName, input: part.input });
}
return out;
}
/**
* Build the slim assistant message the HITL resume path expects (mirrors `slimSubmitMessageForWire`):
* the pending tool part matched by `toolCallId` across `messages`, advanced to `output-available` with
* the interaction's output. Returns undefined when no pending part matches (a stale/duplicate callback).
*/
function buildInteractionResolutionMessage(
resolution: ChannelInteractionResolution,
messages: UIMessage[]
): UIMessage | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]!;
if (message.role !== "assistant") continue;
for (const part of (message.parts ?? []) as any[]) {
if (!part || typeof part !== "object" || part.toolCallId !== resolution.toolCallId) continue;
const type = part.type;
const isTool =
typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
if (!isTool) continue;
const slimPart: Record<string, unknown> = {
type,
toolCallId: resolution.toolCallId,
state: "output-available",
output: resolution.output,
};
if (type === "dynamic-tool" && typeof part.toolName === "string")
slimPart.toolName = part.toolName;
return { id: message.id, role: "assistant", parts: [slimPart] } as unknown as UIMessage;
}
}
return undefined;
}
// Default outbound: post the reply text, or nothing when empty (a tool-only / empty turn).
function defaultChannelOutbound(reply: ChannelReply): ChannelMessage | null {
return reply.text ? { text: reply.text } : null;
}
// Resolve a lifecycle reaction choice to one emoji name for this turn: call a resolver if given, then
// pick one at random from an array. Returns undefined to skip (no choice / empty).
export async function resolveReactionChoice(
choice: ChannelReactionChoice | undefined,
event: unknown
): Promise<string | undefined> {
if (choice == null) return undefined;
let value: string | string[] | null | undefined =
typeof choice === "function" ? await choice(event) : choice;
if (Array.isArray(value)) {
if (value.length === 0) return undefined;
value = value[Math.floor(Math.random() * value.length)];
}
return typeof value === "string" && value.length > 0 ? value : undefined;
}
// Best-effort reaction on the triggering message; a failure never breaks the turn.
async function applyChannelReaction(
connector: AnyChannelConnector | undefined,
wireEvent: { event: unknown; deliveryId: string } | undefined,
reaction: ChannelReaction
): Promise<void> {
if (!connector?.react || !wireEvent || !reaction.name) return;
try {
await connector.react(reaction, { event: wireEvent.event, deliveryId: wireEvent.deliveryId });
} catch (error) {
logger.warn("chat.agent: channel reaction failed", { error, name: reaction.name });
}
}
// The run()-facing reaction surface for a channel turn (flavor 2). Undefined when the connector has no
// `react`, so `payload.channel` is only present when reacting is actually possible.
function buildChannelRunSurface(
connector: AnyChannelConnector | undefined,
wireEvent: { event: unknown; deliveryId: string } | undefined
): ChannelRunSurface | undefined {
if (!connector?.react || !wireEvent) return undefined;
return {
react: (name) => applyChannelReaction(connector, wireEvent, { name }),
unreact: (name) => applyChannelReaction(connector, wireEvent, { name, remove: true }),
};
}
// "stream" egress: as the reply streams, debounce-edit the ack message (previousRef) with the growing
// text. Trailing-edge, one edit per interval (Slack chat.update ~1/s); the turn-complete final edit is
// the authoritative last write. Best-effort: an edit failure is logged, never fatal.
const CHANNEL_STREAM_EDIT_INTERVAL_MS = 1000;
function makeChannelStreamEditor<TEvent>(
connector: ChannelConnector<TEvent>,
channelEvent: { event: unknown; deliveryId: string },
ackRef: string
) {
const outbound = connector.outbound ?? defaultChannelOutbound;
let latest = "";
let timer: ReturnType<typeof setTimeout> | undefined;
let inFlight = false;
let stopped = false;
const edit = async () => {
if (stopped || inFlight) return;
const text = latest;
const message = outbound({
text,
message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] } as UIMessage,
final: false,
stopped: false,
});
if (!message) return;
inFlight = true;
try {
await connector.send!(message, {
event: channelEvent.event as TEvent,
deliveryId: channelEvent.deliveryId,
previousRef: ackRef,
mode: "stream",
final: false,
});
} catch (error) {
logger.warn("chat.agent: channel stream edit failed", { error });
} finally {
inFlight = false;
}
};
return {
observe(chunk: unknown) {
if (stopped) return;
const c = chunk as { type?: string; delta?: unknown };
if (c?.type === "text-delta" && typeof c.delta === "string") {
latest += c.delta;
if (!timer)
timer = setTimeout(() => {
timer = undefined;
void edit();
}, CHANNEL_STREAM_EDIT_INTERVAL_MS);
}
},
stop() {
stopped = true;
if (timer) {
clearTimeout(timer);
timer = undefined;
}
},
};
}
// The `action` type onAction receives: the actionSchema output (when set) unioned with the action
// envelopes of any listed chat.event descriptors. ChatEventActions<[]> is `never`, so it collapses
// cleanly when no events are listed; `unknown` is kept only when neither source is present.
type ChatActionType<
TActionSchema extends TaskSchema | undefined,
TW extends readonly AnyChatEvent[],
> = [TActionSchema] extends [TaskSchema]
? inferSchemaOut<TActionSchema> | ChatEventActions<TW>
: [TW] extends [readonly []]
? unknown
: ChatEventActions<TW>;
export type ChatAgentOptions<
TIdentifier extends string,
TClientDataSchema extends TaskSchema | undefined = undefined,
TUIMessage extends UIMessage = UIMessage,
TActionSchema extends TaskSchema | undefined = undefined,
TTools extends ToolSet = ToolSet,
TW extends readonly AnyChatEvent[] = [],
TChannels extends readonly AnyChannelConnector[] = [],
> = Omit<
TaskOptions<
TIdentifier,
@@ -4750,9 +5232,25 @@ export type ChatAgentOptions<
* `StreamTextResult` (auto-piped), `string`, or `UIMessage`. Returning
* `void` or nothing is the side-effect-only default.
*/
/**
* Inbound `chat.event(...)` descriptors this agent handles. Listing one registers an agent-scoped
* webhook endpoint that routes verified deliveries to this agent's session; the delivery arrives at
* `onAction` as a `{ type, event, source, headers, deliveryId }` envelope whose `type` is the
* descriptor's `type`. The same descriptor listed on another agent gets its own endpoint.
*/
events?: TW;
/**
* Inbound chat frontends (Slack, etc.) via `chat.channels.*`. Listing one registers an agent-scoped
* webhook endpoint that routes verified events to a durable per-key session and delivers them as
* turns: the connector's `inbound()` maps the raw event to the turn's message, `run()` fires as
* normal, and the reply streams back to the channel. Use the connector's `filter` to ignore events.
*/
channels?: TChannels;
onAction?: (
event: ActionEvent<
[TActionSchema] extends [TaskSchema] ? inferSchemaOut<TActionSchema> : unknown,
ChatActionType<TActionSchema, TW>,
inferSchemaOut<TClientDataSchema>,
TUIMessage
>
@@ -5395,8 +5893,18 @@ function chatAgent<
TUIMessage extends UIMessage = UIMessage,
TActionSchema extends TaskSchema | undefined = undefined,
TTools extends ToolSet = ToolSet,
const TW extends readonly AnyChatEvent[] = [],
const TChannels extends readonly AnyChannelConnector[] = [],
>(
options: ChatAgentOptions<TIdentifier, TClientDataSchema, TUIMessage, TActionSchema, TTools>
options: ChatAgentOptions<
TIdentifier,
TClientDataSchema,
TUIMessage,
TActionSchema,
TTools,
TW,
TChannels
>
): Task<TIdentifier, ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>, unknown> {
const {
run: userRun,
@@ -5408,6 +5916,8 @@ function chatAgent<
onValidateMessages,
hydrateMessages,
actionSchema,
events,
channels,
onAction,
onTurnStart,
onBeforeTurnComplete,
@@ -5781,6 +6291,10 @@ function chatAgent<
// those route through the normal continuation-wait path.
const hasRecoveredState = partialAssistant !== undefined;
if (couldHavePriorState && hasRecoveredState) {
locals.set(chatChannelRecoveryPendingKey, { value: true });
}
let hookChain: TUIMessage[] | undefined;
let hookRecoveredTurns: TUIMessage[] | undefined;
let hookBeforeBoot: (() => Promise<void>) | undefined;
@@ -6408,6 +6922,9 @@ function chatAgent<
let capturedPartialResponse: TUIMessage | undefined;
let responseCommitted = false;
const turnBufferedChunks: UIMessageChunk[] = [];
let channelConn: AnyChannelConnector | undefined;
let channelWorkingReaction: string | undefined;
let channelWireEvent: { event: unknown; deliveryId: string } | undefined;
try {
// Extract turn-level context before entering the span. Slim
// wire: at most one delta message per record. `headStartMessages`
@@ -6417,11 +6934,74 @@ function chatAgent<
metadata: wireMetadata,
message: incomingMessage,
headStartMessages: _hsm,
channelEvent: wireChannelEvent,
...restWire
} = currentWirePayload;
void _hsm;
const incomingMessages: TUIMessage[] = incomingMessage
? [incomingMessage as TUIMessage]
channelWireEvent = wireChannelEvent;
let effectiveIncomingMessage = incomingMessage;
let channelAckRef: string | undefined;
if (wireChannelEvent) {
channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId);
if (channelConn) {
const interaction = channelConn.onInteraction?.(wireChannelEvent.event) ?? null;
const resolutionMessage = interaction
? buildInteractionResolutionMessage(
interaction,
accumulatedUIMessages as UIMessage[]
)
: undefined;
if (resolutionMessage) {
effectiveIncomingMessage = resolutionMessage as typeof incomingMessage;
if (interaction && channelConn.finalizeInteraction) {
try {
await channelConn.finalizeInteraction(wireChannelEvent.event, interaction);
} catch (finalizeError) {
logger.warn("chat.agent: channel finalizeInteraction failed; continuing", {
error: finalizeError,
});
}
}
} else {
effectiveIncomingMessage = toUserUIMessage(
channelConn.inbound(wireChannelEvent.event),
currentWirePayload.messageId ?? wireChannelEvent.deliveryId
) as typeof incomingMessage;
if (channelConn.send && channelConn.ack) {
const recoveryPending = locals.get(chatChannelRecoveryPendingKey);
const recovered = recoveryPending?.value === true;
if (recovered) recoveryPending!.value = false;
const ackMessage = channelConn.ack(wireChannelEvent.event, { recovered });
if (ackMessage) {
try {
const ackResult = await channelConn.send(ackMessage, {
event: wireChannelEvent.event,
deliveryId: wireChannelEvent.deliveryId,
mode: channelConn.delivery,
final: false,
});
channelAckRef = ackResult?.ref;
} catch (ackError) {
logger.warn("chat.agent: channel ack post failed; continuing", {
error: ackError,
});
}
}
}
channelWorkingReaction = await resolveReactionChoice(
channelConn.reactions?.working,
wireChannelEvent.event
);
if (channelWorkingReaction) {
await applyChannelReaction(channelConn, wireChannelEvent, {
name: channelWorkingReaction,
});
}
}
}
}
const incomingMessages: TUIMessage[] = effectiveIncomingMessage
? [effectiveIncomingMessage as TUIMessage]
: [];
// Cleaning happens once here so `extractLastUserMessageText` and
// every downstream consumer see the same message shape — and
@@ -6580,9 +7160,11 @@ function chatAgent<
let actionStreamResult: unknown = undefined;
if (isAction) {
// Parse and validate the action payload
const parsedAction = parseAction
? await parseAction(currentWirePayload.action)
: currentWirePayload.action;
const isWebhookAction = currentWirePayload.actionSource === "webhook";
const parsedAction =
parseAction && !isWebhookAction
? await parseAction(currentWirePayload.action)
: currentWirePayload.action;
// Hydrate messages from backend if configured
if (hydrateMessages) {
@@ -7157,6 +7739,7 @@ function chatAgent<
signal: combinedSignal,
cancelSignal,
stopSignal,
channel: buildChannelRunSurface(channelConn, wireChannelEvent),
} as any);
}
@@ -7199,7 +7782,32 @@ function chatAgent<
resolveOnFinish!();
},
});
await pipeChat(tapUIMessageChunks(uiStream, turnBufferedChunks), {
let streamForPipe: typeof uiStream = uiStream;
if (
wireChannelEvent &&
channelConn?.send &&
channelConn.delivery === "stream" &&
channelAckRef &&
uiStream instanceof ReadableStream
) {
const editor = makeChannelStreamEditor(
channelConn,
wireChannelEvent,
channelAckRef
);
streamForPipe = uiStream.pipeThrough(
new TransformStream({
transform(chunk, controller) {
editor.observe(chunk);
controller.enqueue(chunk);
},
flush() {
editor.stop();
},
})
);
}
await pipeChat(tapUIMessageChunks(streamForPipe, turnBufferedChunks), {
signal: combinedSignal,
spanName: "stream response",
});
@@ -7650,6 +8258,61 @@ function chatAgent<
turnAccessToken
);
// Channel egress ("final"): map the turn's reply via outbound() and send it, editing
// the ack placeholder posted at turn start (previousRef). Best-effort: an egress failure
// is logged, not fatal. A manual-pipe turn has no responseMessage, so it opts out.
if (wireChannelEvent && channelConn?.send && turnCompleteEvent.responseMessage) {
const pendingToolCalls = pendingToolCallsInMessage(
turnCompleteEvent.responseMessage
);
const channelMessage =
pendingToolCalls.length > 0 && channelConn.renderInteraction
? channelConn.renderInteraction(pendingToolCalls, {
event: wireChannelEvent.event,
deliveryId: wireChannelEvent.deliveryId,
})
: (channelConn.outbound ?? defaultChannelOutbound)({
text: channelReplyText(turnCompleteEvent.responseMessage),
message: turnCompleteEvent.responseMessage,
final: true,
stopped: turnCompleteEvent.stopped,
});
if (channelMessage) {
try {
await channelConn.send(channelMessage, {
event: wireChannelEvent.event,
deliveryId: wireChannelEvent.deliveryId,
previousRef: channelAckRef,
mode: channelConn.delivery,
final: true,
});
} catch (egressError) {
logger.warn("chat.agent: channel egress send failed", {
error: egressError,
});
}
}
}
// Lifecycle reaction: turn done. Remove "working", mark "done".
if (wireChannelEvent && channelConn?.react) {
if (channelWorkingReaction) {
await applyChannelReaction(channelConn, wireChannelEvent, {
name: channelWorkingReaction,
remove: true,
});
}
const doneReaction = await resolveReactionChoice(
channelConn.reactions?.done,
wireChannelEvent.event
);
if (doneReaction) {
await applyChannelReaction(channelConn, wireChannelEvent, {
name: doneReaction,
});
}
}
// Fire onTurnComplete — stream is closed, use for persistence.
if (onTurnComplete) {
await tracer.startActiveSpan(
@@ -7905,6 +8568,22 @@ function chatAgent<
throw turnError;
}
if (channelWireEvent && channelConn?.react) {
if (channelWorkingReaction) {
await applyChannelReaction(channelConn, channelWireEvent, {
name: channelWorkingReaction,
remove: true,
});
}
const errorReaction = await resolveReactionChoice(
channelConn.reactions?.error,
channelWireEvent.event
);
if (errorReaction) {
await applyChannelReaction(channelConn, channelWireEvent, { name: errorReaction });
}
}
let errorTurnCompleteResult:
| Awaited<ReturnType<typeof writeTurnCompleteChunk>>
| undefined;
@@ -8136,6 +8815,52 @@ function chatAgent<
});
}
// Claim each listed chat.event descriptor: register an agent-scoped webhook endpoint that routes
// verified deliveries to THIS agent's session. The id is scoped by agent so the same descriptor on
// two agents yields two endpoints (no collision), each routing to its own agent.
if (events) {
for (const wh of events) {
resourceCatalog.markSessionWebhookClaimed(wh.id);
resourceCatalog.registerWebhookMetadata({
id: `${options.id}:${wh.id}`,
source: wh.source,
verifierArtifact: wh.verifierArtifact,
secretProvisioning: wh.secretProvisioning,
filter: wh.filter,
routingTarget: {
type: "session",
taskIdentifier: options.id,
keyTemplate: wh.key,
actionType: wh.type,
deliverAs: "action",
},
});
}
}
// Claim each listed channel connector: same agent-scoped endpoint, but deliverAs "message" so a
// verified event becomes a turn (the run maps it via the connector's inbound(), resolved by connectorId).
if (channels) {
for (const ch of channels) {
resourceCatalog.markSessionWebhookClaimed(ch.id);
resourceCatalog.registerWebhookMetadata({
id: `${options.id}:${ch.id}`,
source: ch.source,
verifierArtifact: ch.verifierArtifact,
secretProvisioning: ch.secretProvisioning,
filter: ch.filter,
routingTarget: {
type: "session",
taskIdentifier: options.id,
keyTemplate: ch.key,
connectorId: ch.id,
deliverAs: "message",
startOn: ch.startOn,
},
});
}
}
return task;
}
@@ -10671,6 +11396,10 @@ async function mintPublicTokenWithOverride(args: {
export const chat = {
/** Create a chat agent. See {@link chatAgent}. */
agent: chatAgent,
/** Declare an inbound webhook event an agent claims via `chat.agent({ events })`. See {@link chatEvent}. */
event: chatEvent,
/** Chat frontend connectors (Slack, etc.) an agent claims via `chat.agent({ channels })`. */
channels: chatChannels,
/** Create a custom agent with manual lifecycle control. See {@link chatCustomAgent}. */
customAgent: chatCustomAgent,
/** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */
@@ -10685,6 +11414,8 @@ export const chat = {
local: chatLocal,
/** Create a public access token for a chat task. See {@link createChatAccessToken}. */
createAccessToken: createChatAccessToken,
/** Mint a read-only token to WATCH a session by externalId (cross-surface). See {@link createChatWatchToken}. */
createWatchToken: createChatWatchToken,
/** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */
setTurnTimeout,
/** Override the turn timeout at runtime (seconds). See {@link setTurnTimeoutInSeconds}. */
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveReactionChoice } from "./ai.js";
describe("resolveReactionChoice", () => {
it("returns a single string as-is", async () => {
expect(await resolveReactionChoice("eyes", {})).toBe("eyes");
});
it("skips when absent or empty", async () => {
expect(await resolveReactionChoice(undefined, {})).toBeUndefined();
expect(await resolveReactionChoice("", {})).toBeUndefined();
expect(await resolveReactionChoice([], {})).toBeUndefined();
expect(await resolveReactionChoice(() => undefined, {})).toBeUndefined();
expect(await resolveReactionChoice(() => null, {})).toBeUndefined();
});
it("picks a member of an array (randomly)", async () => {
const options = ["eyes", "hourglass", "thinking_face"];
const seen = new Set<string>();
for (let i = 0; i < 60; i++) {
const picked = await resolveReactionChoice(options, {});
expect(options).toContain(picked);
seen.add(picked!);
}
// Over 60 draws from 3 options, seeing only one is astronomically unlikely: proves it varies.
expect(seen.size).toBeGreaterThan(1);
});
it("resolves a function of the event, returning a string or an array", async () => {
const byKind = (e: unknown) => `emoji-${(e as { kind: string }).kind}`;
expect(await resolveReactionChoice(byKind, { kind: "bug" })).toBe("emoji-bug");
const picked = await resolveReactionChoice(() => ["a", "b"], {});
expect(["a", "b"]).toContain(picked);
});
it("awaits an async resolver", async () => {
expect(await resolveReactionChoice(async () => "shipit", {})).toBe("shipit");
});
});
+18
View File
@@ -97,6 +97,24 @@ export type ChatTaskWirePayload<TMessage extends UIMessage = UIMessage, TMetadat
metadata?: TMetadata;
/** Custom action payload when `trigger` is `"action"`. Validated against `actionSchema` on the backend. */
action?: unknown;
/**
* Origin of an `"action"` trigger. `"webhook"` actions (delivered by the hosted webhook ingress)
* carry a fixed envelope typed via `ChatEventActions`, so they bypass `actionSchema` validation;
* omitted / `"client"` actions (frontend or server) are validated against `actionSchema`.
*/
actionSource?: "client" | "webhook";
/**
* A channel-delivered turn (hosted webhook ingress -> a chat frontend like Slack). Carries the raw
* verified provider event; the run resolves the connector by `connectorId` from `chat.agent({ channels })`
* and applies its `inbound()` mapper to produce the turn's message. Present instead of `message`.
*/
channelEvent?: {
connectorId: string;
event: unknown;
source: string;
headers: Record<string, string>;
deliveryId: string;
};
/** Whether this run is continuing an existing chat whose previous run ended. */
continuation?: boolean;
/** The run ID of the previous run (only set when `continuation` is true). */
+323 -2
View File
@@ -1,5 +1,28 @@
import { Webhook } from "@trigger.dev/core/v3";
import { Webhook, resourceCatalog } from "@trigger.dev/core/v3";
import type {
WebhookSource,
InferWebhookEvent,
AnyWebhookSource,
WebhookRunPayload,
ValidateWebhookFilter,
ChatEvent,
ValidatedWebhookKey,
WebhookVerifierConfig,
StripeWebhookEvent,
GitHubWebhookEvent,
TaskRunContext,
} from "@trigger.dev/core/v3";
import { subtle } from "../imports/uncrypto.js";
import { createTask, type Task } from "./shared.js";
import {
discordVerifierConfig,
githubVerifierConfig,
squareVerifierConfig,
stripeVerifierConfig,
svixVerifierConfig,
webhookProviderConfigs,
type WebhookProviderId,
} from "@trigger.dev/core/webhooks";
/**
* The type of error thrown when a webhook fails to parse or verify
@@ -24,6 +47,275 @@ type ConstructEventOptions = {
header: string | Buffer | Array<string>;
};
// ── Source producers (presets carry the event type) ──
export const webhookSources = {
custom<T = unknown>(config: WebhookVerifierConfig): WebhookSource<T> {
// Roll-your-own webhooks: you control both ends, so offer paste AND generate.
return {
provider: "custom",
verifier: { kind: "config", config },
secretProvisioning: "either",
};
},
// Stripe: `Stripe-Signature: t=…,v1=…` (comma-kv), signed `{t}.{body}`, hex.
// Defaults to a minimal event shape; pass the official type for full typing: stripe<Stripe.Event>().
stripe<TEvent = StripeWebhookEvent>(opts?: { toleranceSeconds?: number }): WebhookSource<TEvent> {
return {
provider: "stripe",
verifier: { kind: "preset", preset: "stripe", config: stripeVerifierConfig(opts) },
secretProvisioning: "provider",
};
},
// GitHub: `X-Hub-Signature-256: sha256=<hex>` (prefixed), signed raw body.
// Defaults to an open shape; pass your event type for full typing: github<MyPushEvent>().
github<TEvent = GitHubWebhookEvent>(): WebhookSource<TEvent> {
return {
provider: "github",
verifier: { kind: "preset", preset: "github", config: githubVerifierConfig() },
secretProvisioning: "integrator",
};
},
// Svix family (Svix, Clerk, Resend): `svix-signature: v1,<b64> v1,<b64>` (space-list),
// signed `{id}.{timestamp}.{body}`, base64; the `whsec_` secret is base64-decoded.
svix<T = unknown>(): WebhookSource<T> {
return {
provider: "svix",
verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() },
secretProvisioning: "provider",
};
},
// Square: bare base64 signature over `{notificationURL}{body}` (URL template var, no separator).
square<T = unknown>(): WebhookSource<T> {
return {
provider: "square",
verifier: { kind: "preset", preset: "square", config: squareVerifierConfig() },
secretProvisioning: "provider",
};
},
// Discord: asymmetric Ed25519 over `{timestamp}{body}`. The "secret" stored on the endpoint is
// the application PUBLIC KEY (hex by default). No shared secret.
discord<T = unknown>(opts: { publicKeyEncoding?: "raw-hex" | "pem" } = {}): WebhookSource<T> {
return {
provider: "discord",
verifier: { kind: "preset", preset: "discord", config: discordVerifierConfig(opts) },
secretProvisioning: "provider",
};
},
/**
* Per-provider producers over shared presets. Each is a thin wrapper: same verifier config as the
* preset it references, differing only in `provider` (routing + picker identity) and who provisions
* the secret. Pass the provider's own published type for full typing, e.g. clerk<WebhookEvent>().
*/
clerk<T = unknown>(): WebhookSource<T> {
return {
provider: "clerk",
verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() },
secretProvisioning: "provider",
};
},
resend<T = unknown>(): WebhookSource<T> {
return {
provider: "resend",
verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() },
secretProvisioning: "provider",
};
},
openai<T = unknown>(): WebhookSource<T> {
return {
provider: "openai",
verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() },
secretProvisioning: "provider",
};
},
replicate<T = unknown>(): WebhookSource<T> {
return {
provider: "replicate",
verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() },
secretProvisioning: "provider",
};
},
recallai<T = unknown>(): WebhookSource<T> {
return {
provider: "recall-ai",
verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() },
secretProvisioning: "provider",
};
},
brex<T = unknown>(): WebhookSource<T> {
return {
provider: "brex",
verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() },
secretProvisioning: "provider",
};
},
gitlab<T = unknown>(): WebhookSource<T> {
return {
provider: "gitlab",
verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() },
secretProvisioning: "integrator",
};
},
whatsapp<T = unknown>(): WebhookSource<T> {
return {
provider: "whatsapp",
verifier: { kind: "preset", preset: "github", config: githubVerifierConfig() },
secretProvisioning: "integrator",
};
},
} as const;
/**
* Per-provider producers generated from the core config table (kind "config"). Each carries the
* provider's own HMAC verifier config and stays in lockstep with the round-trip-tested configs.
*/
export type ProviderProducers = {
[K in WebhookProviderId]: <TEvent = unknown>() => WebhookSource<TEvent>;
};
export const providerProducers = Object.fromEntries(
Object.entries(webhookProviderConfigs).map(([provider, entry]) => [
provider,
() => ({
provider,
verifier: { kind: "config" as const, config: entry.config() },
secretProvisioning: entry.secretProvisioning,
}),
])
) as ProviderProducers;
// ── webhook() entry: single-callback IoC, infers event from source ──
export type WebhookOnEventParams<TEvent> = {
/** The verified event body, typed by the source (a preset type, or the `<T>` you supply). */
event: TEvent;
/** The inbound request headers (case-insensitive, Web `Headers`). e.g. headers.get("x-github-event"). */
headers: Headers;
ctx: TaskRunContext;
};
export type WebhookOptions<TIdentifier extends string, TSource extends AnyWebhookSource> = {
id: TIdentifier;
source: TSource;
/**
* Optional server-side filter (a type-safe string DSL checked against the event shape). A delivery
* that doesn't match is received and recorded but not routed (no run). e.g.
* `"event.action == 'created' && event.repository.private == false"`.
*/
filter?: string;
onEvent: (params: WebhookOnEventParams<InferWebhookEvent<TSource>>) => Promise<void> | void;
};
export type WebhookHandle<TIdentifier extends string, TEvent> = Task<TIdentifier, TEvent, void>;
export function webhook<
TIdentifier extends string,
TSource extends AnyWebhookSource,
const TFilter extends string = string,
>(
options: WebhookOptions<TIdentifier, TSource> & {
filter?: TFilter & ValidateWebhookFilter<InferWebhookEvent<TSource>, TFilter>;
}
): WebhookHandle<TIdentifier, InferWebhookEvent<TSource>> {
const { id, source, onEvent, filter } = options;
// 1. The task half: webhook IS a first-class task kind (triggerSource "webhook").
// The platform delivers a { event, headers } envelope; unwrap it for onEvent. The handle's
// payload type stays the event (webhook tasks are triggered by the ingress, not tasks.trigger).
const task = createTask<TIdentifier, InferWebhookEvent<TSource>, void>({
id,
triggerSource: "webhook",
run: async (payload, runOptions) => {
const envelope = payload as unknown as WebhookRunPayload<InferWebhookEvent<TSource>>;
await onEvent({
event: envelope.event,
headers: new Headers(envelope.headers ?? {}),
ctx: runOptions.ctx,
});
},
});
// 2. The endpoint half: register the verifier + default routing target (this task) + filter.
resourceCatalog.registerWebhookMetadata({
id,
source: source.provider,
verifierArtifact: source.verifier,
routingTarget: { type: "task", taskId: id },
secretProvisioning: source.secretProvisioning,
filter,
});
return task;
}
// ── chat.event(): declarative descriptor an agent claims via chat.agent({ events }). No handler. ──
// Carries the verifier (source), a validated string `key`, and a `type` discriminant. The `key`
// validates against the event/webhook/header namespaces and mirrors the stored {body.x} wire template.
export function chatEvent<
TSource extends AnyWebhookSource,
const TId extends string = string,
const TKey extends string = string,
const TType extends string = TId,
const TFilter extends string = string,
>(options: {
id: TId;
source: TSource;
key: ValidatedWebhookKey<InferWebhookEvent<TSource>, TKey>;
/** The `action.type` the handler reads. Optional; defaults to `id`. */
type?: TType;
/** Optional server-side filter (same type-safe DSL as `webhook()`); a non-match is recorded FILTERED and not routed. */
filter?: TFilter & ValidateWebhookFilter<InferWebhookEvent<TSource>, TFilter>;
}): ChatEvent<TType, InferWebhookEvent<TSource>> {
const { id, source, key, type, filter } = options;
const keyTemplate = normalizeKeyString(key as string);
// Record the descriptor as declared so the indexer can flag it if no agent ever claims it.
resourceCatalog.registerDeclaredSessionWebhook(id);
return {
id,
type: type ?? id,
key: keyTemplate,
source: source.provider,
verifierArtifact: source.verifier,
secretProvisioning: source.secretProvisioning,
filter,
} as ChatEvent<TType, InferWebhookEvent<TSource>>;
}
// Public chat-event types (descriptor, the shared action union, and the key namespaces).
export type {
ChatEvent,
AnyChatEvent,
ChatEventAction,
ChatEventActions,
WebhookKeyMeta,
} from "@trigger.dev/core/v3";
// Brace placeholders without a recognized namespace default to the event body. webhook./header./body.
// pass through unchanged.
export function normalizeKeyString(key: string): string {
return key.replace(/\{([^}]+)\}/g, (_match, path: string) =>
path.startsWith("webhook.") || path.startsWith("header.") || path.startsWith("body.")
? `{${path}}`
: `{body.${path}}`
);
}
// P2 seam (TYPE only):
export type { CreateWebhookEndpointParams } from "@trigger.dev/core/v3";
/**
* Interface describing the webhook utilities
*/
@@ -50,14 +342,43 @@ interface Webhooks {
/** Header name used for webhook signatures */
SIGNATURE_HEADER_NAME: string;
custom: typeof webhookSources.custom;
stripe: typeof webhookSources.stripe;
github: typeof webhookSources.github;
svix: typeof webhookSources.svix;
square: typeof webhookSources.square;
discord: typeof webhookSources.discord;
clerk: typeof webhookSources.clerk;
resend: typeof webhookSources.resend;
openai: typeof webhookSources.openai;
replicate: typeof webhookSources.replicate;
recallai: typeof webhookSources.recallai;
brex: typeof webhookSources.brex;
gitlab: typeof webhookSources.gitlab;
whatsapp: typeof webhookSources.whatsapp;
}
/**
* Webhook utilities for handling incoming webhook requests
*/
export const webhooks: Webhooks = {
export const webhooks: Webhooks & ProviderProducers = {
...providerProducers,
constructEvent,
SIGNATURE_HEADER_NAME,
custom: webhookSources.custom,
stripe: webhookSources.stripe,
github: webhookSources.github,
svix: webhookSources.svix,
square: webhookSources.square,
discord: webhookSources.discord,
clerk: webhookSources.clerk,
resend: webhookSources.resend,
openai: webhookSources.openai,
replicate: webhookSources.replicate,
recallai: webhookSources.recallai,
brex: webhookSources.brex,
gitlab: webhookSources.gitlab,
whatsapp: webhookSources.whatsapp,
};
async function constructEvent(
+122 -1
View File
@@ -2261,6 +2261,28 @@ importers:
specifier: ^1.7.0
version: 1.7.0
packages/slack:
dependencies:
'@trigger.dev/core':
specifier: workspace:4.5.0-rc.7
version: link:../core
devDependencies:
'@arethetypeswrong/cli':
specifier: ^0.18.5
version: 0.18.5
'@trigger.dev/sdk':
specifier: workspace:4.5.0-rc.7
version: link:../trigger-sdk
rimraf:
specifier: 6.0.1
version: 6.0.1
tshy:
specifier: ^3.0.2
version: 3.3.2
tsx:
specifier: 4.17.0
version: 4.17.0
packages/trigger-sdk:
dependencies:
'@ai-sdk/otel':
@@ -8293,48 +8315,95 @@ packages:
'@types/ws@8.5.4':
resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260603.1':
resolution: {integrity: sha512-BvaaQAHWaHA0nl26DsWTsXsKkCHqUTm7f5FMuNDyCU83Hvo7zHx0vpSTrzL+1KEWeWLUVVGA7U224dk+3yIosQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [darwin]
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2':
resolution: {integrity: sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260603.1':
resolution: {integrity: sha512-a2JJezvJAqWXgTAD1tKdWs7iA/4s3EakLcCYx4rg3ptEbBF6ADWv7A9ySHxT/+CQWYCD0DYIb9du1JUWL85fRw==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2':
resolution: {integrity: sha512-Afc7M5zOwo+GpfcYwz5Z8HMB2tPVsui7nNIqEuuFB73MPdVqNn/Wmpe4tP4MRri0AtJnJknoHBaTJ/VDAp/Jhw==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260603.1':
resolution: {integrity: sha512-MQ0JcRucLdcR6MT14wlrGNz9HaxJrFF8Axmo0IN6e5gSou2UrKKUvvAH1i8zU5Gm7jl8CWCLzzX/qGCi1TsinA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [linux]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2':
resolution: {integrity: sha512-iITBa2WjjTI5N9t5l7Z4KoOSI+2zBlhbvFzsD/f8qX8QoKjz/Y4DPyBDgezYi8nkqjjksbgSOJ3/ykzhwrB9cg==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20260603.1':
resolution: {integrity: sha512-GLsTfJQiGfTN+r1ezlxMcTd5MNYRB/tADD6Y1j1jfLjZsFYSVkNfCnDQA/jwUG6GddBBF+0Um7tBUP16emej9w==}
engines: {node: '>=16.20.0'}
cpu: [arm]
os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2':
resolution: {integrity: sha512-hJm/UOqZTr9FHmR7uNm8VGX4oKtfWk0Jem0zPeJFNC8ckGUfSBueyiEYMZB+XmRc1aG4x1E46y3CplP4CLHvGQ==}
engines: {node: '>=16.20.0'}
cpu: [arm]
os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20260603.1':
resolution: {integrity: sha512-u6gvCiVGSDagdR2+GI5VJLPxJbGevATJgjZ2QFLKBLWI3re7liTGlPAuaINNDq/r0m9rUPj2rEn0zwqtcLK0nQ==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2':
resolution: {integrity: sha512-du0dzi6y97Po5vDNdPJTyyijHCpaS22JLRnKZEJXBDaO9gCIymOv/5QQokFRuOlQm0bWl3i9PF4OVdGP6uAOQA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260603.1':
resolution: {integrity: sha512-slxm6HBYs+jk0GpIBC2o03uY5qHgW7wIH+OTjK8JHbd2sUCgYV7P7bfZHUVZYi/cJZcVrnDW6MxXx734TuFn+w==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [win32]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2':
resolution: {integrity: sha512-SsAwfhyHJ1akgBc+99z4+hwdbHsdWaKB8EwCNIMA6JfSLMeUjffrYvxu+vfMyxVtOVOz7RrRXRoiDiu4a2sCtg==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20260603.1':
resolution: {integrity: sha512-G7SDZJn2Z9+c1qsAFzI/JL0OsRjJ18diQ39ycWKmJikilZZeL7iS7j933bemWY4ODaLTfxhMRKPMHf9292gpDA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2':
resolution: {integrity: sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [win32]
'@typescript/native-preview@7.0.0-dev.20260603.1':
resolution: {integrity: sha512-CO519Ccw5rji4JIG0DGVMR5owraCeQhm94jM53eRhMdlzz0nAJcAZ63Y6m1u3dUwqGssqlYxh4CcwTFPxTpMYw==}
engines: {node: '>=16.20.0'}
hasBin: true
'@typescript/native-preview@7.0.0-dev.20260707.2':
resolution: {integrity: sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg==}
engines: {node: '>=16.20.0'}
@@ -15260,6 +15329,11 @@ packages:
unrun:
optional: true
tshy@3.3.2:
resolution: {integrity: sha512-vOIXkqMtBWNjKUR/c99+6N50LhWdnKG1xE3+5wf8IPdzxx2lcIFPvbGgFdBBgoTMbdNb8mz06MUm7hY+TFnJcw==}
engines: {node: 20 || >=22}
hasBin: true
tshy@4.1.3:
resolution: {integrity: sha512-uEaLO1lFhu5X58KZxS5gKCabh+xd72MfXDOHhAIvnoreBAP1F4HNpbK2m0DUmrrzpcgxvXbsdXn1A0OaQxFYqw==}
engines: {node: 20 || >=22}
@@ -19457,7 +19531,7 @@ snapshots:
json-parse-even-better-errors: 3.0.0
normalize-package-data: 5.0.0
proc-log: 3.0.0
semver: 7.8.1
semver: 7.8.5
transitivePeerDependencies:
- bluebird
@@ -23326,27 +23400,58 @@ snapshots:
dependencies:
'@types/node': 24.13.3
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260603.1':
optional: true
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2':
optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260603.1':
optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2':
optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260603.1':
optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2':
optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20260603.1':
optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2':
optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20260603.1':
optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2':
optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260603.1':
optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2':
optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20260603.1':
optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2':
optional: true
'@typescript/native-preview@7.0.0-dev.20260603.1':
optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260603.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260603.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260603.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260603.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260603.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260603.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260603.1
'@typescript/native-preview@7.0.0-dev.20260707.2':
optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260707.2
@@ -31410,6 +31515,22 @@ snapshots:
- oxc-resolver
- vue-tsc
tshy@3.3.2:
dependencies:
'@typescript/native-preview': 7.0.0-dev.20260603.1
chalk: 5.6.2
chokidar: 4.0.3
foreground-child: 4.0.3
jsonc-simple-parser: 3.0.0
minimatch: 10.2.5
mkdirp: 3.0.1
polite-json: 5.0.0
resolve-import: 2.4.0
rimraf: 6.1.3
sync-content: 2.0.4
typescript: 5.9.3
walk-up-path: 4.0.0
tshy@4.1.3:
dependencies:
'@typescript/native-preview': 7.0.0-dev.20260707.2