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

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

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