## summary
new `@chat-adapter/x` adapter for X (Twitter), built on the X API v2 and
the X Activity API. write bot logic once and reply to mentions, hold DM
conversations, post from the account, and like posts, like the other
Chat SDK adapters
what it supports:
- reply to public mentions (`post.mention.create`) and top-level posts
via `channel.post`
- send and receive direct messages (`dm.received` / `dm.sent`)
- edit and delete owned posts, delete own DM events
- likes as the only reaction (`emoji.heart` or `"like"`)
- buffered streaming: accumulates an LLM stream and posts once instead
of post+edit churn on a public timeline
- OAuth 2.0 user context with managed token refresh (rotating refresh
token persisted in the state adapter, optional AES-256-GCM encryption)
- webhook CRC and `x-twitter-webhooks-signature` verification
key design decisions:
- DMs are threaded by the other participant's user id (`x:dm:{userId}`)
because X DM webhooks carry no conversation id, only participants
- OAuth 2.0 only at runtime: DM send and read are verified to work on
OAuth 2.0 user tokens, so no OAuth 1.0a in the adapter (subscription and
webhook setup is one-time and handled in the X developer console)
- parsers were written against real captured payloads: mentions use the
v2 shape (author hydrated in `includes.users`), DMs use the legacy
Account Activity shape (`direct_message_events`,
`message_create.message_data`, a `users` map, and no conversation id)
also includes the `chat/adapters` catalog entry, docs page, CLI scaffold
spec, and `sample-messages.md` with real captured payloads
<details><summary>usage</summary>
```typescript
import { Chat } from "chat";
import { createXAdapter } from "@chat-adapter/x";
const bot = new Chat({
userName: "mybot",
adapters: { x: createXAdapter() },
});
bot.onNewMention(async (thread, message) => {
await thread.post(`hi @${message.author.userName}!`);
});
bot.onDirectMessage(async (thread) => {
await thread.post("hello from X");
});
```
</details>
## test plan
- adapter unit tests pass against the real captured payload shapes, with
regression tests for author-from-`includes` (mentions) and the legacy
`direct_message_events` shape (DMs)
- real captured `post.mention.create` and `dm.received` payloads
verified end-to-end through `handleWebhook`: signature verification,
routing, author resolution, and participant threading, plus
bad-signature rejection returns 401
- every write and read path fired live against the X API through the
adapter: top-level post, reply to a mention, like and unlike, edit,
delete, DM send, DM read, DM delete
- OAuth 2.0 managed token refresh exercised live (access and refresh
token rotation)
---------
Signed-off-by: dancer <josh@afterima.ge>
* feat(chat): expose awaitable Promise from processMessage
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.
* feat(adapter-web): add @chat-adapter/web package
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.
* feat(example-nextjs-chat): wire up web adapter and add /chat page
- 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).
* docs: list @chat-adapter/web in registry
- 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
* feat(adapter-web): tighten request handling and message construction
- 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
* test(adapter-web): add direct coverage for stream()
- 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
* docs(adapter-web): expand README into the full adapter docs page
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.
* docs(adapter-web): drop unsupported provider import from streaming example
* fix(adapter-web): validate conversationId for reserved colon character
* fix(example): show error state in web chat demo
* fix(example): add thinking indicator to web chat demo
* feat(example): redesign web chat demo with tailwind
* chore: remove redundant changeset
---------
Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
Co-authored-by: dancer <josh@afterima.ge>