Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 42 additions & 22 deletions docs/channels/chat-sdk.mdx
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
---
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."
description: "Bridge any Chat SDK adapter, including iMessage, WhatsApp, and email, to your agent through one channel, using your own credentials and state store."
---

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.
The Chat SDK channel connects your agent to any [Chat SDK](https://chat-sdk.dev) adapter. You pick an adapter (`@chat-adapter/messenger`, `@resend/chat-sdk-adapter`, and so on), register handlers for the messages you care about, and call `send` to hand each turn to eve. The adapter owns provider auth, webhook verification, and delivery, while eve owns session dispatch, streaming, typing indicators, and human-in-the-loop.

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) manage credentials through Vercel Connect instead — reach for whichever fits the surface you are targeting.
<Callout type="info" title="Prefer a native channel when one exists">
eve ships native channels for [Slack](./slack), [Microsoft Teams](./teams), [Discord](./discord),
[Telegram](./telegram), and more. They work out of the box with no adapter code, so reach for them
first and use the Chat SDK channel for surfaces eve does not cover natively.
</Callout>

## 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:
Add the Chat SDK core (`chat`), an adapter, and a state adapter.

The examples on this page use the [Resend email adapter](https://chat-sdk.dev/adapters/vendor-official/resend) 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.
Swap in whichever adapter matches your surface, such as `@chat-adapter/messenger`.

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

The example below wires up the Resend adapter, but the shape is the same for any adapter:

```ts title="agent/channels/resend.ts"
import { createMemoryState } from "@chat-adapter/state-memory";
Expand Down Expand Up @@ -51,7 +61,11 @@ bot.onSubscribedMessage(async (thread: Thread, message: Message) => {
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.
`adapters` is a map of adapter name to adapter instance, each entry mounts its own webhook.

`state` takes any Chat SDK state adapter: `createMemoryState()` is fine for local development, but use a durable adapter (e.g., [Redis](https://chat-sdk.dev/adapters/official/redis)) 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 because it dispatches the turn on the webhook that is currently running.

Deploy once the channel file is in place:

Expand All @@ -63,7 +77,7 @@ eve deploy

## 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.
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 at that path.

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

Expand All @@ -73,7 +87,7 @@ export const { bot, channel, send } = chatSdkChannel({
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
routes: { resend: "/webhooks/inbound-email" }, // or pin it exactly
});
```

Expand Down Expand Up @@ -101,9 +115,9 @@ bot.onNewMention(async (thread, message) => {

### 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.
The default handlers post the agent's reply back to the thread, so no `events` override is 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:
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, such as email, so the reply posts once on completion instead of editing:

```ts
chatSdkChannel({
Expand All @@ -118,13 +132,17 @@ Typing indicators post automatically where the adapter supports them: `Working

### 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`:
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`:
Override any default by passing `events`.

Handlers receive `(eventData, channel, ctx)`, with the rebuilt Chat SDK thread on `channel.thread`:

```ts
chatSdkChannel({
Expand All @@ -142,15 +160,17 @@ chatSdkChannel({

### 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:
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 the default collides with action ids your app already uses, and provide `resolveInputAuth` to carry user or tenant auth across the resume:

```ts
chatSdkChannel({
userName: "Support Bot",
adapters: { slack: createSlackAdapter() },
adapters: { messenger: createMessengerAdapter() },
state: createMemoryState(),
resolveInputAuth: (event) => ({
authenticator: "slack",
authenticator: "messenger",
principalType: "user",
principalId: event.user?.userId ?? "unknown",
attributes: {},
Expand All @@ -160,7 +180,7 @@ chatSdkChannel({

### Proactive sessions

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

```ts
await channel.receive({
Expand Down Expand Up @@ -188,17 +208,17 @@ See [File uploads](./custom#file-uploads) for how eve stages remote file URLs be

| 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). |
| `adapters` | required | Map of adapter name to Chat SDK adapter instance. One webhook per entry. |
| `state` | required | Chat SDK state adapter for subscriptions, locks, and dedupe. |
| `userName` | required | 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. |
| `routes` | none | 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`). |
| `webhook` | none | Extra Chat SDK webhook options (eve owns `waitUntil`). |

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

Expand Down
Loading