UNPKG

eve

Version:

Filesystem-first framework for durable backend AI agents that run anywhere.

264 lines (189 loc) 16.3 kB
--- title: "Slack" description: "Reach your agent from Slack app mentions and DMs, with thread anchoring, buttons, and Vercel Connect credentials." type: integration --- The Slack channel puts your agent inside a workspace. It answers `@mentions` and DMs, replies in threads, shows typing indicators, and turns human-in-the-loop (HITL) prompts into buttons. Use it when the conversation should happen where your team already works. Credentials run through [Vercel Connect](../guides/auth-and-route-protection), which handles both the outbound bot token and inbound webhook verification, so there's no `SLACK_BOT_TOKEN` or `SLACK_SIGNING_SECRET` for you to manage. See [Channels](./overview) for the contract this builds on. ## Set up Connect Create a Slack Connect client and copy its UID (e.g. `slack/my-agent`), then attach this project as the trigger destination at eve's Slack route: ```bash npm install -g vercel@latest vercel connect create slack --triggers vercel connect detach <uid> --yes vercel connect attach <uid> --triggers --trigger-path /eve/v1/slack --yes ``` The `create` step provisions a destination at the default Connect path. `detach` then `attach --trigger-path /eve/v1/slack` re-points the trigger at the eve Slack route, since eve does not serve the default Connect path. `--triggers` turns on Slack Event Subscriptions; without it, Slack never delivers `app_mention` or `message.im`. You can also create the client from the [Connect dashboard](https://vercel.com/d?to=/%5Bteam%5D/~/connect&title=Go+to+Connect). ## Add the channel Scaffold the channel and its dependency with `eve channels add slack`, or set it up by hand: ```bash npm install @vercel/connect ``` ```ts title="agent/channels/slack.ts" import { connectSlackCredentials } from "@vercel/connect/eve"; import { slackChannel } from "eve/channels/slack"; export default slackChannel({ credentials: connectSlackCredentials("slack/my-agent"), }); ``` `connectSlackCredentials` returns `{ botToken, webhookVerifier }`, keeping token rotation, multi-workspace tenancy, and request verification inside Connect rather than your code. ### Deploy Deploy once the trigger destination and channel file are ready: ```bash VERCEL_USE_EXPERIMENTAL_FRAMEWORKS=1 vercel deploy --prod ``` `VERCEL_USE_EXPERIMENTAL_FRAMEWORKS=1` lets the Vercel CLI recognize eve as a framework during the build. eve's own setup commands set the same flag. ## How the channel handles inbound events ### Message hooks Message hooks return `{ auth }` to dispatch, `null` to drop, or `{ auth, context }` to inject background into history. - `onMessage(ctx, message)` handles Slack `message` events. eve drops messages authored by the installed app before this hook runs, preventing self-reply loops. Messages from other bots remain visible; check `message.author?.isBot` when those should also be ignored. `ctx.isBotMentioned()` and `ctx.isSubscribed()` support mention and active-thread policies. - `onAppMention(ctx, message)` handles only `app_mention` and takes precedence over `onMessage`. Its default derives workspace-scoped auth and posts `Thinking…`. - `onDirectMessage(ctx, message)` handles only DMs and takes precedence over `onMessage`. Bot-authored messages and edits are filtered first; Slack requires `message.im` and `im:history`. | Incoming event | Handler order | | ---------------------- | ------------------------------------------------------------------- | | App mention | `onAppMention` → `onMessage` → `onEvent` → built-in mention default | | Direct message | `onDirectMessage` → `onMessage` → `onEvent` → built-in DM default | | Other Slack message | `onMessage` → `onEvent` → ignore | | Other Events API event | `onEvent` → ignore | Only the first available handler runs. A message hook returning `null` drops the message; it does not continue down the table. `onInteraction(action, ctx)` separately handles `block_actions` callbacks not consumed by HITL. eve attaches the triggering Slack user id to the same model message as its text, preserving speaker attribution without profile lookups. #### Continue conversations without repeated mentions Use `onMessage` to handle explicit mentions and continue replies in threads with an active eve session: ```ts title="agent/channels/slack.ts" export default slackChannel({ credentials: connectSlackCredentials("slack/my-agent"), async onMessage(ctx, message) { if (message.author?.isBot) return null; const isDirectMessage = message.raw.channel_type === "im"; return isDirectMessage || ctx.isBotMentioned() || (await ctx.isSubscribed()) ? { auth: null } : null; }, }); ``` `isBotMentioned()` identifies an explicit mention. `isSubscribed()` checks whether the message belongs to a thread with an active eve session. For Vercel Connect, open **Advanced** when creating the connector and add `message.channels` under **Trigger Event Types** and `channels:history` under **Bot Scopes**. Private channels additionally need `message.groups` and `groups:history`. Use `ctx.thread.listParticipants()` when routing depends on who has joined the thread. It fetches the current thread and returns unique human Slack user ids in first-appearance order, so the first id is the starting author for a human-started thread. Bot and system messages are excluded: ```ts async onMessage(ctx, message) { if (!message.author || message.author.isBot) return null; const participants = await ctx.thread.listParticipants(); const isGroupFollowUpFromStarter = participants.length > 1 && participants[0] === message.author.userId; return isGroupFollowUpFromStarter ? { auth: null } : null; } ``` Like `threadContext`, this helper calls `conversations.replies` and requires the matching Slack history scope. It observes at most the first 50 messages of the thread, and a failed fetch is logged and swallowed, so the returned list may be empty or stale — treat an unexpected empty list as "don't route" rather than "no participants". #### Cancel and replace in-flight work Message hooks (`onMessage`, `onAppMention`, and `onDirectMessage`) receive a thread-bound `ctx.cancel({ turnId? })` helper. Call it before returning `{ auth }` to stop the current turn and queue the new Slack message as replacement input, producing a debounce-like experience: ```ts title="agent/channels/slack.ts" export default slackChannel({ credentials: connectSlackCredentials("slack/my-agent"), async onMessage(ctx, message) { const shouldHandle = ctx.isBotMentioned() || (await ctx.isSubscribed()); if (!shouldHandle || message.author?.isBot) return null; await ctx.cancel(); return { auth: null }; }, }); ``` `"accepted"` means the cancellation request was consumed; `"no_active_turn"` means the thread had no cancellable turn. Both are successful outcomes, so the hook can still return `{ auth }` to deliver the replacement message. Cancellation never sends input itself. A custom `onInteraction` handler receives the same bound helper, which is useful for a Stop button that should cancel without replacement input. ### Other Events API callbacks Use `onEvent` for subscribed events such as `reaction_added`, `team_join`, or `channel_created`. It receives the raw, open-ended Slack event and owns control flow. `ctx.receive(options)` is the current Slack channel's pre-bound form of the schedule API's `receive(slack, options)`: call it zero, one, or many times to start turns. Each call accepts `message`, `target`, and `auth`, and returns the resulting session. ```ts title="agent/channels/slack.ts" import { connectSlackCredentials } from "@vercel/connect/eve"; import { slackChannel } from "eve/channels/slack"; const onboardingChannels = ["C0123ABC", "C0456DEF"]; export default slackChannel({ credentials: connectSlackCredentials("slack/my-agent"), async onEvent({ receive }, event) { if (event.type !== "team_join") return; await Promise.all( onboardingChannels.map((channelId) => receive({ message: `A user joined the Slack workspace. Onboard them from this event:\n${JSON.stringify(event)}`, target: { channelId }, auth: null, }), ), ); }, }); ``` The webhook invocation already keeps an awaited `onEvent` handler alive. Use `ctx.waitUntil(promise)` for deliberately detached work, matching a handler-form schedule. `ctx.slack.request(operation, body)` provides workspace-scoped Slack Web API access, while `ctx.envelope` carries delivery metadata such as `team_id`, `event_id`, and `event_time`. Calls to `ctx.receive` automatically seed the callback's team id into Slack session state. Because a generic event is not necessarily tied to one thread, its cancellation helper takes the target explicitly: `ctx.cancel({ channelId, threadTs, turnId? })`. Pair it with `ctx.receive(...)` when an event should replace in-flight work, or call it alone to stop the turn. `onEvent` is the raw fallback after the message hooks. If an event is not claimed by `onAppMention`, `onDirectMessage`, or `onMessage`, an authored `onEvent` receives it; otherwise eve applies the built-in mention/DM default or ignores it. `onEvent` covers JSON `event_callback` deliveries only. URL verification, slash commands, and interactive payloads do not reach it. Add every desired event and required OAuth scope to the Slack app's Event Subscriptions configuration; eve can only handle events Slack sends. You get the triggering mention by default, but not the earlier replies in the thread. Enable `threadContext` to fetch and inject them with every message attributed by stable Slack user id. Use `since: "last-agent-reply"` so repeated mentions inject only what is new: ```ts import { slackChannel } from "eve/channels/slack"; import { connectSlackCredentials } from "@vercel/connect/eve"; export default slackChannel({ credentials: connectSlackCredentials("slack/my-agent"), threadContext: { since: "last-agent-reply" }, }); ``` `since` sets the boundary for what each mention injects and accepts three values: - `"thread-root"` (the default): every prior message in the thread, on every mention. `threadContext: {}` behaves the same. - `"last-agent-reply"`: only messages after this installed agent's last reply, keeping repeated mentions incremental. Replies from other Slack bots do not move the boundary. - A predicate `(message: SlackThreadMessage) => boolean`: only messages after the last one it matches, such as "since the last message that mentioned a particular user". `threadContext` requires the matching Slack history scope. Thread helpers reuse messages already loaded within the same inbound handler, and overlapping refreshes share one `conversations.replies` request. Omit it when the agent should see only direct mentions. `loadThreadContextMessages` remains available when you need custom filtering or non-model processing of the raw thread messages. ### Slack API calls outside a handler Inside webhook-side handlers (`onAppMention`, `onEvent`, `onInteraction`, `events`), `ctx.slack.request(operation, body)` is the raw-API escape hatch. Outside those contexts there is no handle — a schedule resolving reactions on old messages, for example, has no inbound Slack request. For that, call the same primitive the handle uses directly: ```ts import { callSlackApi, resolveSlackBotToken } from "eve/channels/slack"; import { connectSlackCredentials } from "@vercel/connect/eve"; const { botToken } = connectSlackCredentials("slack/my-agent"); const response = await callSlackApi({ botToken, operation: "reactions.get", body: { channel: "C0123456789", timestamp: "1712345678.000100", full: true }, }); if (!response.ok) throw new Error(String(response.error)); ``` `callSlackApi` resolves function-form tokens (secret managers, Connect rotation) at call time and form-encodes the body — the only safe default, since Slack's JSON support is partial (`conversations.replies` rejects JSON). `resolveSlackBotToken` materializes a `SlackBotToken` to a string when you need the bearer token itself. ### Delivery The default handlers reply in-thread and show progress. Typing indicators post automatically: `Thinking…` on inbound, `Working…` on `turn.started`, a truncated reasoning snippet on `reasoning.appended`, and an action label on `actions.requested` — the tool name plus its most telling argument (`grep useEveAgent`, `read_file agent/agent.ts`), the subagent or remote-agent name for dispatched calls, and `+N more` when the model requests several actions at once. The model's own pre-tool narration, when present, takes precedence over the derived label. Reasoning snippets build progressively: extensions of at least four characters appear immediately, while smaller streamed deltas use the five-second refresh interval to avoid one Slack request per token. Override `events["reasoning.appended"]` if you prefer generic wording. Override an inbound handler or the `events` handlers to customize. Outbound text preserves bare `@` tokens as literal text. To mention a user, embed Slack's `<@USER_ID>` syntax directly or use `channel.thread.mentionUser(userId)`. When a session starts without a `threadTs` (say, from a schedule or `receive(slack, ...)`), eve gives it a unique temporary continuation token. The first agent post anchors the session to the Slack message timestamp, and later posts and mentions resume that same session. Pass `initialMessage` with a `Card` to land a structured anchor first instead. `threadTs` and `initialMessage` are mutually exclusive. The example below overrides `onAppMention` to gate on an authored message and posts the completed reply to the thread. Event handlers receive `(eventData, channel, ctx)`, with Slack platform handles on `channel.thread` and `channel.slack`: ```ts import { defaultSlackAuth, slackChannel } from "eve/channels/slack"; import { connectSlackCredentials } from "@vercel/connect/eve"; export default slackChannel({ credentials: connectSlackCredentials("slack/my-agent"), onAppMention: (ctx, message) => message.author ? { auth: defaultSlackAuth(message, ctx) } : null, events: { "message.completed"(eventData, channel, ctx) { if (eventData.finishReason === "tool-calls") return; if (eventData.message) channel.thread.post(eventData.message); }, }, }); ``` ### Human-in-the-loop (HITL) HITL renders as Slack buttons and selects. When the user responds, the parked session (paused awaiting input) resumes. Authorization prompts split public status from private credentials. A sign-in challenge (OAuth URL, device code) is a credential. Anyone who completes it binds their identity to the session's connection. The default `authorization.required` handler posts a public, link-free status in the thread, delivers the actual challenge ephemerally to the triggering user, device code included, and then updates that public status when `authorization.completed` fires. The handler receives a private-delivery context with `postEphemeral`, `postDirectMessage` (needs the `im:write` scope), and `state`. There is, intentionally, no public `post` and no raw API access. ```ts events: { "authorization.required"(eventData, channel) { const userId = channel.state.triggeringUserId; if (!userId || !eventData.authorization?.url) return; return channel.postDirectMessage(userId, `Sign in to continue: ${eventData.authorization.url}`); }, }, ``` ### Proactive sessions Start a session without an inbound message through `receive(slack, { message, target, auth })` from a schedule `run` handler, or `args.receive(slack, ...)` from another channel. The proactive target shape is `{ channelId }`. ### Attachments Inbound files behind authenticated Slack URLs are staged with `fetchFile`. See [File uploads](./custom#file-uploads) for the `fetchFile` contract. ## What to read next - [Channels overview](./overview): the channel contract and every built-in channel - [Auth & route protection](../guides/auth-and-route-protection): authenticating inbound traffic