---
title: Chat SDK Reference
description: Chat SDK channel options, webhook routes, send and respond, steering, delivery, human-in-the-loop, sign-ins, proactive sessions, and attachments.
url: "https://eve.dev/docs/channels/chat-sdk/reference"
docs_index: /llms.txt
type: reference
---

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

This page documents every part of `chatSdkChannel` from `eve/channels/chat-sdk`. For setup and common tasks, start with [Chat SDK](/docs/channels/chat-sdk).

The Chat SDK channel connects your agent to any [Vercel Chat SDK](https://chat-sdk.dev) adapter, such as `@chat-adapter/slack` or `@resend/chat-sdk-adapter`. It lives in a file under `agent/channels/`, such as `agent/channels/resend.ts`. You register handlers for the messages you care about, and call `send` to hand each one to eve. Use it for a surface without a built-in eve channel, or to manage credentials and state with Chat SDK instead of [Vercel Connect](/docs/guides/auth-and-route-protection).

The adapter handles provider auth, webhook verification, and posting messages, and you supply its state store. eve handles sessions, streaming, typing indicators, and human-in-the-loop prompts. Built-in channels such as [Slack](/docs/channels/slack) have their own APIs and take credentials from Vercel Connect or environment variables.

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

Any Chat SDK adapter works, such as `@chat-adapter/slack`, `@chat-adapter/discord`, or `@chat-adapter/telegram`.

## Add the channel

`chatSdkChannel` returns `{ bot, channel, send }`. Register Chat SDK handlers on `bot`, and call `send` from those handlers to start or resume an eve session. 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",
  // One webhook per adapter, at /eve/v1/<name>.
  adapters: {
    resend: createResendAdapter({
      fromAddress: "hello@example.com",
      fromName: "Resend Bot",
    }),
  },
  // In-memory state is for local development only.
  state: createMemoryState(),
  // Email can't edit a sent message, so post each reply once.
  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;
```

- `state` takes any Chat SDK state adapter. In production, use a persistent adapter such as Redis or Upstash, so thread subscriptions and duplicate checks survive restarts.
- `send` accepts a string or an array of message parts. Call it inside a Chat SDK handler, because it starts the turn on the running webhook.
- Pass `idempotencyKey: message.id` to `send` so a retried message reaches the agent once within the session's [recent-key window](/docs/channels/custom#retried-deliveries). Chat SDK can mark a message as seen before eve accepts it, so a shared state adapter alone doesn't guarantee the agent gets it.

Deploy to Vercel production, linking the project if needed:

```bash
eve deploy
```

## Configure the webhook route

Each adapter serves `GET` and `POST` at `/eve/v1/{adapterName}`, so the `resend` adapter above is at `/eve/v1/resend`. Point your provider's webhook, such as the Resend inbound address or the Slack Event Subscriptions URL, at that path. The adapter handles each method. For example, X sends its verification challenge with `GET` and events with `POST`.

Set `route` to change the base path for every adapter, or `routes` to pin one adapter's path:

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

Use `routes` when a provider requires a fixed URL, or to keep an existing endpoint without changing the provider's settings.

## Dispatch

A turn starts when a handler you register on `bot` calls `send`:

- `bot.onNewMention(thread, message)` fires on a new `@mention`, or a new inbound thread on surfaces like email. Call `thread.subscribe()` so later replies in the thread keep reaching the agent.
- `bot.onSubscribedMessage(thread, message)` fires on later messages in a subscribed thread.
- `bot.onAction`, `bot.onReaction`, and `bot.onSlashCommand` fire for adapters that emit them.

`send(message, options)` starts or resumes the eve session. The `thread` you pass picks the session, so replies land on that thread:

```ts
bot.onNewMention(async (thread, message) => {
  // `title` sets the session's display title without changing the model message.
  await send(message.text, { thread, title: "Support request" });
});
```

`options` accepts `thread` and `adapterName`, plus the channel send options `auth`, `context`, `outputSchema`, `title`, `callback`, `turnPolicy`, and `idempotencyKey`. `auth` names who the turn runs as. Omit it for `null`, an anonymous turn.

To answer open approvals and questions from controls you render yourself, call `respond(answers, { thread, auth?, context? })`. It never creates a session. This example answers a custom button whose `value` carries the request's `interactionId`:

```ts
export const { bot, channel, send, respond } = chatSdkChannel({/* ... */});

bot.onAction("approve_deploy", async (event) => {
  if (!event.thread || !event.value) return;
  await respond([{ interactionId: event.value, optionId: "approve" }], { thread: event.thread });
});
```

## Steering

By default, a message that arrives while the agent is answering joins the running turn (`turnPolicy: "steer"`). The turn keeps its ID and completed tool work. [Message delivery and steering](/docs/concepts/execution-model-and-durability#message-delivery-and-steering) says when it applies. With no active turn, the message starts one.

Set `turnPolicy: "queue"` to let the active turn finish first:

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

Set the policy on `chatSdkChannel({ turnPolicy })` or per `send(...)`. `turnPolicy` governs overlapping eve turns, and Chat SDK's `concurrency` option governs overlapping webhook handlers. Use `concurrency: "concurrent"` so each new message reaches the running turn at once.

## Delivery

The default handlers post the agent's reply to the thread as markdown (`{ markdown: … }`), so adapters render rich text and email HTML.

By default the channel streams. It posts a message and edits it as text arrives (`content.delta`), at most once per `streamingEditIntervalMs`. A last edit adds the full text if needed, and the channel never posts the streamed reply again. If the final edit fails, the channel logs the error and posts a short notice with the error id. On the next message, it tells the model that the user hasn't seen its reply.

Set `streaming: false` for surfaces that send one message per turn or can't edit messages, such as email and WhatsApp. The reply then posts once, when the turn completes.

Where the adapter supports typing indicators, the channel shows `Working…` when a turn starts. On each tool call, it shows the call's [label](/docs/tools#label-tool-activity) or readable name, except for `eve__task_wait` and `eve__task_cancel`.

## Optional capabilities degrade gracefully

When an adapter's `startTyping` throws a `NotImplementedError`, or an error with code `NOT_IMPLEMENTED`, the channel skips typing indicators. An unsupported `editMessage` turns off streaming edits for the rest of the session, so later replies post once at the end. A reply that already has a streamed message still gets the final edit attempt, and a failure is reported as above. Use the same check in custom `events` with `isNotImplemented`:

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

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

```ts
chatSdkChannel({
  userName: "Resend Bot",
  adapters: { resend: createResendAdapter({ fromAddress: "hello@example.com" }) },
  state: createMemoryState(),
  events: {
    "content.completed"(event, ctx) {
      const { kind, phase, value } = event.data;
      if (kind !== "text" || phase !== "reply" || typeof value !== "string" || !ctx.channel.thread)
        return;
      return ctx.channel.thread.post({ markdown: value });
    },
  },
});
```

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

Approvals and questions render as a Chat SDK `Card` with buttons, and the channel wires `bot.onAction` to `respond`, so clicks resume the session. Adapters that can't render cards, such as Photon iMessage, get text with numbered options:

- A reply sent through `send` with an option's number or label answers the request. For example, `1` or `approve` runs an approval-gated tool, and `2` or `deny` stops it.
- If the question allows a custom answer or has no options, any other text is the answer.

Set `inputActionPrefix` if your app already uses the default `eve_input:` prefix. Set `resolveInputAuth` to keep user or tenant auth when a click resumes the session:

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

## Sign-ins

When a tool needs a connection the person hasn't authorized, eve shows a sign-in prompt with the connection's link, code, or instructions. In a direct message, eve posts the prompt in the thread and edits it once the sign-in completes.

Elsewhere, the prompt is a credential, so eve sends it only to the person signing in. It uses an ephemeral message where the adapter supports one, and otherwise a direct message. The thread gets a status without the link, which eve edits once the sign-in completes.

eve finds that person by matching the turn's `auth` user to the author of the message you passed to `send`. Private sign-ins need both of these:

- per-person `auth` on each `send`, so each `auth` names one person; and
- the handler's `Thread` object as `thread`, rather than a thread id string, so eve sees who wrote the message.

```ts
bot.onSubscribedMessage(async (thread, message) => {
  await send(message.text, {
    thread,
    auth: {
      authenticator: "slack",
      principalType: "user",
      principalId: message.author.userId,
      attributes: {},
    },
  });
});
```

Without both, including with the default `auth: null`, the thread gets only a notice to continue in a direct message, and the prompt isn't shown anywhere. The same happens when several people share one `auth`, such as tenant auth, or when the adapter has neither ephemerals nor `openDM`.

## Proactive sessions

To start a session without an incoming webhook, call `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 have only 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

To forward a Chat SDK message's attachments, convert it with `messageContentOf`. It returns `message.text` when there are no attachments. Otherwise it returns message parts: the text, plus one part per attachment:

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

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

When the adapter sets `fetchData`, as the Slack, Google Chat, WhatsApp, and Twilio adapters do, eve downloads the attachment when the turn runs. It uses the adapter's credentials and `rehydrateAttachment`, and passes the file bytes, because the model provider can't open platform URLs that need credentials. An attachment with only a URL is downloaded when the URL is public `https:`.

Every other attachment reaches the agent as a short note, so the agent still knows a file was sent. This covers:

- an attachment from an adapter without `rehydrateAttachment`;
- a non-public URL or a failed download;
- a file over 25 MB; and
- an attachment with neither a download nor a URL.

Audio attachments, such as voice notes, reach the model as a file label, not as sound. To answer them, give the agent a transcription tool; see [Voice notes](/docs/patterns/voice-notes).

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

## Audience

Workspace-visible threads are `public`, DMs and private or external threads are `private`, and threads of unknown visibility are `unknown`. The audience controls which message content observability records, not who can access the thread. See [Audience](/docs/channels#audience).

## Options

| 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.           |
| `turnPolicy`              | `"steer"`    | `"steer"` or `"queue"`. `send(...)` can override it per message.         |
| `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](/docs/channels): what every channel shares, and every built-in channel
- [Slack](/docs/channels/slack), [Discord](/docs/channels/discord), and [Telegram](/docs/channels/telegram): built-in channels with platform-specific APIs
- [Custom channels](/docs/channels/custom): build a channel for any surface with `defineChannel`
- [Auth & route protection](/docs/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)