---
title: Web
description: Web chat adapter that speaks the AI SDK and AG-UI stream protocols.
tagline: Serve a browser chat UI from the same Chat SDK bot and handlers you use for Slack, Teams, and Discord. Speaks the AI SDK UI message stream protocol or AG-UI, so AI SDK and TanStack AI clients work against it.
package: @chat-adapter/web
---

# Web



## Create a new bot

Scaffold a Next.js bot with the Web adapter using [`create-chat-sdk`](/docs/create-chat-sdk):

<CreateChatSdk adapters="web memory" />

The generated app uses the [Memory state adapter](/adapters/official/memory) for local development. To add the adapter to an existing app, start at [Install](#install).

## Install

<PackageInstall package="@chat-adapter/web" />

The server has no runtime dependency on a client SDK. Install the client packages that match your UI:

| Client                      | Packages                                                        | Import from                | `protocol`           |
| --------------------------- | --------------------------------------------------------------- | -------------------------- | -------------------- |
| AI SDK + React / Next.js    | `ai`, `@ai-sdk/react`                                           | `@chat-adapter/web/react`  | `"ai-sdk"` (default) |
| AI SDK + Vue / Nuxt         | `ai`, `@ai-sdk/vue`                                             | `@chat-adapter/web/vue`    | `"ai-sdk"` (default) |
| AI SDK + Svelte / SvelteKit | `ai`, `@ai-sdk/svelte`                                          | `@chat-adapter/web/svelte` | `"ai-sdk"` (default) |
| TanStack AI                 | `@tanstack/ai-react` (or another TanStack AI framework package) | `@tanstack/ai-react`       | `"ag-ui"`            |

## Quick start

```typescript title="lib/bot.ts" lineNumbers
import { Chat } from "chat";
import { createWebAdapter } from "@chat-adapter/web";
import { createMemoryState } from "@chat-adapter/state-memory";

export const bot = new Chat({
  userName: "mybot",
  adapters: {
    web: createWebAdapter({
      userName: "mybot",
      getUser: (req) => ({ id: getUserIdFromCookie(req) }),
    }),
  },
  state: createMemoryState(),
});

bot.onDirectMessage(async (thread, message) => {
  await thread.post(`You said: ${message.text}`);
});
```

```typescript title="app/api/chat/route.ts" lineNumbers
import { after } from "next/server";
import { bot } from "@/lib/bot";

export async function POST(request: Request): Promise<Response> {
  return bot.webhooks.web(request, {
    waitUntil: (task) => after(() => task),
  });
}
```

<CodeBlockTabs defaultValue="react">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="react">
      React
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="vue">
      Vue
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="svelte">
      Svelte
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="react">
    ```tsx title="app/chat/page.tsx" lineNumbers
    "use client";
    import { useChat } from "@chat-adapter/web/react";

    export default function ChatPage() {
      const { messages, sendMessage, status, stop } = useChat();
      // Render with `ai-elements` (<Conversation>, <Message>, <PromptInput>)
      // or your own components. `messages`, `sendMessage`, and `status` are the
      // standard AI SDK UI API.
    }
    ```
  </CodeBlockTab>

  <CodeBlockTab value="vue">
    ```vue title="components/Chat.vue" lineNumbers
    <script setup lang="ts">
    import { useChat } from "@chat-adapter/web/vue";

    // Returns a Chat instance: access state directly, don't destructure
    const chat = useChat({ api: "/api/chat" });
    </script>

    <template>
      <div v-for="msg in chat.messages" :key="msg.id">
        <template v-for="part in msg.parts">
          <p v-if="part.type === 'text'">{{ part.text }}</p>
        </template>
      </div>
    </template>
    ```
  </CodeBlockTab>

  <CodeBlockTab value="svelte">
    ```svelte title="Chat.svelte" lineNumbers
    <script lang="ts">
      import { useChat } from "@chat-adapter/web/svelte";

      // Returns a Chat instance: access state directly, don't destructure
      const chat = useChat({ api: "/api/chat" });
    </script>

    {#each chat.messages as msg (msg.id)}
      {#each msg.parts as part}
        {#if part.type === "text"}<p>{part.text}</p>{/if}
      {/each}
    {/each}
    ```
  </CodeBlockTab>
</CodeBlockTabs>

## Authentication

The Web adapter has no platform credentials. Browser requests arrive without a platform signature, so your `getUser` function is the security boundary: it identifies the caller from your own app's session, and returning `null` rejects the request with HTTP 401.

```typescript title="lib/bot.ts" lineNumbers
// NextAuth
createWebAdapter({
  userName: "mybot",
  getUser: async (req) => {
    const session = await getServerSession(authOptions);
    if (!session?.user) return null;
    return { id: session.user.id, name: session.user.name };
  },
});

// Clerk
createWebAdapter({
  userName: "mybot",
  getUser: async () => {
    const { userId, sessionClaims } = await auth();
    if (!userId) return null;
    return { id: userId, name: sessionClaims?.name as string | undefined };
  },
});
```

The resolved `user.id` is embedded in the Chat SDK thread ID. IDs containing `:` are rejected with HTTP 400, so normalize them inside `getUser` (for example, by base64-encoding them) if your auth provider emits IDs like `provider:sub`.

## Configuration

<TypeTable
  type={{
  userName: {
    type: "string",
    description:
      "Bot username. Required by Chat SDK for mention detection and to seed the bot identity for assistant messages.",
  },
  getUser: {
    type: "(request: Request) => WebUser | null | Promise<WebUser | null>",
    description:
      "Resolves the user from the inbound HTTP request. Returning `null` produces HTTP 401. This is the security boundary for the Web adapter.",
  },
  protocol: {
    type: '"ai-sdk" | "ag-ui"',
    default: '"ai-sdk"',
    description:
      "Wire protocol for requests and streamed responses. Use `\"ag-ui\"` for TanStack AI and other AG-UI clients.",
  },
  persistMessageHistory: {
    type: "boolean",
    default: "true",
    description:
      "Persist incoming message history in the configured state adapter. The adapter never reads prior turns from the request body, so with `false` handlers see only the current message.",
  },
  threadIdFor: {
    type: "({ user, conversationId }) => string",
    description:
      "Override how thread ids are derived. Default: `web:{user.id}:{conversationId}`.",
  },
  logger: {
    type: "Logger",
    description: 'Defaults to `ConsoleLogger("info")`.',
  },
}}
/>

## Streaming

`thread.post` accepts an `AsyncIterable<string | StreamChunk>` and writes deltas directly to the SSE response, without the post-and-edit loop or rate limiting other adapters need. The handler can use any model library. For example, you can pass `streamText` output from the AI SDK:

```typescript
import { streamText } from "ai";

bot.onDirectMessage(async (thread, message) => {
  const result = streamText({ model, prompt: message.text });
  await thread.post(result.textStream);
});
```

The adapter honors `request.signal`, so calling `stop()` from the client short-circuits the iterator on the server.

### Task and plan updates

With `"ai-sdk"`, [structured streaming chunks](/docs/streaming#structured-streaming-chunks) reach the browser as AI SDK [data parts](https://ai-sdk.dev/docs/ai-sdk-ui/streaming-data) on the assistant message. Each part's `data` is the chunk without its `type` field. For `"ag-ui"`, see [TanStack AI](#tanstack-ai).

| Chunk         | Part type   | Part `id`                | `data`                                     |
| ------------- | ----------- | ------------------------ | ------------------------------------------ |
| `task_update` | `data-task` | The task's `id`          | `{ id, title, status, details?, output? }` |
| `plan_update` | `data-plan` | The assistant message ID | `{ title }`                                |

`markdown_text` chunks stream as text, the same as plain strings.

`useChat` replaces a data part when a later part has the same type and `id`, so each task stays a single part whose `status` moves from `pending` to `complete` or `error`. A response has one plan part, because every `plan_update` in it shares the assistant message ID. The parts are stored on the client's message, so they remain in `messages` after the stream ends.

The first chunk for a task, or the first plan chunk, adds a part and closes the current text part, and text yielded after it starts a new one. `message.parts` therefore lists text, tasks, and the plan in the order your handler first yielded them. A later update to the same task or plan replaces its part where it already sits and leaves the current text part open, so the text around it stays in one part.

A stream that yields tasks or a plan but no text adds no text part.

Pass `WebUIMessage` as `useChat`'s type argument to narrow `part.data` by part type:

```tsx title="app/chat/page.tsx" lineNumbers
"use client";
import { useChat, type WebUIMessage } from "@chat-adapter/web/react";

export default function ChatPage() {
  const { messages } = useChat<WebUIMessage>();
  return messages.map((message) => (
    <div key={message.id}>
      {message.parts.map((part, index) => {
        switch (part.type) {
          case "text":
            return <p key={`${message.id}-${index}`}>{part.text}</p>;
          case "data-plan":
            return <h3 key={part.id}>{part.data.title}</h3>;
          case "data-task":
            return (
              <div key={part.id}>
                {part.data.title}: {part.data.status}
                {part.data.output ? <pre>{part.data.output}</pre> : null}
              </div>
            );
          default:
            return null;
        }
      })}
    </div>
  ));
}
```

The Vue and Svelte entries export `WebUIMessage` too, and their `useChat` helpers take the same type argument.

### Reasoning

With `"ai-sdk"`, [`reasoning` chunks](/docs/streaming#reasoning) stream as AI SDK [reasoning parts](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot#reasoning), so the browser can show the model's reasoning while other adapters drop it. Consecutive `reasoning` chunks fill one part of type `reasoning`. Text, a new task, or the first plan chunk closes it, and reasoning yielded after that starts a new part. A `reasoning` chunk likewise closes the current text part. `message.parts` therefore keeps reasoning, text, tasks, and the plan in the order your handler first yielded them.

Render reasoning parts next to the text parts:

```tsx title="app/chat/page.tsx"
case "reasoning":
  return (
    <details key={`${message.id}-${index}`}>
      <summary>Reasoning</summary>
      {part.text}
    </details>
  );
```

Reasoning isn't part of the message text, so it isn't included in the `text` of the message `thread.post()` returns.

## Framework integrations

The `protocol` option sets the wire format the endpoint speaks:

* `"ai-sdk"` (default): the [AI SDK UI message stream protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol), for `useChat` from `@ai-sdk/react`, `@ai-sdk/vue`, and `@ai-sdk/svelte`.
* `"ag-ui"`: [AG-UI](https://docs.ag-ui.com/concepts/events) events, for TanStack AI's `fetchServerSentEvents` connection and other AG-UI clients.

Handlers are the same for both. Only the request body and the SSE frames change.

The AI SDK framework subpaths expose `useChat` helpers preconfigured for the endpoint. The `api` and `threadId` options are the same in all three.

### React

`@chat-adapter/web/react` is a thin wrapper around `@ai-sdk/react`'s `useChat`, preconfigured with `DefaultChatTransport`. It returns destructurable helpers and accepts a few extra options on top of the standard API:

```tsx
import { useChat } from "@chat-adapter/web/react";

const { messages, sendMessage, status, stop, regenerate } = useChat({
  api: "/api/chat",
  threadId: "support-1",
});
```

| Option                  | Description                                                       |
| ----------------------- | ----------------------------------------------------------------- |
| `api`                   | API endpoint for the Web adapter route. Defaults to `/api/chat`.  |
| `threadId`              | Chat SDK thread ID, sent as the request body's `id`. Recommended. |
| `experimental_throttle` | Throttle wait in ms for chat messages and data updates.           |
| `resume`                | Whether to resume an ongoing chat generation stream.              |
| ...rest                 | All other options pass through to `@ai-sdk/react`'s `useChat`.    |

For anything the wrapper doesn't cover, use `@ai-sdk/react`'s `useChat` directly.

### Vue / Nuxt

`@chat-adapter/web/vue` exports a `useChat` factory that returns a `Chat` instance from `@ai-sdk/vue`. Its `messages`, `status`, and `error` properties are Vue-reactive. Access them directly in your template; destructuring breaks Vue's reactivity tracking.

```vue
<script setup lang="ts">
import { useChat } from "@chat-adapter/web/vue";

const chat = useChat({ api: "/api/chat", threadId: "support-1" });
</script>

<template>
  <div v-for="msg in chat.messages" :key="msg.id">
    <template v-for="part in msg.parts">
      <p v-if="part.type === 'text'">{{ part.text }}</p>
    </template>
  </div>
</template>
```

### Svelte / SvelteKit

`@chat-adapter/web/svelte` exports the same factory, returning a `Chat` instance from `@ai-sdk/svelte` with Svelte 5 `$state`-backed reactive properties. As with Vue, the reactive state lives on the object itself.

```svelte
<script lang="ts">
  import { useChat } from "@chat-adapter/web/svelte";

  const chat = useChat({ api: "/api/chat", threadId: "support-1" });
</script>

{#each chat.messages as msg (msg.id)}
  {#each msg.parts as part}
    {#if part.type === "text"}<p>{part.text}</p>{/if}
  {/each}
{/each}
```

### TanStack AI

Set `protocol: "ag-ui"` on the adapter:

```typescript title="lib/bot.ts"
createWebAdapter({
  userName: "mybot",
  getUser,
  protocol: "ag-ui",
});
```

Then point TanStack AI's `useChat` at the route with `fetchServerSentEvents`:

```tsx title="app/chat/page.tsx" lineNumbers
"use client";
import { fetchServerSentEvents, useChat } from "@tanstack/ai-react";

export default function ChatPage() {
  const { messages, sendMessage, isLoading, stop } = useChat({
    connection: fetchServerSentEvents("/api/chat"),
    threadId: "support-1",
  });
  // Text parts carry their text in `part.content`.
}
```

`threadId` becomes the conversation ID in the Chat SDK thread ID. Each `thread.post` call in a handler arrives as its own assistant message. Handler errors end the run with a `RUN_ERROR` event.

`reasoning` chunks stream as AG-UI `REASONING_*` events, which TanStack AI shows as thinking parts. `task_update` and `plan_update` chunks arrive as `CUSTOM` events named `task` and `plan`, whose `value` is the chunk without its `type` field. Handle them with TanStack AI's `onCustomEvent` callback. The first chunk for a task, or the first plan chunk, ends the current text message, so text yielded after it starts a new `TEXT_MESSAGE_START`. Later updates to the same task or plan leave the text message open.

## Message persistence

`persistMessageHistory` defaults to `true`. Web has no platform-side history API, so handlers can see prior turns through `thread.messages` only if the configured state adapter has cached them.

The request body's `messages[]` array is not an alternative history source. A browser controls it entirely, so the adapter consumes only the latest user message and strips tool parts from it. A client therefore cannot inject forged tool-call or approval state or rewrite earlier turns. With `"ai-sdk"`, text, file, and custom `data-*` parts pass through to `message.raw` unchanged. With `"ag-ui"`, `message.raw.parts` holds the message content as `{ type: "text", text }` parts plus any `image`, `audio`, `video`, `document`, or `binary` content parts. Setting `persistMessageHistory: false` leaves handlers with only the current message.

The last entry in `messages[]` must be a user message. A body ending in an assistant or tool message, such as a client-side tool result, gets HTTP 400, because the adapter doesn't support tool or assistant continuations. Sending the same user message again (`regenerate()` or a reload) runs the handler again rather than being dropped as a duplicate, and history keeps the earlier reply and stores the repeated message a second time.

`message.raw` is typed `WebMessage`. Part fields other than `type` are `unknown`. With `"ai-sdk"` the shape matches the AI SDK `UIMessage`, so you can cast it with `message.raw as UIMessage`.

## Thread IDs

By default, each client conversation maps to one Chat SDK thread:

```
web:{user.id}:{conversationId}
```

`conversationId` is the `id` field AI SDK's `useChat` sends in its request body, or the `threadId` field with `"ag-ui"`. If your client supplies one (`useChat({ id: "support-chat" })` or TanStack AI's `useChat({ threadId: "support-chat" })`), it's reused across reloads; otherwise the client or adapter generates one. A conversation ID that isn't a string, or that contains `:`, gets HTTP 400.

Override `threadIdFor` to use a single thread per user:

```typescript
createWebAdapter({
  userName: "mybot",
  getUser,
  threadIdFor: ({ user }) => `web:${user.id}:default`,
});
```

The adapter exposes encode and decode helpers:

```typescript
adapter.encodeThreadId({ userId: "u1", conversationId: "abc" });
// → "web:u1:abc"
adapter.decodeThreadId("web:u1:abc");
// → { userId: "u1", conversationId: "abc" }
```

## Feature support

<FeatureSupport />
