---
title: Chat SDK
description: Bridge any Vercel Chat SDK adapter — Slack, Discord, Telegram, WhatsApp, email, and more — to your agent through one channel, using your own credentials and state store.
---

# Chat SDK



The Chat SDK channel connects your agent to any [Vercel Chat SDK](https://chat-sdk.dev) adapter. You pick an adapter (`@chat-adapter/slack`, `@resend/chat-sdk-adapter`, and so on), register handlers for the messages you care about, and call `send` to hand each turn to eve. Use it to reach a surface eve does not ship a first-class channel for, or when you want to manage credentials and state with the Chat SDK's own primitives rather than [Vercel Connect](../guides/auth-and-route-protection). See [Channels](./overview) for the contract this builds on.

You supply an adapter and a state store: the adapter owns provider auth, webhook verification, and delivery, while eve owns session dispatch, streaming, typing, and human-in-the-loop. First-class channels such as [Slack](./slack) use their own channel APIs and can manage credentials through Vercel Connect or environment variables you configure directly.

## Install

Add eve, the Chat SDK core (`chat`), an adapter, and a state adapter. The example below uses the Resend email adapter with the in-memory state store:

```bash
npm install eve@latest chat @resend/chat-sdk-adapter @chat-adapter/state-memory
```

Swap in whichever adapter matches your surface — `@chat-adapter/slack`, `@chat-adapter/discord`, `@chat-adapter/telegram`, and so on. Any Chat SDK adapter works.

## Add the channel

`chatSdkChannel` returns `{ bot, channel, send }`. Register Chat SDK handlers on `bot`, call `send` from those handlers to start or resume an eve session, and export `channel` as the module default:

```ts title="agent/channels/resend.ts"
import { createMemoryState } from "@chat-adapter/state-memory";
import { createResendAdapter } from "@resend/chat-sdk-adapter";
import type { Message, Thread } from "chat";
import { chatSdkChannel } from "eve/channels/chat-sdk";

export const { bot, channel, send } = chatSdkChannel({
  userName: "Resend Bot",
  adapters: {
    resend: createResendAdapter({
      fromAddress: "hello@example.com",
      fromName: "Resend Bot",
    }),
  },
  state: createMemoryState(),
  streaming: false,
});

bot.onNewMention(async (thread: Thread, message: Message) => {
  await thread.subscribe();
  await send(message.text, { thread });
});

bot.onSubscribedMessage(async (thread: Thread, message: Message) => {
  await send(message.text, { thread });
});

export default channel;
```

`adapters` is a map of adapter name to adapter instance; each entry mounts its own webhook (see below). `state` takes any Chat SDK state adapter: `createMemoryState()` is fine for local development, but use a durable adapter (Redis, Upstash, etc.) in production so thread subscriptions and inbound deduplication survive restarts. `send` accepts a plain string, an AI SDK `UserContent` array, or a `SendPayload`, and must be called from inside a Chat SDK handler — it dispatches the turn on the webhook that is currently running.

Deploy once the channel file is in place:

```bash
eve deploy
```

`eve deploy` links the project if needed and deploys to Vercel production.

## Configure the webhook route

Each adapter mounts one `POST` route at `/eve/v1/{adapterName}`, so the `resend` adapter above is served at `/eve/v1/resend`. Point your provider's webhook (the Resend inbound address, the Slack Event Subscriptions URL, etc.) at that path.

Override the base path for every adapter with `route`, or pin an individual adapter's path with `routes`:

```ts
export const { bot, channel, send } = chatSdkChannel({
  userName: "Resend Bot",
  adapters: { resend: createResendAdapter({ fromAddress: "hello@example.com" }) },
  state: createMemoryState(),
  route: "/webhooks", // resend now mounts at /webhooks/resend
  routes: { resend: "/webhooks/inbound-email" }, // …or pin it exactly
});
```

Use `routes` when a provider requires a fixed URL or when you are migrating an existing endpoint without changing the provider's settings.

## How the channel handles messages

### Dispatch

You choose which Chat SDK events start a turn by registering handlers on `bot` and calling `send`:

* `bot.onNewMention(thread, message)` fires on a fresh `@mention` (or, for surfaces like email, a new inbound thread). Call `thread.subscribe()` when you want later replies in the same thread to keep reaching the agent.
* `bot.onSubscribedMessage(thread, message)` fires on subsequent messages in a subscribed thread.
* `bot.onAction`, `bot.onReaction`, and `bot.onSlashCommand` are available for adapters that emit them.

`send(input, options)` starts or resumes the eve session. The `thread` you pass determines the continuation token and the persisted channel state, so replies land back on the originating thread:

```ts
bot.onNewMention(async (thread, message) => {
  await send(message.text, { thread, title: "Support request" });
});
```

`options` accepts `{ thread, auth?, title?, mode?, callback?, adapterName?, turnPolicy? }`. `title` sets the eve session's display title without changing the model message; `auth` attaches an authenticated principal to the turn.

### Steering

Messages default to `turnPolicy: "steer"`: a message sent while an eve turn is active is durably buffered, then cancels that turn and starts as its replacement. Set `turnPolicy: "queue"` when the active turn should finish first:

```ts
bot.onSubscribedMessage(async (thread, message) => {
  await send(message.text, {
    thread,
    turnPolicy: "queue",
  });
});
```

Cancellation-backed steering emits `turn.cancelled`, and the replacement message starts a new turn with a new turn ID. Partial output and completed side effects from the interrupted turn are not rolled back. If no turn is active, the message is sent normally. You can set the policy once on `chatSdkChannel({ turnPolicy })` or override it per `send(...)`.

This policy controls overlapping eve turns. Chat SDK's separate `concurrency` option controls overlapping webhook handlers; use `concurrency: "concurrent"` when each inbound message should reach the steering path immediately.

### Delivery

The default handlers post the agent's reply back to the thread — no `events` override required. Completed assistant messages are posted as markdown (`{ markdown: … }`) so adapters render rich text and email HTML rather than a raw string.

Streaming is on by default: the channel posts an initial message and edits it as tokens arrive (`message.appended`), throttled by `streamingEditIntervalMs` (default `1000`). Set `streaming: false` for surfaces that deliver one message per turn — email, for example — so the reply posts once on completion instead of editing:

```ts
chatSdkChannel({
  userName: "Resend Bot",
  adapters: { resend: createResendAdapter({ fromAddress: "hello@example.com" }) },
  state: createMemoryState(),
  streaming: false,
});
```

Typing indicators post automatically where the adapter supports them: `Working…` on `turn.started`, and tool status on `actions.requested`.

### Optional capabilities degrade gracefully

Adapters do not all implement every operation. When an adapter's `startTyping` or `editMessage` throws a `NotImplementedError` (or an error with code `NOT_IMPLEMENTED`), the channel swallows it: typing indicators are skipped, and a streaming edit falls back to a single final post for the rest of the session. You never have to guard optional capabilities in your own handlers. The same predicate is exported as `isNotImplemented` if you want it in custom `events`:

```ts
import { isNotImplemented } from "eve/channels/chat-sdk";
```

Override any default by passing `events`. Handlers receive `(eventData, channel, ctx)`, with the rebuilt Chat SDK thread on `channel.thread`:

```ts
chatSdkChannel({
  userName: "Resend Bot",
  adapters: { resend: createResendAdapter({ fromAddress: "hello@example.com" }) },
  state: createMemoryState(),
  events: {
    "message.completed"(eventData, channel) {
      if (eventData.finishReason === "tool-calls" || !eventData.message || !channel.thread) return;
      return channel.thread.post({ markdown: eventData.message });
    },
  },
});
```

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

HITL prompts render as a Chat SDK `Card` with buttons. Button clicks resume the parked session automatically — the channel wires `bot.onAction` for you. Change the action-id prefix with `inputActionPrefix` (default `eve_input:`) if your app already uses it, and provide `resolveInputAuth` to carry user or tenant auth across the resume:

```ts
chatSdkChannel({
  userName: "Support Bot",
  adapters: { slack: createSlackAdapter() },
  state: createMemoryState(),
  resolveInputAuth: (event) => ({
    authenticator: "slack",
    principalType: "user",
    principalId: event.user?.userId ?? "unknown",
    attributes: {},
  }),
});
```

### Proactive sessions

Start a session without an inbound webhook through `to(channel, target).send(message, { auth })` from a schedule `run` handler, or `ctx.to(channel, target).send(message, { auth })` from another channel. The target is a serialized Chat SDK thread, or `{ threadId, adapterName }` when you only have a provider-native thread id:

```ts
await to(channel, { adapterName: "resend", threadId: "resend:user@example.com" }).send(
  "Your weekly digest is ready.",
  { auth: null },
);
```

### Attachments

`send` takes plain text or an AI SDK `UserContent` array. To forward a Chat SDK message's attachments, convert it with `messageToUserContent`, which returns `message.text` when there are no attachments and a `UserContent` array (text plus one file part per attachment URL) when there are:

```ts
import { messageToUserContent } from "eve/channels/chat-sdk";

bot.onNewMention(async (thread, message) => {
  await send(messageToUserContent(message), { thread });
});
```

See [File uploads](./custom#file-uploads) for how eve stages remote file URLs before the model call.

## Configuration reference

| Option                    | Default      | Purpose                                                                  |
| ------------------------- | ------------ | ------------------------------------------------------------------------ |
| `adapters`                | —            | Map of adapter name to Chat SDK adapter instance. One webhook per entry. |
| `state`                   | —            | Chat SDK state adapter for subscriptions, locks, and dedupe.             |
| `userName`                | —            | Display name for the bot (a standard Chat SDK `ChatConfig` field).       |
| `route`                   | `/eve/v1`    | Base path for generated adapter webhooks (`{route}/{adapter}`).          |
| `routes`                  | —            | Per-adapter path overrides for fixed or migrated webhook URLs.           |
| `streaming`               | `true`       | Post-then-edit streaming. Set `false` for one-message-per-turn surfaces. |
| `streamingEditIntervalMs` | `1000`       | Minimum interval between streaming edits.                                |
| `events`                  | built-in     | Per-event handlers. A supplied handler replaces that built-in default.   |
| `inputActionPrefix`       | `eve_input:` | Prefix for default HITL button action ids.                               |
| `resolveInputAuth`        | `null`       | Auth resolver applied when a HITL button click resumes a session.        |
| `webhook`                 | —            | Extra Chat SDK webhook options (eve owns `waitUntil`).                   |

Any other Chat SDK `ChatConfig` field (for example `concurrency`) is accepted and passed through.

## What to read next

* [Channels overview](./overview): the channel contract and every built-in channel
* [Custom channels](./custom): build a channel for any surface with `defineChannel`
* [Auth & route protection](../guides/auth-and-route-protection): authenticating inbound traffic


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)