Skip to content

feat: add @chat-adapter/web — browser chat UI for chat-sdk bots#444

Merged
dancer merged 13 commits intomainfrom
feat/web-adapter
May 5, 2026
Merged

feat: add @chat-adapter/web — browser chat UI for chat-sdk bots#444
dancer merged 13 commits intomainfrom
feat/web-adapter

Conversation

@bensabic
Copy link
Copy Markdown
Contributor

@bensabic bensabic commented May 4, 2026

add @chat-adapter/web

A new platform adapter that lets a chat-sdk bot serve a browser chat UI alongside Slack, Teams, Discord, etc. The same bot.onDirectMessage(...) handler fires for every platform — including streamed replies via thread.post(stream).

The adapter speaks the AI SDK UI message stream protocol, so @ai-sdk/react's useChat and the ai-elements component library work out of the box. No client-side glue.

// server
const bot = new Chat({
  userName: "mybot",
  adapters: {
    web: createWebAdapter({
      userName: "mybot",
      getUser: (req) => ({ id: getUserIdFromCookie(req) }),
    }),
  },
  state: createMemoryState(),
});
export const POST = bot.webhooks.web;
// client
import { useChat } from "@chat-adapter/web/react";
const { messages, sendMessage, status } = useChat();

What's in the package

@chat-adapter/web ships two subpath exports:

  • @chat-adapter/web — server-side createWebAdapter({ userName, getUser }) that produces an Adapter for the Chat constructor. Handles webhook parsing, user resolution, and streaming the response.
  • @chat-adapter/web/react — thin client wrapper exposing useChat() preconfigured with DefaultChatTransport. Re-exports UIMessage and UseChatHelpers types.

How the pieces fit

browser (useChat)                   server                          chat-sdk
─────────────────                   ──────                          ────────
sendMessage ────POST /api/chat────► bot.webhooks.web
                                    └─ WebAdapter.handleWebhook
                                       ├─ getUser(req) ───── 401 if null
                                       ├─ build Message from last UIMessage
                                       ├─ createUIMessageStream({execute})
                                       │   └─ webRequestContext.run(ctx, ──────► chat.processMessage
                                       │       () => chat.processMessage(...))      └─ onDirectMessage(thread, msg)
                                       │                                                  └─ thread.post(...) ──┐
                                       │                                                                        │
                                       │   postMessage / stream read AsyncLocalStorage                          │
                                       │   ctx.writer to emit text-start/delta/end ◄────────────────────────────┘
                                       └─ createUIMessageStreamResponse  (SSE; x-vercel-ai-ui-message-stream: v1)

isDM: true routes every web message through onDirectMessage. channelIdFromThreadId === threadId keeps channel.messages scoped per useChat conversation. persistMessageHistory: true (default) backfills thread.messages from state since web has no platform history API.

Notable design choices

getUser is the security boundary

Web requests come straight from a browser, so unlike the platform adapters there's no signature to verify. getUser is the auth boundary: returning null → HTTP 401 and no handler runs. The adapter's job is to plug into whatever the host app already uses (NextAuth, Clerk, custom session cookies). User ids that contain the reserved : delimiter are rejected with HTTP 400 to keep the thread-id round-trip clean.

Native streaming end-to-end

thread.post accepts AsyncIterable<string | StreamChunk> and pumps deltas straight onto the SSE response body — no edit loop, no rate-limit concerns. Plays nicely with the AI SDK's streamText. request.signal is forwarded into the stream loop, so useChat's stop() short-circuits the iterator on the server side. task_update / plan_update chunks have no native v1 representation in the UI message stream and are dropped silently.

chat.processMessage returns a Promise

The Web adapter response body is the user handler's output stream, so we need to surface handler errors to the client. Chat.processMessage previously returned void; it now returns Promise<void> that rejects on handler failure. Existing webhook adapters using options.waitUntil are unchanged: the SDK still tracks the work with errors swallowed (logged) so platforms don't retry on handler bugs.

Message persistence defaults to true

Web has no platform-side history API, so thread.messages / channel.messages are only populated through the configured state adapter's message history cache. Defaulting to true matches the typical use case; opt out only if your handler re-derives history from the request body's messages[] itself.

v1 scope

In: text + markdown, native streaming, DM-style routing (isDM: true), persisted message history, abort propagation via request.signal, useChat-compatible useChat hook.

Out (deferred to v2): cards/JSX rendering, reactions, modals, file uploads, edit/delete, multi-tab proactive push.

Example app

examples/nextjs-chat gains:

  • /chat page using useChat with ai-elements components
  • /api/chat route delegating to bot.webhooks.web with Next.js after() for tracking
  • Web adapter wired into the existing bot instance — same handlers fire from Slack and from the browser

