---
title: "Slack"
description: "Reach your agent from Slack app mentions, DMs, slash commands, and interactive callbacks."
type: integration
---

The Slack channel puts your agent inside a workspace. It handles `@mentions`, DMs, slash commands, shortcuts, and interactive callbacks; replies in threads; shows typing indicators; and turns human-in-the-loop (HITL) prompts into buttons. [Vercel Connect](https://vercel.com/kb/guide/vercel-connect) is the recommended setup: it manages the Slack bot token, verifies inbound requests, supports token rotation and multiple workspace installations, and forwards events to your agent without copying Slack secrets into your project environment. If you cannot use Vercel Connect, you can provide the bot token and signing secret through environment variables. See [Channels](./overview) for the contract this builds on.

## Add the channel

Run the guided setup from your agent project:

```bash
eve add channel/slack
```

With Vercel Connect, the recommended option, setup signs you in to Vercel and links a project when needed. It reuses a suitable Slack app (a Connect connector) or creates one, prompts you to install it in a workspace if needed, routes its events to `/eve/v1/slack`, and writes the channel file:

```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` keeps the bot token, token rotation, multiple workspace installations, and request verification inside Connect. Deploy with `eve deploy` once setup finishes.

### Resume setup

If setup stops partway, or Slack events stop reaching the agent, run `eve add channel/slack` again. Setup checks what is already in place and runs only the missing steps. When `agent/channels/slack.ts` names a connector, setup finishes that connector without asking anything. Setup never runs `vercel connect detach`, which would remove the project's token access and every trigger destination for the project.

### Manage Slack credentials yourself

Choose **Use portable credentials** when the agent does not run on Vercel or you can't use Vercel Connect. eve adds `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` to `.env.example` but doesn't create the Slack app. In [Slack app settings](https://api.slack.com/apps):

1. Add the `app_mentions:read` and `chat:write` bot scopes, plus `im:history` for DMs, `files:read` for inbound attachments, and `files:write` for long-response snippets.
2. Install the app, then set `SLACK_BOT_TOKEN` to its Bot User OAuth Token and `SLACK_SIGNING_SECRET` to the signing secret from **Basic Information**. Put both values in `.env.local` for local development or in the target runtime's environment when deployed.
3. Set the Request URL to `https://your-agent.example/eve/v1/slack` under **Event Subscriptions** (subscribe to `app_mention`, plus `message.im` for DMs), **Interactivity & Shortcuts**, and each **Slash Command**.

A function-form `botToken` receives `{ teamId }`, the app installation workspace, so a self-managed multi-workspace app can select an installation: `credentials: { botToken: ({ teamId }) => loadInstallationToken(teamId) }`. Reject a missing `teamId` instead of falling back to a default installation.

## Test a preview branch

Create a second connector for preview traffic and route it to your preview branch:

```bash
vercel connect create slack --name my-agent-preview --triggers
vercel connect attach slack/my-agent-preview \
  --environment preview \
  --triggers \
  --trigger-branch your-preview-branch \
  --trigger-path /eve/v1/slack \
  --yes
```

Then select the connector at runtime:

```ts title="agent/channels/slack.ts"
import { connectSlackCredentials } from "@vercel/connect/eve";
import { slackChannel } from "eve/channels/slack";

const connectorUid =
  process.env.VERCEL_ENV === "preview" ? "slack/my-agent-preview" : "slack/my-agent";

export default slackChannel({
  credentials: connectSlackCredentials(connectorUid),
});
```

`connect create --triggers` also adds a default production destination; remove it in the [Connect dashboard](https://vercel.com/d?to=/%5Bteam%5D/~/connect&title=Go+to+Connect) so preview traffic stays isolated. Guided setup does not restrict credential environments. Set the production connector's project access to **Production** and the preview connector's to **Preview** in the dashboard. A connector can have at most three trigger destinations.

Slack must reach the preview route without an authentication challenge. For a Connect trigger, exempt the branch domain with a [Deployment Protection Exception](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/deployment-protection-exceptions) or use an unprotected domain. For a Slack app using environment-variable credentials, append `?x-vercel-protection-bypass=your-generated-secret` to its event and interaction Request URLs. Enabling [Protection Bypass for Automation](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation) does not help unless Slack sends the secret with each request.

## Troubleshoot Slack delivery

| Symptom                                                 | Next action                                                                                                                                                                  |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eve add channel/slack` stops before finishing          | Fix the step the error names, then rerun `eve add channel/slack`.                                                                                                            |
| Setup says the Slack app isn't installed in a workspace | Rerun `eve add channel/slack`. It opens the app's install page again; finish the install in your browser while setup waits.                                                  |
| Setup says a connector is used by another project       | Let setup create a new Slack app for this agent. To keep the existing app, remove it from the other project in the Connect dashboard first.                                  |
| Setup says the connector has too many destinations      | Remove a destination you no longer need in the Connect dashboard, then rerun setup.                                                                                          |
| Mentions never reach the deployment                     | Rerun `eve add channel/slack` to repair the production destination. Check branch destinations in the Connect dashboard.                                                      |
| A preview request receives an authentication page       | Exempt the branch domain from Deployment Protection, or add the automation bypass secret to a self-managed Slack app's event and interaction Request URLs.                   |
| Mentions work but DMs or thread replies don't           | Add `message.im` and `im:history` for DMs, or the matching message event and history scope for channel replies, then reinstall the Slack app.                                |
| A long response can't be uploaded as a snippet          | Add the `files:write` bot scope and reinstall the Slack app.                                                                                                                 |
| The thread says the agent couldn't deliver its answer   | Search the Vercel runtime logs for the notice's error id. The `final reply was not delivered to Slack` line carries the error, including one thrown by an authored renderer. |
| The webhook arrives but the agent doesn't reply         | Check the Vercel runtime logs, then the session in [**Agent Runs**](/docs/observability/agent-runs) when your team has access.                                               |

## Audience

Slack public channels are `public`; DMs, group DMs, and private channels are
`private`. When an inbound event does not identify the conversation type, eve
checks Slack and treats an ambiguous or failed lookup as `private`. The audience
controls observability content capture, not access to the channel. See
[Audience](./overview#audience) for the shared channel model.

## 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. Public conversations use the returned `title`, or the triggering message text when `title` is omitted. DMs and private channels always use `Private message` so the run title does not expose message details.

- `onMessage(ctx, message)` handles newly posted Slack messages. 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.
- `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.

Message hooks only decide whether and how a message starts a turn. They don't render anything, so replacing one never changes what people see: eve still acknowledges the message, shows progress, and posts the reply. To change what the agent posts, add [renderers](#customize-rendering).

`onInteraction(action, ctx)` separately handles user-owned `block_actions` callbacks. eve-owned HITL buttons and modal submissions go through `onInputResponse(ctx, submission)` instead. `action.triggerId` carries the click's `trigger_id`, so a button can open a modal with `ctx.slack.request("views.open", { trigger_id, view })`. Slack accepts the id for about 3 seconds. eve attaches the triggering Slack user id to the same model message as its text, preserving speaker attribution without profile lookups.

Use `onShortcut(shortcut, ctx)` for message shortcuts and global shortcuts configured under **Interactivity & Shortcuts** in your Slack app. The handler receives the shortcut callback ID, user, workspace, and trigger ID. Message shortcuts also include the selected message and channel. The context exposes workspace-scoped Slack API access because global shortcuts have no channel or message. eve acknowledges shortcut requests immediately and keeps their handlers alive in the background.

Use `onSlashCommand(command, ctx)` for commands configured under **Slash Commands** in your Slack app. The handler receives the command name, argument text, invoking user, channel, workspace, trigger ID, and response URL. It also receives workspace-scoped Slack API access. eve acknowledges slash commands immediately with an empty `200 OK` response and keeps the handler alive in the background:

```ts title="agent/channels/slack.ts"
import { slackChannel } from "eve/channels/slack";

export default slackChannel({
  async onSlashCommand(command, ctx) {
    if (command.command !== "/ask") return;
    await ctx.slack.request("chat.postEphemeral", {
      channel: command.channelId,
      user: command.user.id,
      text: `Received: ${command.text}`,
    });
  },
});
```

The model-visible `<slack_message>` includes `sender_id`, `bot_user_id` when available, and the `is_mentioned` value from `ctx.isBotMentioned()`. Its `<content>` keeps `<@USER_ID>` intact while converting other mrkdwn to Markdown; routing is unchanged.

When a Slack message includes a link preview, eve includes its attachment text in `<content>` alongside the sender's message, including when the link has words around it. Shared Slack messages work the same way: eve extracts the forwarded message body from Slack's message-unfurl attachment and keeps any top-level comment.

#### Restrict who can invoke the agent

A valid Slack signature proves that Slack sent the event. It does not decide which channel or person your agent should trust. Fail closed in the inbound handler before returning auth, especially when the app is installed in a shared Slack Connect channel:

```ts title="agent/channels/slack.ts"
import { connectSlackCredentials } from "@vercel/connect/eve";
import { defaultSlackAuth, slackChannel, type SlackMessage } from "eve/channels/slack";

const allowedConversations = new Set(["C01234567", "D01234567"]);
const allowedUsers = new Set(["U01234567"]);

function isAllowedUser(message: SlackMessage) {
  const userId = message.author?.userId;
  return Boolean(userId && allowedUsers.has(userId));
}

export default slackChannel({
  credentials: connectSlackCredentials("slack/my-agent"),
  onAppMention(ctx, message) {
    if (!isAllowedUser(message) || !allowedConversations.has(message.channelId)) {
      return null;
    }
    return { auth: defaultSlackAuth(message, ctx) };
  },
  onDirectMessage(ctx, message) {
    if (!isAllowedUser(message) || !allowedConversations.has(message.channelId)) {
      return null;
    }
    return { auth: defaultSlackAuth(message, ctx) };
  },
  onInputResponse(ctx, submission) {
    if (!allowedUsers.has(submission.user.id) || !allowedConversations.has(ctx.slack.channelId)) {
      return null;
    }
    return { auth: ctx.defaultAuth };
  },
});
```

Override every enabled inbound handler; leaving `onDirectMessage` out would retain eve's default DM behavior. Apply the same policy in `onInputResponse` so someone who cannot start a turn cannot resume one through an eve-owned HITL control. Slack Connect can deliver events from external users, so authorize the explicit user and current conversation IDs your policy expects. If access depends on organization membership, resolve it from Slack's shared-channel and user data instead of inferring it from the request signature.

Keep authorization checks inside sensitive tools too. The inbound allowlist controls who can start a turn; tool-level checks control what that authenticated Slack principal may read or change.

`defaultSlackAuth` and `ctx.defaultAuth` identify a person as `slack:<team>:<user>`, where `<team>` is the app installation workspace when Slack reports one, otherwise the event's team. Messages and button clicks resolve the same team, so a Slack Connect user keeps one principal, and one connection sign-in, whether they post in the thread or click an approval.

`onInputResponse` runs after eve decodes a built-in HITL answer but before the parked session resumes. Return `{ auth }` to accept the answer. Returning `null` or throwing rejects it, leaves the request pending, and keeps the Slack controls active. `ctx.defaultAuth` identifies the Slack user who submitted the signed interaction; it does not authorize that user by itself.

If you omit `onInputResponse`, Slack accepts HITL answers with the submitting user's auth, regardless of which message or event handlers you define. Define `onInputResponse` when those handlers enforce an invocation policy that must also apply to HITL answers. Put sensitive prompts in conversations limited to the intended approvers, and re-check `ctx.session.auth.current` inside a sensitive tool before performing its side effect.

#### 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;
    return (await ctx.isDMOrPrivateChannel()) || ctx.isBotMentioned() || (await ctx.isSubscribed())
      ? { auth: null }
      : null;
  },
});
```

`isDMOrPrivateChannel()` returns `true` for DMs, group DMs, and private channels. Slack message events identify public channels as `channel` and private channels as `group`, so those events do not require an API lookup. Events such as `app_mention` omit the conversation type; for those, the helper calls Slack's `conversations.info` API and checks `is_private`. Missing scopes, API errors, and ambiguous responses return `true` so privacy-sensitive behavior fails closed. `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".

#### Load prior thread messages

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.

#### Control overlapping turns

Accepted Slack messages steer by default. Before assistant answer output or local tool execution begins, steering interrupts pending model generation and continues the same turn with the correction. Executing tools finish and checkpoint their results first; after answer output starts, steering waits for the next committed workflow boundary. Reasoning and search progress do not count as answer output, and interrupted provider-managed search may run again.

Mentions and messages filtered before sending never reach the active turn. See [Overlapping messages](./overview#overlapping-messages) for deliveries ignored later by a channel's `deliver` hook. Set `turnPolicy: "queue"` when every active turn should finish:

```ts title="agent/channels/slack.ts"
export default slackChannel({
  credentials: connectSlackCredentials("slack/my-agent"),
  turnPolicy: "queue",
});
```

With `"queue"`, adjacent messages with matching full auth contexts can combine into one turn, keeping their attachments and context in order. Messages from different users, or from the same user with different authorization attributes, stay separate. Anonymous deliveries also stay separate. Batching follows session-inbox order, and turns still share the thread's session history.

Message hooks (`onMessage`, `onAppMention`, and `onDirectMessage`) also receive thread-bound session operations directly on `ctx`. `ctx.cancel({ turnId? })` remains useful for a Stop button: it cancels without sending replacement input. `"accepted"` means the live thread session durably queued that request; a parked session consumes it as a no-op, and `"no_active_turn"` means the thread has no active session owner.

Message and interaction hooks expose every current-owner operation directly on `ctx`:

```ts
async onAppMention(ctx) {
  await ctx.send("Run a separate turn.");
  await ctx.respond(inputResponses);
  await ctx.cancel();
  await ctx.compact();
  await ctx.clear();
  await ctx.reset({ reason: "Start over" });
  const session = await ctx.resolveSession();
  return { auth: null };
}
```

These calls are independent examples; a handler normally chooses one. `ctx.send()` is the only method that can create a session when the thread is unowned. It derives Slack auth from the inbound user unless `auth` is supplied explicitly. Use `await ctx.resolveSession()` only when later work must stay pinned to the currently owning durable session ID.

#### Reset a conversation

Use the thread-bound `ctx.reset({ reason? })` method when the conversation should start over instead. Reset terminally retires the session that currently owns the thread, including any in-flight turn. The next delivered message starts a new session with fresh history, state, and a new session-scoped sandbox:

```ts title="agent/channels/slack.ts"
export default slackChannel({
  credentials: connectSlackCredentials("slack/my-agent"),
  async onMessage(ctx, message) {
    if (message.text.trim() !== "/new") return null;

    await ctx.reset({ reason: "Slack user requested /new" });
    await ctx.thread.post("Started a fresh conversation.");
    return null;
  },
});
```

Reset returns `{ status: "reset", previousSessionId }` when the thread had a session, or `{ status: "no_active_session" }` when it was already free. Both outcomes are successful. Returning `null` after reset consumes the triggering message as a command; return `{ auth }` instead to deliver that message as the first turn of the fresh session. Custom `onInteraction` handlers receive the same flat operations, which is useful for a New conversation button.

### 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. Call `ctx.send(message, { target, auth, title })` zero, one, or many times to start turns. `title` sets the run title without changing the message sent to the model. Each call 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(ctx, event) {
    if (event.type !== "team_join") return;

    await Promise.all(
      onboardingChannels.map((channelId) =>
        ctx.send(
          `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.send` automatically seed the callback's team id into Slack session state.

Because a generic event is not necessarily tied to one thread, put the target in each operation's input:

```ts
const target = { channelId, threadTs };

await ctx.send("Follow up", { auth: null, target });
await ctx.respond(inputResponses, { auth: null, target });
await ctx.cancel({ target, turnId });
await ctx.compact({ target });
await ctx.clear({ target });
await ctx.reset({ reason: "Start over", target });
const session = await ctx.resolveSession({ target });
```

`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. Slash commands reach `onSlashCommand`; add every desired event and required OAuth scope to the Slack app's Event Subscriptions configuration so other callbacks reach their handlers.

### Slack API calls outside a handler

Inside inbound webhook handlers such as `onAppMention`, `onEvent`, `onInteraction`, `onShortcut`, `onSlashCommand`, and `onInputResponse`, `ctx.slack.request(operation, body)` is the raw-API escape hatch. Inside a [renderer](#customize-rendering)'s event handler, use `channel.slack.request(...)` instead. 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 } from "eve/channels/slack";
import { connectSlackCredentials } from "@vercel/connect/eve";

const { botToken } = connectSlackCredentials("slack/my-agent");
const response = await callSlackApi({
  api: { apiBaseUrl: process.env.SLACK_API_BASE_URL },
  botToken,
  context: { teamId: "T0123456789" },
  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) with the supplied `context` at call time and form-encodes the body — the only safe default,
since Slack's JSON support is partial (`conversations.replies` rejects
JSON). It takes the same
[`api`](#point-the-channel-at-a-different-slack-api-host) the channel takes, checked and
confined the same way, so a call made without a channel carries the same guarantees. An
`apiBaseUrl` of `undefined` keeps Slack's own host.

`resolveSlackBotToken` materializes a `SlackBotToken` to a string when you need the
bearer token itself.

### Point the channel at a different Slack API host

```ts
slackChannel({
  api: {
    apiBaseUrl: "https://slack-simulator.example/api/slack",
    fetch: (input, init) => {
      const headers = new Headers(init?.headers);
      headers.set("x-vercel-protection-bypass", process.env.SLACK_SIMULATOR_BYPASS_SECRET ?? "");
      return fetch(input, { ...init, headers });
    },
    fileBaseUrl: "https://slack-simulator.example/files",
  },
  credentials: { botToken: process.env.SLACK_BOT_TOKEN },
});
```

The bases say where a call may go. `apiBaseUrl` is the base Slack Web API method names resolve against, and defaults to `https://slack.com/api/`. `fileBaseUrl` adds a base for attachment downloads and falls back to `apiBaseUrl`: a download URL under it is fetched with the bot token, in addition to `files.slack.com` and Enterprise Grid file URLs. eve downloads every other public `https:` URL without the bot token. Both bases must be absolute `http:` or `https:` URLs carrying no query string or fragment; `slackChannel()` throws on one that is not, naming the option. A missing trailing slash is added, so `https://sim.example/api` sends `chat.postMessage` to `https://sim.example/api/chat.postMessage`.

`api.fetch` says how the traffic travels. It replaces the global `fetch` for calls to the configured bases — use it to attach the per-call header a host behind an authenticating proxy requires, as the example does for a simulator behind Vercel Deployment Protection. eve confines it to those bases' origins, so that header reaches the simulator alone: `files.slack.com`, and any other host Slack names in a response or an inbound payload, keep the global `fetch`. A wrapper therefore attaches its credential unconditionally. With no base configured every call already goes to Slack's own hosts, and `api.fetch` carries all of them, which is what observes or stubs the traffic in a test.

### Delivery

eve shows an agent's progress in two places. Short work, such as thinking or a tool call, appears in the thread status and never posts a message. [Tasks](/docs/tools/tasks), which keep working while the conversation continues, appear in a task card: one message per turn that eve updates in place. Replies post as ordinary messages, so the thread holds the conversation, the task card, and nothing else.

| Moment                                            | Thread status                                                                                                                      |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| A mention or DM arrives                           | `Thinking...`, right away, while the message hook is still deciding                                                                |
| The message hook drops the message                | Cleared                                                                                                                            |
| The turn starts                                   | `Thinking...`                                                                                                                      |
| The model reasons                                 | Its latest reasoning heading, or its latest finished sentence                                                                      |
| The model calls tools                             | Its narration before the calls, or the first action label, such as `Search useEveAgent`, plus `+N more` for the step's other calls |
| A tool reports progress                           | The tool's `label.delta` text                                                                                                      |
| A tool finishes                                   | The tool's `label.complete` text, when it defines one; otherwise the status stays                                                  |
| The model starts its next step                    | `Reviewing researcher's results...` after a task finishes, otherwise the same status again                                         |
| The model streams a long reply                    | `Writing a reply...`, once the text runs past 280 characters                                                                       |
| The turn waits on its tasks                       | `Waiting on researcher...`, or `Waiting on 3 tasks...`                                                                             |
| The turn waits on an approval, answer, or sign-in | Cleared until the turn resumes, then the status it had                                                                             |
| A reply posts                                     | Cleared by Slack                                                                                                                   |

A model step never resets the status to a generic line. The status keeps naming the latest work until something newer replaces it, so a user watching the thread sees `Checking recent deploys`, then `Found 4 failed deploys`, then `Reading build logs`, without `Thinking...` in between.

Slack removes a thread status after two minutes without a message from the app, and clears it whenever the app posts in the thread. eve sets the current status again at each model step, after it posts a new task card, and as a tool reports progress. A tool that runs longer than two minutes without reporting progress loses its status. Report progress with [`label.delta`](/docs/tools#label-tool-activity), or run the work as a [task](/docs/tools/tasks), whose card shows it is still going.

Action labels follow the same rule as the task card titles described below. They leave out the model's own `eve__task_wait` and `eve__task_cancel` calls. A label can include part of the call's input, such as a search query or a `bash` command, as labels always could. Reasoning shows one piece at a time: a heading when the model titles its reasoning, as reasoning summaries do, otherwise the latest finished sentence. Each piece of reasoning, and each progress label, stays up for at least three seconds, so the status changes at a readable pace instead of sending a Slack request per token. Every status is cut to 50 characters at a word boundary.

Replies up to 12,000 characters render inline. Longer replies are uploaded unchanged as a Markdown snippet named `eve-response.md` with a short in-thread note, which requires the `files:write` bot scope.

eve's default renderer also recovers when Slack refuses its reply as too large or malformed (`msg_too_long`, `msg_blocks_too_long`, `msg_blocks_too_many`, `invalid_blocks`, or `invalid_blocks_format`): a refused Markdown message is uploaded as the same `eve-response.md` snippet, without posting the message again. The default delivers the reply it receives, so a change a [renderer](#customize-rendering) passes to `next()`, such as a redaction, carries into the snippet. eve logs each fallback it takes, `inline` or `snippet`, with Slack's error code in a `Slack refused the final reply` warning. The log never includes the reply text.

When a final reply still doesn't reach Slack, because the upload fails, Slack returns another error, or an authored renderer throws, eve logs the error and posts a short notice in the thread: `I finished, but couldn't deliver my answer in Slack (error id ...). Please ask me to resend it.` The error id matches the `final reply was not delivered to Slack` line in the Vercel runtime logs. The notice carries no reply content: eve never re-sends a reply that a renderer failed to post. The next message in the session tells the model that its previous reply was not delivered, so it doesn't assume the user read it.

This applies to the turn's final reply only. A failed status update, tool-call narration, or other event is logged and skipped. Delivery failures don't trigger a model retry.

When the model replies while its tasks keep working, that reply posts like any other, and the reply after the results posts as another message. A schedule's session posts only the final reply.

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)`.

### Task card

When a turn starts its first task, eve posts a task card in the thread and keeps it current until the turn and its tasks finish. One task renders as a Slack `task_card` block; two or more render as a `plan` block with one row per task the turn started. What an agent task does on its own, including the tasks it starts, stays inside that task and does not add rows. The card needs no configuration beyond the `chat:write` scope the channel already uses.

Each row shows:

- **Title:** the tool's [`label.start`](/docs/tools#label-tool-activity), or a readable form of the tool name. eve drops the extension or memory namespace before `__` and splits the rest into words, so `crm__sync_accounts` and `syncAccounts` both show as `Sync accounts`. A connection call shows the connection and its tool, as in `Linear: List issues`. An agent task shows the agent name and the first sentence of its brief (`Researcher: Find the incidents behind the checkout spike`). A task the agent hands to its own copy shows the brief alone. eve can't infer brand casing from a name, so `github` shows as `Github`; set `label.start` when the exact wording matters.
- **Waiting:** while the task waits on an approval, an answer, or a sign-in, the row says so, as in `Waiting for approval`. In a DM or private channel it also shows the request's prompt; elsewhere it shows only what kind of request is waiting, since `approvalChannel` can send the request to one person. The request itself posts wherever `approvalChannel` sends it, and the row goes back to working once the request resolves.
- **How it ended:** the first sentence of the result or the agent's reply, cut to about 100 characters, `Failed` for a failure, or a line for a cancelled task that says why it stopped. A task the model cancelled with `eve__task_cancel` shows `Stopped early since it was no longer needed` with a success check, since the model no longer needed that work. A task stopped by [cancelling the turn](#message-hooks) shows `Stopped by request`, one stopped because its turn ended shows `Stopped when the turn ended`, and one whose run stopped on its own shows `Stopped`. Those rows and failed rows never show a success check. In a DM or private channel, a failure also shows the first sentence of its error, as in `Failed: Rate limited`. Error text can carry internal details, so everywhere else, including proactive sessions without a known `audience`, shows `Failed` alone. A completed or failed task that ran for a second or more also shows how long it took: `Done in 1m 14s: Found three incidents.` or `Failed after 3m`.

The plan title summarizes the turn: `Working on 2 tasks`, `1 of 2 tasks done`, `Waiting for approval`, then `Finished 2 tasks in 3m` or `Finished 3 tasks in 5m 12s: 1 failed, 1 stopped`. When every task calls a declared subagent or remote agent, and the turn calls three or fewer of them, the title names them instead of counting: `Asking researcher and reviewer`, then `Waiting on reviewer · 1 of 2 tasks done`. Tasks the model cancelled with `eve__task_cancel` count as finished, not stopped. A resumable task continued in a later turn appears in that turn's card.

A turn stays open while its tasks work, so messages you send during that time continue the same turn. When that turn starts a task after every task on its card has settled, eve marks that card finished and posts a new card for the new work, so the new tasks don't update a card further up the thread.

eve writes the card from the session's own events as they happen, and only when it changed. A failed Slack call never fails the turn: eve tries a failed write once more after a second, then again on the turn's next change. If someone deletes the card, eve posts it again on its next change. Card posts never unfurl links. Schedule sessions and sessions without a Slack thread get no card.

### Customize rendering

Without `renderers`, eve renders everything above with its default renderer. Pass `renderers` to change what the agent posts without replacing that default. Each renderer wraps the ones after it, ending with eve's default renderer, and has three optional parts:

- `received(message, channel, next)` acknowledges a mention or DM the moment it arrives, and another channel message once `onMessage` dispatches it. eve's default sets `Thinking...`.
- `events` handles session events such as `message.completed` or `input.requested`. Each handler receives `(eventData, channel, ctx, next)`, with Slack platform handles on `channel.thread` and `channel.slack`; a `session.failed` handler receives `(eventData, channel, next)`.
- `taskCard(view, next)` returns the task card's blocks and notification text, or `null` for no card.

Call `next()` to run the rest of the chain, ending with eve's default. Call it after your own work, before it, or with changed data, as in `next({ ...event, message })`; the rest of the chain runs at most once. Skip `next` to replace the default for that event. This renderer adds feedback buttons below each final reply and a context row to the finished task card:

```ts title="agent/channels/slack.ts"
import { connectSlackCredentials } from "@vercel/connect/eve";
import { defineSlackRenderer, slackChannel } from "eve/channels/slack";

const feedback = defineSlackRenderer({
  events: {
    async "message.completed"(event, channel, _ctx, next) {
      await next(); // eve posts the reply, or uploads a long one as a snippet
      if (event.finishReason === "tool-calls") return;
      await channel.thread.post({
        blocks: [
          {
            type: "context_actions",
            elements: [
              {
                type: "feedback_buttons",
                action_id: "reply_feedback",
                positive_button: { text: { type: "plain_text", text: "Helpful" }, value: "up" },
                negative_button: {
                  text: { type: "plain_text", text: "Not helpful" },
                  value: "down",
                },
              },
            ],
          },
        ],
        text: "Was this helpful?",
      });
    },
  },
  taskCard(view, next) {
    const card = next(view);
    if (card === null || view.state !== "finished") return card;
    const note = {
      type: "context",
      elements: [{ type: "mrkdwn", text: "Results are in the reply below." }],
    };
    return { ...card, blocks: [...card.blocks, note] };
  },
});

export default slackChannel({
  credentials: connectSlackCredentials("slack/my-agent"),
  renderers: [feedback],
});
```

A `message.completed` handler that posts the final reply itself, instead of calling `next()`, owns that delivery. If it throws, eve logs the error, posts the content-free notice described in [Delivery](#delivery), and tells the model on the next message. eve doesn't retry or re-send your reply, because your renderer may post elsewhere, change the content, or attach data eve can't reproduce. If you post the reply and then a separate follow-up message, as the feedback example above does, catch the follow-up's errors in your handler. Otherwise eve posts the notice even though the reply arrived.

To get eve's fallbacks for a reply you render, post it with `postCompletedSlackReply`. You choose the content; eve makes the Slack calls:

1. Your `blocks`, when given, with `text` as the notification text. `text` defaults to `markdown`.
2. Your `markdown` as one native Markdown message, when it fits in `SLACK_MARKDOWN_TEXT_MAX_LENGTH` (12,000) characters. eve moves on from the blocks only when Slack refuses them as too large or malformed.
3. Your `markdown` uploaded as the `eve-response.md` snippet, when it is longer or Slack refuses the Markdown message.

Any other error, and a failed upload, is thrown to your handler. If your handler lets it propagate, eve posts the notice. eve only sends the `blocks`, `text`, and `markdown` you pass. Slack caps the combined text of all `markdown` blocks in one message at 12,000 characters, so a reply that fits in `markdown_text` can still be refused as blocks.

```ts title="agent/channels/slack.ts"
import { defineSlackRenderer, postCompletedSlackReply, slackChannel } from "eve/channels/slack";

const sourcedReplies = defineSlackRenderer({
  events: {
    async "message.completed"(event, channel, _ctx, next) {
      if (event.finishReason === "tool-calls" || !event.message) return next();
      await postCompletedSlackReply(channel, {
        blocks: [
          { type: "markdown", text: event.message },
          { type: "context", elements: [{ type: "mrkdwn", text: "Sources: internal docs" }] },
        ],
        markdown: event.message,
      });
    },
  },
});

export default slackChannel({ renderers: [sourcedReplies] });
```

Pass a string, as in `postCompletedSlackReply(channel, reply)`, to deliver Markdown alone the way eve's default does.

`taskCard` runs for every turn that calls a tool, and receives the same view eve's default renders from:

- `state`: `blocked` while any task waits on a person, `working` while the turn or any other task works, then `finished`.
- `tasks`: the tasks the turn started, each with a `title`, `status`, `blockedOn`, and `summary`. A failed task's `summary` and a blocked task's `blockedOn.label` are present only in a DM or private channel.
- `actions`: the turn's other tool calls, each with its tool `name`, `title`, `status`, and `input`. `input` is left out when its JSON is longer than 4,096 characters.

`taskCard` is synchronous and pure: eve posts the card and updates it as the view changes. eve's default returns `null` for a turn that started no tasks. Its block keeps the same `block_id` for the whole turn; keep that `block_id` on a block you build in its place. To change a row's title in every channel, give the tool a [label](/docs/tools#label-tool-activity) instead.

Renderers run in the order you list them, and the first one is outermost. `onInteraction` receives clicks on buttons a renderer posts, such as the feedback buttons above.

### Show your own tool on the task card

A renderer can draw any of the turn's tool calls from their input. For example, give the agent a `plan` tool that records a checklist, and show the checklist ahead of eve's task rows:

```ts title="agent/tools/plan.ts"
import { defineTool } from "eve/tools";
import { z } from "zod";

export default defineTool({
  description:
    "Show the steps of a multi-step request as a checklist. Pass the complete list on every call.",
  inputSchema: z.object({
    items: z.array(
      z.object({
        status: z.enum(["pending", "working", "completed", "failed"]),
        title: z.string(),
      }),
    ),
  }),
  async execute() {
    return { updated: true };
  },
});
```

```ts title="agent/channels/slack.ts"
import { type BlockKitBlock, defineSlackRenderer, slackChannel } from "eve/channels/slack";

interface PlanItem {
  readonly status: "pending" | "working" | "completed" | "failed";
  readonly title: string;
}

const SLACK_STATUS = {
  completed: "complete",
  failed: "error",
  pending: "pending",
  working: "in_progress",
} as const;

const plan = defineSlackRenderer({
  taskCard(view, next) {
    const card = next(view);
    const calls = view.actions.filter((action) => action.name === "plan");
    const items = calls.at(-1)?.input?.items as PlanItem[] | undefined;
    if (items === undefined) return card;
    // eve's card is one `plan` block, or one `task_card` block for a single task.
    const [block] = card?.blocks ?? [];
    const taskRows: BlockKitBlock[] =
      block === undefined ? [] : block.type === "plan" ? (block.tasks as BlockKitBlock[]) : [block];
    const planRows = items.map((item, index) => ({
      status: SLACK_STATUS[item.status],
      task_id: `plan_${index}`,
      title: item.title,
    }));
    const tasks = [...planRows, ...taskRows.map(({ type: _type, ...row }) => row)];
    return {
      blocks: [{ type: "plan", block_id: block?.block_id, title: "Plan", tasks }],
      text: `Plan: ${items.map((item) => item.title).join(", ")}`,
    };
  },
});

export default slackChannel({ renderers: [plan] });
```

A turn that only plans gets a card too, because `taskCard` runs for every turn that calls a tool.

### Human-in-the-loop (HITL)

HITL renders as Slack buttons and selects. Fixed-choice actions and freeform modal submissions pass through `onInputResponse` before the parked session resumes. The initial **Type your answer** action only opens eve's modal; the hook runs when the user submits an answer.

Set `approvalChannel` to choose where each input request appears, including tool approvals and questions from `ctx.ask()`. Return `"direct-message"` when the request must be visible only to the Slack user who triggered the turn, or `"thread"` for the normal shared thread.

```ts title="agent/channels/slack.ts"
import { slackChannel } from "eve/channels/slack";

export default slackChannel({
  approvalChannel: (request) =>
    request.kind === "question" || request.action.toolName === "review_answer"
      ? "direct-message"
      : "thread",
});
```

The callback receives the `InputRequest` and current `SessionContext`, so it can switch on `kind`, `action`, or presentation properties. Omit it to keep every request in the thread, or return `"direct-message"` unconditionally to make every request private. If eve cannot resolve the triggering Slack user for a direct message, it logs the undelivered request and posts no fallback, so private delivery fails closed. The existing `onInputResponse` or tool approval response policy still decides whether the person who clicks may respond.

When a tool approval resolves, eve retires the messages it posted for it. The card loses its buttons and shows the answer and who gave it, the separate tool-input post is deleted, and a direct-message request's `Waiting on approval` note in the thread changes to the outcome. This also happens when the approval ends without a click, for example when the person replies in the thread instead, in which case the card reads `No longer needed`.

A renderer's `input.requested` handler can extend delivery and then hand the requests on to eve with `next()`. For example, an application can add non-sensitive context to the Slack thread before eve renders the actionable controls. The configured `approvalChannel` still sends sensitive reviews to a DM and keeps ordinary requests in the thread:

```ts title="agent/channels/slack.ts"
import { slackChannel } from "eve/channels/slack";
import { isPrivate } from "../lib/review-policy";

export default slackChannel({
  approvalChannel(request) {
    return isPrivate(request) ? "direct-message" : "thread";
  },
  renderers: [
    {
      events: {
        async "input.requested"(_event, channel, _ctx, next) {
          await channel.thread.post({
            blocks: [
              {
                type: "context",
                elements: [
                  {
                    type: "mrkdwn",
                    text: "A review is needed before this work can continue.",
                  },
                ],
              },
            ],
            text: "A review is needed before this work can continue.",
          });
          await next();
        },
      },
    },
  ],
});
```

#### Render and decode HITL controls yourself

`eve/channels/slack` exports the helpers eve uses for its own HITL controls. `renderInputRequestBlocks(request, route?)` returns the Block Kit blocks for one `InputRequest`. `deriveHitlResponse(action)` decodes a click on those blocks into `{ response, kind?, route? }`, where `response` is the eve `InputResponse`, or returns `null` for any other action. Every eve-owned control's `action_id` starts with `HITL_ACTION_PREFIX` (`"eve_input:"`), so give your own buttons a different prefix to keep them routed to `onInteraction`.

```ts
import { deriveHitlResponse, renderInputRequestBlocks } from "eve/channels/slack";

const [card] = renderInputRequestBlocks(request) as [
  { actions: { action_id: string; value: string }[] },
];
const button = card.actions[0]!;

// For a request whose first option id is "approve":
deriveHitlResponse({ actionId: button.action_id, value: button.value });
// { response: { optionId: "approve", requestId: request.requestId } }
```

Use them to test a custom renderer against eve's wire format, or to post eve's controls from your own renderer. A click on these controls in the session's Slack thread resumes the session through `onInputResponse`, the same as eve's own controls. eve does not track controls you post yourself, so update or remove those messages from your renderer's `approval.settled` and `input.resolved` handlers. To pass `response` to `respond()` yourself, validate it first with `parseInputResponses` from `eve/client`.

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 Slack user behind the event's `principalId`, device code included, and then updates that public status when `authorization.completed` fires. A renderer's `authorization.required` 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. Its `next()` reaches eve's default, which posts the public status; skip `next` to deliver the challenge yourself, as this handler does by direct message. `state.slackUsersByPrincipal` maps each principal eve has seen in the thread, through a message or an input response, to its Slack user ID. If the principal has no entry, the challenge is not sent privately.

The default private prompt names the service, says the agent is paused, and pairs a **Connect** button with **Cancel**. Cancel ends the held turn and removes the prompt; the public status, "Paused: waiting for @user to connect Linear…", then reads "Linear sign-in cancelled". Approval cards say the agent is paused until someone approves or cancels.

```ts
renderers: [
  {
    events: {
      "authorization.required"(eventData, channel) {
        const userId =
          eventData.principalId === undefined
            ? undefined
            : channel.state.slackUsersByPrincipal?.[eventData.principalId];
        if (!userId || !eventData.authorization?.url) return;
        return channel.postDirectMessage(userId, `Sign in to continue: ${eventData.authorization.url}`);
      },
    },
  },
],
```

The private authorization button uses the label **Sign in**, so long connection display names do not make the button invalid. The public status identifies the connection.

### Proactive sessions

Start a Slack conversation from a [webhook on another channel](./custom#cross-channel-hand-off) or a [schedule](../schedules#handler-form-run), without waiting for someone to message the app.

Pass `{ channelId }` as the target to start a new thread. The agent's first post becomes the thread's root message. You can also set these target fields:

| Field                | Use                                                                                       |
| -------------------- | ----------------------------------------------------------------------------------------- |
| `threadTs`           | Continue an existing thread instead of creating one.                                      |
| `initialMessage`     | Post a card as the thread root before the agent runs. Cannot be combined with `threadTs`. |
| `installationTeamId` | Select the app installation workspace when using a function-form bot token.               |
| `audience`           | Set the destination's visibility when you know it. Defaults to `unknown`.                 |

The default mention handler lets teammates continue the session by mentioning the app in the thread. To accept replies without a mention, [configure an `onMessage` hook and the required Slack subscriptions and scopes](#continue-conversations-without-repeated-mentions).

For a Slack post without an agent turn, use `ctx.thread.post(...)` in an inbound handler or `channel.thread.post(...)` in a renderer's event handler. Outside a handler, use [`callSlackApi(...)`](#slack-api-calls-outside-a-handler).

### Attachments

Inbound files behind authenticated Slack URLs are staged with `fetchFile`. See [File uploads](./custom#file-uploads) for the `fetchFile` contract.

The agent receives the files attached to the message that reaches it. When a mention carries no files, eve also collects the files posted in the thread since the previous mention of the app, up to 10 messages back, so a person can share a file and then ask about it in a follow-up. This works for mentions up to 1,000 replies into a thread. The app's own messages are skipped. A DM, or a thread where the app answers without a mention, gets no lookback, because each earlier message brought its own files.

Images, PDFs, audio, video, and other files all reach the agent. See [Inbound attachments](../sandbox#inbound-attachments) for which files the model reads directly and which it opens with tools. Files shared through an integration such as Google Drive or Dropbox are links to the other service, not Slack files, so eve skips them; their link stays in the message text. A download that fails or takes longer than 30 seconds reaches the model as a note.

Private file downloads resolve credentials from the session's app installation workspace and require the Slack bot token's `files:read` scope. Add the scope and reinstall the Slack app before sending attachments.

Downloads cover Slack's own file hosts plus URLs under a configured [`api.fileBaseUrl`](#point-the-channel-at-a-different-slack-api-host), and travel on `api.fetch` when one is configured.

Staged files live in the session sandbox under `/workspace/.eve/attachments`. Session history keeps a reference to that path, not a second copy of the Slack file. If the backend can no longer reattach the original sandbox, eve cannot restore the attachment bytes from history; later turns receive a missing-file result. Store files outside the sandbox when the agent must retain them independently of sandbox availability.

## 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
