eve
Version:
Filesystem-first framework for durable backend AI agents that run anywhere.
264 lines (189 loc) • 16.3 kB
text/mdx
---
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