Tests

  • packages/adapter-web/src/index.test.ts — 18 tests covering construction, thread-id encoding, input validation (400/401 paths), end-to-end handler dispatch (onDirectMessage routing, async-iterable streaming, error propagation), and direct stream() coverage (abort short-circuit, dropping non-text task_update/plan_update chunks, SentMessage.id matches the streamed text-* event id).
  • packages/chat/src/chat.test.ts — new tests for the awaitable processMessage contract (resolves on success, rejects with the original error on handler failure, waitUntil tracks both).

@bensabic bensabic requested a review from a team as a code owner May 4, 2026 06:33
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chat Ready Ready Preview, Comment, Open in v0 May 5, 2026 9:50pm
chat-sdk-nextjs-chat Ready Ready Preview, Comment, Open in v0 May 5, 2026 9:50pm

@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 4, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​ai-sdk/​react@​3.0.176991007498100
Updatednpm/​ai@​6.0.6 ⏵ 6.0.17492 -710010099100

View full report

bensabic and others added 11 commits May 5, 2026 22:41
Return the inner task as Promise<void> instead of void so streaming
adapters can await full handler completion and surface user-handler
rejections at the wire level. waitUntil semantics for existing webhook
adapters are unchanged — the SDK still tracks the work with errors
swallowed (and logged) so platforms don't retry on handler bugs.

Required by @chat-adapter/web, whose response body is the user
handler's stream.
A new platform adapter that lets a chat-sdk bot serve a browser chat
UI alongside Slack/Teams/Discord/etc. without writing any client-side
glue. Speaks the AI SDK UI message stream protocol, so @ai-sdk/react's
useChat and the ai-elements component library work out of the box.

- `@chat-adapter/web` — server: createWebAdapter({ userName, getUser })
- `@chat-adapter/web/react` — client: useChat() preconfigured with
  DefaultChatTransport against /api/chat (override via `api`)

Defaults that matter for v1:
- `isDM: true` — every web message routes through onDirectMessage
- `persistMessageHistory: true` — chat-sdk caches each turn in the
  configured state adapter so handlers can read prior context via
  thread.messages / channel.messages (no platform history API exists)
- channelId === threadId — web has no separate channel concept; this
  prevents cross-conversation bleed when a single user has multiple
  useChat sessions
- Native `adapter.stream` implementation pumps text-deltas straight
  onto the SSE response — no post+edit fallback

Out of scope for v1: cards/JSX rendering, reactions, modals, file
uploads, edit/delete, multi-tab proactive push.
- Register the web adapter in lib/adapters.ts with a demo getUser
  (single shared identity — replace with NextAuth/Clerk/cookie auth
  in production)
- Expose POST /api/chat backed by bot.webhooks.web (using next/after
  for waitUntil)
- Add a minimal /chat page using @chat-adapter/web/react's useChat —
  same bot.onDirectMessage handler that powers Slack now powers the
  browser too

Bumps `ai` to ^6.0.174 to align with @ai-sdk/react@^3 (avoids dual
provider-utils versions in the workspace).
- Add an entry to adapters.json so the package shows up on /adapters
- Add a globe SVG to lib/logos.tsx and wire it into the icon map
- Mention the new adapter in docs/adapters.mdx
- Reject user ids containing ':' with HTTP 400 — the character would
  corrupt the thread-id round-trip through decodeThreadId
- Skip emitting text-start/text-end in postMessage when the resolved
  text is empty so useChat doesn't render blank assistant bubbles
- Derive the parseMessage author from raw.role so rehydrated assistant
  messages report the bot identity instead of "unknown"
- Drop the duplicate handler-error log; chat.processMessage already
  logs at ERROR level
- Document the actual persistMessageHistory default (true) and the
  state-cache rationale; promote the fetchMessages no-op rationale
  into its JSDoc
- Aborting request.signal mid-stream short-circuits the iterator and
  still writes text-end via the finally block
- Non-text StreamChunks (task_update, plan_update) are dropped without
  emitting any delta
- The SentMessage returned from thread.post matches the id used in
  text-start / text-end events
The docs site renders each adapter's README, so flesh out
@chat-adapter/web to match the depth of @chat-adapter/slack:
authentication boundary, threading semantics, streaming,
persistence, React hook reference, configuration table,
feature matrix, and troubleshooting.
Copy link
Copy Markdown

@gr2m gr2m left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code changes look good to me! Very cool!

@dancer dancer merged commit 3490a8c into main May 5, 2026
13 checks passed
@dancer dancer deleted the feat/web-adapter branch May 5, 2026 22:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants