diff --git a/.changeset/sparkly-crews-worry.md b/.changeset/sparkly-crews-worry.md
new file mode 100644
index 000000000..8ee73788b
--- /dev/null
+++ b/.changeset/sparkly-crews-worry.md
@@ -0,0 +1,5 @@
+---
+"create-chat-sdk": minor
+---
+
+rename the scaffold CLI to create-chat-sdk and support npm create chat-sdk
diff --git a/apps/docs/content/docs/contributing/building.mdx b/apps/docs/content/docs/contributing/building.mdx
index 45ff01573..06392f30a 100644
--- a/apps/docs/content/docs/contributing/building.mdx
+++ b/apps/docs/content/docs/contributing/building.mdx
@@ -36,6 +36,10 @@ Chat SDK ships with Vercel-maintained adapters for Slack, Teams, Google Chat, Di
- Documentation of the adapter in primary vendor docs.
- Announcement of the adapter in blog post or changelog and social media.
+
+ Vendor official adapters are also included in the [`create-chat-sdk`](/docs/create-chat-sdk) CLI and co-marketing opportunities may be available.
+
+
## Project setup
This guide uses a hypothetical **Matrix** adapter as a running example. Replace "matrix" with your platform name throughout.
diff --git a/apps/docs/content/docs/create-chat-sdk.mdx b/apps/docs/content/docs/create-chat-sdk.mdx
new file mode 100644
index 000000000..0f7d85de0
--- /dev/null
+++ b/apps/docs/content/docs/create-chat-sdk.mdx
@@ -0,0 +1,87 @@
+---
+title: create-chat-sdk
+description: Scaffold a new Chat SDK bot project with a single command.
+---
+
+`create-chat-sdk` is the fastest way to start a new Chat SDK project. It scaffolds a Next.js app with your chosen platform adapters, state adapter, environment variables, and webhook route - ready to run.
+
+## Quick start
+
+```bash tab="npm"
+npm create chat-sdk@latest my-bot
+```
+
+```bash tab="pnpm"
+pnpm create chat-sdk@latest my-bot
+```
+
+```bash tab="yarn"
+yarn create chat-sdk my-bot
+```
+
+```bash tab="bun"
+bunx create-chat-sdk@latest my-bot
+```
+
+The CLI walks you through selecting platform adapters (Slack, Teams, Discord, etc.) and a state adapter (Redis, PostgreSQL, or in-memory), then installs dependencies automatically.
+
+## What you get
+
+```
+my-bot/
+ src/
+ lib/bot.ts # Bot config with your adapters and handlers
+ app/api/webhooks/[platform]/route.ts # Dynamic webhook route for all platforms
+ .env.example # Pre-populated environment variables
+ next.config.ts # Next.js config with serverExternalPackages
+ package.json # Dependencies for your selected adapters
+ tsconfig.json # TypeScript config with Chat SDK JSX runtime
+```
+
+The generated `bot.ts` includes starter handlers for `onNewMention` and `onSubscribedMessage` so you can start testing immediately.
+
+## Non-interactive usage
+
+Every prompt can be skipped with flags, making `create-chat-sdk` fully scriptable for CI, automation, and AI agents:
+
+```bash
+npm create chat-sdk@latest my-bot -d 'My bot' --adapter slack teams redis -yq
+```
+
+### Flags
+
+| Flag | Description |
+|------|-------------|
+| `-d, --description ` | Project description |
+| `--adapter ` | Platform or state adapters to include (skips interactive prompt) |
+| `--pm ` | Package manager to use (`npm`, `yarn`, `pnpm`, `bun`) |
+| `-y, --yes` | Skip prompts and accept defaults |
+| `--no-install` | Skip dependency installation |
+| `-q, --quiet` | Suppress non-essential output |
+| `--no-color` | Disable color output (respects `NO_COLOR`) |
+
+### Available adapter values
+
+**Messaging Platforms:** `slack`, `teams`, `gchat`, `discord`, `telegram`, `whatsapp`, `matrix`, `imessage`, `zernio`
+
+**Developer Tools:** `github`, `linear`, `resend`, `liveblocks`
+
+**State:** `memory`, `redis`, `ioredis`, `pg`
+
+## After scaffolding
+
+1. Copy the example environment file and fill in your platform credentials:
+
+```bash
+cp .env.example .env.local
+```
+
+2. Start the dev server:
+
+```bash
+npm run dev
+```
+
+3. Expose your local server (e.g. with [ngrok](https://ngrok.com)) and configure your platform's webhook URL to `https:///api/webhooks/`.
+
+See the [platform adapter docs](/docs/adapters) for setup instructions specific to each platform.
diff --git a/apps/docs/content/docs/getting-started.mdx b/apps/docs/content/docs/getting-started.mdx
index 253372ce6..3199b0719 100644
--- a/apps/docs/content/docs/getting-started.mdx
+++ b/apps/docs/content/docs/getting-started.mdx
@@ -3,6 +3,14 @@ title: Getting Started
description: Pick a guide to start building with Chat SDK.
---
+## Scaffold a new project
+
+The fastest way to get started is with `create-chat-sdk`. It scaffolds a complete Next.js bot project with your chosen adapters, environment variables, and webhook route.
+
+
+
+
+
## Usage
Learn the core patterns for handling incoming events and posting messages back to your users.
diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx
index b011f1bb4..53db62749 100644
--- a/apps/docs/content/docs/index.mdx
+++ b/apps/docs/content/docs/index.mdx
@@ -18,13 +18,23 @@ Building a chat bot that works across multiple platforms typically means maintai
- **AI streaming** with first-class support for streaming LLM responses
- **Serverless-ready** with distributed state via Redis and message deduplication
+## Quick start
+
+The fastest way to start a new project is with `create-chat-sdk`:
+
+```bash
+npm create chat-sdk@latest my-bot
+```
+
+This scaffolds a Next.js app with your chosen adapters, environment variables, and a webhook route - ready to run. See the [create-chat-sdk docs](/docs/create-chat-sdk) for all options.
+
## How it works
Chat SDK has three core concepts:
-1. **Chat** — the main entry point that coordinates [adapters](/adapters) and routes events to your handlers
-2. **[Adapters](/adapters)** — platform-specific implementations that handle webhook parsing, message formatting, and API calls
-3. **State** — a pluggable persistence layer for thread subscriptions and distributed locking
+1. **Chat** - the main entry point that coordinates [adapters](/adapters) and routes events to your handlers
+2. **[Adapters](/adapters)** - platform-specific implementations that handle webhook parsing, message formatting, and API calls
+3. **State** - a pluggable persistence layer for thread subscriptions and distributed locking
```typescript title="lib/bot.ts" lineNumbers
import { Chat } from "chat";
diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json
index 384a4b615..16805f557 100644
--- a/apps/docs/content/docs/meta.json
+++ b/apps/docs/content/docs/meta.json
@@ -3,6 +3,7 @@
"pages": [
"index",
"getting-started",
+ "create-chat-sdk",
"---Usage---",
"usage",
"threads-messages-channels",
diff --git a/biome.jsonc b/biome.jsonc
index 68a031a98..a70a4c364 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -29,6 +29,7 @@
"files": {
"includes": [
"**/*",
+ "!packages/create-chat-sdk/_template",
"!apps/docs/app/\\[lang\\]/docs",
"!apps/docs/app/\\[lang\\]/llms.mdx",
"!apps/docs/app/\\[lang\\]/llms.txt",
diff --git a/knip.json b/knip.json
index 6cdccc948..dc94520c4 100644
--- a/knip.json
+++ b/knip.json
@@ -4,7 +4,8 @@
"ignoreDependencies": ["@biomejs/biome"],
"ignore": [
"apps/docs/**/*",
- "examples/nextjs-chat/src/app/.well-known/workflow/**/*"
+ "examples/nextjs-chat/src/app/.well-known/workflow/**/*",
+ "packages/create-chat-sdk/_template/**/*"
],
"rules": {
"duplicates": "off",
diff --git a/packages/create-chat-sdk/AGENTS.md b/packages/create-chat-sdk/AGENTS.md
new file mode 100644
index 000000000..ca831a046
--- /dev/null
+++ b/packages/create-chat-sdk/AGENTS.md
@@ -0,0 +1,19 @@
+# AGENTS.md
+
+`create-chat-sdk` is a CLI tool that scaffolds new [Chat SDK](https://chat-sdk.dev) bot projects.
+
+## Commands
+
+```bash
+pnpm --filter create-chat-sdk build # Build with tsup
+pnpm --filter create-chat-sdk typecheck # Type-check
+pnpm --filter create-chat-sdk test # Run tests with coverage
+pnpm -w run check # Lint and format (monorepo-wide)
+pnpm validate # Full validation (build, typecheck, lint, test)
+```
+
+## Docs
+
+- [CLI documentation](../../apps/docs/content/docs/create-chat-sdk.mdx)
+- [Full Chat SDK docs](../../apps/docs/content/docs)
+- [Architecture](./ARCHITECTURE.md) for detailed structure, data flow, and design decisions.
diff --git a/packages/create-chat-sdk/ARCHITECTURE.md b/packages/create-chat-sdk/ARCHITECTURE.md
new file mode 100644
index 000000000..45a9ad398
--- /dev/null
+++ b/packages/create-chat-sdk/ARCHITECTURE.md
@@ -0,0 +1,81 @@
+# Architecture
+
+`create-chat-sdk` is a CLI that scaffolds new [Chat SDK](https://chat-sdk.dev) bot projects. It is published as the `create-chat-sdk` npm package and lives in the Chat SDK monorepo at `packages/create-chat-sdk`.
+
+## Project structure
+
+```
+packages/create-chat-sdk/
+├── src/
+│ ├── index.ts # Entry point - calls createProgram().parse()
+│ ├── cli.ts # Commander program definition, flags, help text
+│ ├── prompts.ts # Interactive prompts (Clack) and flag resolution
+│ ├── scaffold.ts # File copying, post-processing, dependency install
+│ ├── templates.ts # Dynamic code generation (bot.ts)
+│ └── types.ts # Shared TypeScript interfaces
+├── _template/ # Static template files copied into scaffolded projects
+│ ├── src/app/api/webhooks/[platform]/route.ts
+│ ├── .agents/skills/chat-sdk/SKILL.md
+│ ├── AGENTS.md
+│ ├── README.md
+│ ├── .env.example
+│ ├── .gitignore
+│ ├── next.config.ts
+│ ├── tsconfig.json
+│ └── package.json
+├── adapters.json # Adapter registry (platforms, state, env vars, categories)
+├── AGENTS.md # Agent instructions for this package
+├── ARCHITECTURE.md # This document
+├── CLAUDE.md # Points to AGENTS.md
+├── tsup.config.ts # Bundler config (ESM + shebang)
+├── vitest.config.ts # Test config with v8 coverage
+└── package.json
+```
+
+## Data flow
+
+```
+CLI invocation (npm create chat-sdk@latest my-bot --adapter slack redis)
+ │
+ ▼
+index.ts ──► cli.ts (Commander parses args and flags)
+ │
+ ▼
+ prompts.ts (resolves flags or runs interactive Clack prompts)
+ │
+ ├── resolveAdapterFlags() ← matches --adapter values to adapters.json
+ ├── text / groupMultiselect / select / confirm ← interactive fallback
+ └── detectPackageManager() ← reads npm_config_user_agent
+ │
+ ▼
+ scaffold.ts (creates the project on disk)
+ │
+ ├── copyDir() ← copies _template/ → project directory
+ ├── postProcessEnvExample ← injects adapter env vars into .env.example
+ ├── postProcessNextConfig ← adds serverExternalPackages if needed
+ ├── npm pkg set ← sets name, description, adapter dependencies
+ ├── templates.botTs() ← generates src/lib/bot.ts with imports + handlers
+ └── execa(install) ← runs package manager install
+ │
+ ▼
+ cli.ts (displays next steps and outro)
+```
+
+## Key design decisions
+
+- **Static template + post-processing**: Most files live as-is in `_template/` and are copied verbatim. Only `.env.example`, `next.config.ts`, and `package.json` are post-processed. `src/lib/bot.ts` is the sole fully-generated file because its imports and adapter configuration vary per selection.
+- **`adapters.json` as registry**: All adapter metadata (packages, factory functions, env vars, categories, server external packages) is centralized in a single JSON file. The CLI reads it at build time via an import assertion. This avoids hardcoding adapter knowledge across multiple source files.
+- **`npm pkg set` for `package.json`**: Instead of generating the full `package.json` from a template function, the CLI copies a base `package.json` from `_template/` and patches it with `npm pkg set`. This keeps the base file readable and avoids JSON serialization edge cases.
+- **Clack for interactive UX**: `@clack/prompts` provides the interactive prompt flow with spinners, grouped multi-select, and cancellation handling. All prompts are skippable via flags for non-interactive / CI usage.
+- **Commander for arg parsing**: Handles positional args, flags, help text generation, and the `--no-color` convention.
+
+## Testing
+
+Tests use Vitest with v8 coverage. Each source module has a co-located `.test.ts` file. The test strategy:
+
+- **`cli.test.ts`** - Mocks `runPrompts` and `scaffold`; tests Commander program creation, help text output, and action handler logic.
+- **`prompts.test.ts`** - Mocks `@clack/prompts`; tests interactive flows, flag resolution, cancellation, validation, and package manager detection.
+- **`scaffold.test.ts`** - Uses real temp directories; mocks `execa` and `@clack/prompts`; tests file copying, post-processing, dependency installation, and overwrite prompts.
+- **`templates.test.ts`** - Pure function tests for `botTs()` output with various adapter combinations.
+
+`types.ts` and `index.ts` are excluded from coverage - `types.ts` is definition-only and `index.ts` is a one-line entry point.
diff --git a/packages/create-chat-sdk/README.md b/packages/create-chat-sdk/README.md
new file mode 100644
index 000000000..b0d327310
--- /dev/null
+++ b/packages/create-chat-sdk/README.md
@@ -0,0 +1,138 @@
+# create-chat-sdk
+
+Scaffold a new [Chat SDK](https://chat-sdk.dev) bot project.
+
+Chat SDK is a unified TypeScript SDK by Vercel for building chat bots across Slack, Teams, Google Chat, Discord, WhatsApp, and more.
+
+## Quick Start
+
+```bash
+npm create chat-sdk@latest my-bot
+```
+
+Or with your preferred package manager:
+
+```bash
+pnpm create chat-sdk@latest my-bot
+yarn create chat-sdk my-bot
+bunx create-chat-sdk@latest my-bot
+```
+
+You can also run the binary directly:
+
+```bash
+npx create-chat-sdk@latest my-bot
+pnpm create chat-sdk my-bot
+bunx create-chat-sdk my-bot
+```
+
+The CLI walks you through selecting platform adapters, a state adapter, and installs dependencies for you.
+
+## Usage
+
+```
+Usage: create-chat-sdk [options] [name]
+
+Arguments:
+ name name of the project
+
+Options:
+ -d, --description project description
+ --adapter platform or state adapters to include (skips interactive prompt)
+ --pm package manager to use (npm, yarn, pnpm, bun)
+ -y, --yes skip prompts and accept defaults
+ --no-install skip dependency installation
+ -q, --quiet suppress non-essential output
+ --no-color disable color output (respects NO_COLOR)
+ -h, --help display help for command
+```
+
+## Examples
+
+Interactive mode (prompts for everything):
+
+```bash
+npm create chat-sdk@latest
+```
+
+Provide a name and let the CLI prompt for the rest:
+
+```bash
+npm create chat-sdk@latest my-bot
+```
+
+Skip adapter prompts by passing them directly:
+
+```bash
+npm create chat-sdk@latest my-bot --adapter slack teams redis
+```
+
+Fully non-interactive:
+
+```bash
+npm create chat-sdk@latest my-bot -d 'My awesome bot' --adapter slack redis -y
+```
+
+Silent non-interactive (for CI/scripts):
+
+```bash
+npm create chat-sdk@latest my-bot --adapter slack pg -yq --pm pnpm --no-install
+```
+
+## Available Adapters
+
+### Messaging Platforms
+
+| Adapter | Flag value | Package |
+| --- | --- | --- |
+| Slack | `slack` | `@chat-adapter/slack` |
+| Microsoft Teams | `teams` | `@chat-adapter/teams` |
+| Google Chat | `gchat` | `@chat-adapter/gchat` |
+| Discord | `discord` | `@chat-adapter/discord` |
+| Telegram | `telegram` | `@chat-adapter/telegram` |
+| WhatsApp | `whatsapp` | `@chat-adapter/whatsapp` |
+| Beeper Matrix | `matrix` | `@beeper/chat-adapter-matrix` |
+| Photon iMessage | `imessage` | `chat-adapter-imessage` |
+| Zernio | `zernio` | `@zernio/chat-sdk-adapter` |
+
+### Developer Tools
+
+| Adapter | Flag value | Package |
+| --- | --- | --- |
+| GitHub | `github` | `@chat-adapter/github` |
+| Linear | `linear` | `@chat-adapter/linear` |
+| Resend | `resend` | `@resend/chat-sdk-adapter` |
+| Liveblocks | `liveblocks` | `@liveblocks/chat-sdk-adapter` |
+
+### State
+
+| Adapter | Flag value | Package | Notes |
+| --- | --- | --- | --- |
+| In-Memory | `memory` | `@chat-adapter/state-memory` | Development only |
+| Redis | `redis` | `@chat-adapter/state-redis` | node-redis driver |
+| ioredis | `ioredis` | `@chat-adapter/state-ioredis` | ioredis driver |
+| PostgreSQL | `pg` | `@chat-adapter/state-pg` | |
+
+## What You Get
+
+The scaffolded project is a Next.js app with:
+
+- **`src/lib/bot.ts`** - Bot configuration with your selected adapters
+- **`src/app/api/webhooks/[platform]/route.ts`** - Dynamic webhook route
+- **`.env.example`** - Pre-populated with the environment variables for your adapters
+- **`next.config.ts`** - Configured with any required `serverExternalPackages`
+
+## After Scaffolding
+
+```bash
+cd my-bot
+cp .env.example .env.local
+# Fill in your credentials in .env.local
+npm run dev
+```
+
+See the [Chat SDK docs](https://chat-sdk.dev/docs) for platform setup guides and API reference.
+
+## License
+
+MIT
diff --git a/packages/create-chat-sdk/_template/.agents/skills/chat-sdk/SKILL.md b/packages/create-chat-sdk/_template/.agents/skills/chat-sdk/SKILL.md
new file mode 100644
index 000000000..c4e4ad8f9
--- /dev/null
+++ b/packages/create-chat-sdk/_template/.agents/skills/chat-sdk/SKILL.md
@@ -0,0 +1,209 @@
+---
+name: chat-sdk
+description: >
+ Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to
+ (1) Build a Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, or WhatsApp bot,
+ (2) Use Chat SDK to handle mentions, direct messages, subscribed threads, reactions, slash
+ commands, cards, modals, files, or AI streaming,
+ (3) Set up webhook routes or multi-adapter bots,
+ (4) Send rich cards or streamed AI responses to chat platforms,
+ (5) Build or maintain a custom adapter or state adapter,
+ (6) Scaffold a new bot project with create-chat-sdk.
+ Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "google chat bot", "discord bot",
+ "telegram bot", "whatsapp bot", "@chat-adapter", "@chat-adapter/state-", "custom adapter",
+ "state adapter", "build adapter", "create-chat-sdk", "scaffold bot", and building bots that work
+ across multiple chat platforms.
+---
+
+# Chat SDK
+
+Unified TypeScript SDK for building chat bots across Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. Write bot logic once, deploy everywhere.
+
+## Scaffold a new project
+
+The `create-chat-sdk` CLI scaffolds a complete Next.js bot project with adapters, environment variables, and a webhook route:
+
+```bash
+npm create chat-sdk@latest my-bot
+```
+
+It supports non-interactive usage for CI or scripting:
+
+```bash
+npm create chat-sdk@latest my-bot --adapter slack discord redis -d "My bot" --pm pnpm -y
+```
+
+Key flags: `--adapter ` (platform or state adapters), `-d ` (description), `--pm ` (npm/yarn/pnpm/bun), `-y` (skip prompts), `--no-install` (skip dependency installation), `-q` (quiet output). Run `npm create chat-sdk@latest -- --help` for the full list.
+
+The scaffolded project structure:
+
+```
+src/
+ lib/bot.ts # Bot config — adapters, state, handlers
+ app/api/webhooks/[platform]/route.ts # Webhook route (all platforms)
+.env.example # Required environment variables
+next.config.ts # Next.js config
+```
+
+## Start with published sources
+
+When Chat SDK is installed in a user project, inspect the published files that ship in `node_modules`:
+
+```
+node_modules/chat/docs/ # bundled docs
+node_modules/chat/dist/index.d.ts # core API types
+node_modules/chat/dist/jsx-runtime.d.ts # JSX runtime types
+node_modules/chat/docs/contributing/ # adapter-authoring docs
+node_modules/chat/docs/guides/ # framework/platform guides
+```
+
+If one of the paths below does not exist, that package is not installed in the project yet.
+
+Read these before writing code:
+
+- `node_modules/chat/docs/getting-started.mdx` — install and setup
+- `node_modules/chat/docs/usage.mdx` — `Chat` config and lifecycle
+- `node_modules/chat/docs/handling-events.mdx` — event routing and handlers
+- `node_modules/chat/docs/threads-messages-channels.mdx` — thread/channel/message model
+- `node_modules/chat/docs/posting-messages.mdx` — post, edit, delete, schedule
+- `node_modules/chat/docs/streaming.mdx` — AI SDK integration and streaming semantics
+- `node_modules/chat/docs/cards.mdx` — JSX cards
+- `node_modules/chat/docs/actions.mdx` — button/select interactions
+- `node_modules/chat/docs/modals.mdx` — modal submit/close flows
+- `node_modules/chat/docs/slash-commands.mdx` — slash command routing
+- `node_modules/chat/docs/direct-messages.mdx` — DM behavior and `openDM()`
+- `node_modules/chat/docs/files.mdx` — attachments/uploads
+- `node_modules/chat/docs/state.mdx` — persistence, locking, dedupe
+- `node_modules/chat/docs/adapters.mdx` — cross-platform feature matrix
+- `node_modules/chat/docs/api/chat.mdx` — exact `Chat` API
+- `node_modules/chat/docs/api/thread.mdx` — exact `Thread` API
+- `node_modules/chat/docs/api/message.mdx` — exact `Message` API
+- `node_modules/chat/docs/api/modals.mdx` — modal element and event details
+
+For the specific adapter or state package you are using, inspect that installed package's `dist/index.d.ts` export surface in `node_modules`.
+
+## Quick start
+
+```typescript
+import { Chat } from "chat";
+import { createSlackAdapter } from "@chat-adapter/slack";
+import { createRedisState } from "@chat-adapter/state-redis";
+
+const bot = new Chat({
+ userName: "mybot",
+ adapters: {
+ slack: createSlackAdapter(),
+ },
+ state: createRedisState(),
+ dedupeTtlMs: 600_000,
+});
+
+bot.onNewMention(async (thread) => {
+ await thread.subscribe();
+ await thread.post("Hello! I'm listening to this thread.");
+});
+
+bot.onSubscribedMessage(async (thread, message) => {
+ await thread.post(`You said: ${message.text}`);
+});
+```
+
+## Core concepts
+
+- **Chat** — main entry point; coordinates adapters, routing, locks, and state
+- **Adapters** — platform-specific integrations for Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp
+- **State adapters** — persistence for subscriptions, locks, dedupe, and thread state
+- **Thread** — conversation context with `post()`, `stream()`, `subscribe()`, `setState()`, `startTyping()`
+- **Message** — normalized content with `text`, `formatted`, attachments, author info, and platform `raw`
+- **Channel** — container for threads and top-level posts
+
+## Event handlers
+
+
+| Handler | Trigger |
+| ---------------------------- | ------------------------------------------- |
+| `onNewMention` | Bot @-mentioned in an unsubscribed thread |
+| `onDirectMessage` | New DM in an unsubscribed DM thread |
+| `onSubscribedMessage` | Any message in a subscribed thread |
+| `onNewMessage(regex)` | Regex match in an unsubscribed thread |
+| `onReaction(emojis?)` | Emoji added or removed |
+| `onAction(actionIds?)` | Button clicks and select/radio interactions |
+| `onModalSubmit(callbackId?)` | Modal form submitted |
+| `onModalClose(callbackId?)` | Modal dismissed/cancelled |
+| `onSlashCommand(commands?)` | Slash command invocation |
+| `onAssistantThreadStarted` | Slack assistant thread opened |
+| `onAssistantContextChanged` | Slack assistant context changed |
+| `onAppHomeOpened` | Slack App Home opened |
+| `onMemberJoinedChannel` | Slack member joined channel event |
+
+
+Read `node_modules/chat/docs/handling-events.mdx`, `node_modules/chat/docs/actions.mdx`, `node_modules/chat/docs/modals.mdx`, and `node_modules/chat/docs/slash-commands.mdx` before wiring handlers. `onDirectMessage` behavior is documented in `node_modules/chat/docs/direct-messages.mdx`.
+
+## Streaming
+
+Pass any `AsyncIterable` to `thread.post()` or `thread.stream()`. For AI SDK, prefer `result.fullStream` over `result.textStream` when available so step boundaries are preserved.
+
+```typescript
+import { ToolLoopAgent } from "ai";
+
+const agent = new ToolLoopAgent({ model: "anthropic/claude-4.5-sonnet" });
+
+bot.onNewMention(async (thread, message) => {
+ const result = await agent.stream({ prompt: message.text });
+ await thread.post(result.fullStream);
+});
+```
+
+Key details:
+
+- `streamingUpdateIntervalMs` controls post+edit fallback cadence
+- `fallbackStreamingPlaceholderText` defaults to `"..."`; set `null` to disable
+- Structured `StreamChunk` support is Slack-only; other adapters ignore non-text chunks
+
+## Cards and modals (JSX)
+
+Set `jsxImportSource: "chat"` in `tsconfig.json`.
+
+Card components:
+
+- `Card`, `CardText`, `Section`, `Fields`, `Field`, `Button`, `CardLink`, `LinkButton`, `Actions`, `Select`, `SelectOption`, `RadioSelect`, `Table`, `Image`, `Divider`
+
+Modal components:
+
+- `Modal`, `TextInput`, `Select`, `SelectOption`, `RadioSelect`
+
+```tsx
+await thread.post(
+
+ Your order has been received.
+
+
+
+
+
+);
+```
+
+## Adapter inventory
+
+For the full list of official, vendor-official, and community adapters (platform and state), see https://chat-sdk.dev/adapters.
+
+## Building a custom adapter
+
+Read these published docs first:
+
+- `node_modules/chat/docs/contributing/building.mdx`
+- `node_modules/chat/docs/contributing/testing.mdx`
+- `node_modules/chat/docs/contributing/publishing.mdx`
+
+Also inspect:
+
+- `node_modules/chat/dist/index.d.ts` — `Adapter` and related interfaces
+- `node_modules/@chat-adapter/shared/dist/index.d.ts` — shared errors and utilities
+- Installed official adapter `dist/index.d.ts` files — reference implementations for config and APIs
+
+A custom adapter needs request verification, webhook parsing, message/thread/channel operations, ID encoding/decoding, and a format converter. Use `BaseFormatConverter` from `chat` and shared utilities from `@chat-adapter/shared`.
+
+## Webhook setup
+
+Each registered adapter exposes `bot.webhooks.`. Wire those directly to your HTTP framework routes. See `node_modules/chat/docs/guides/slack-nextjs.mdx` and `node_modules/chat/docs/guides/discord-nuxt.mdx` for framework-specific route patterns.
diff --git a/packages/create-chat-sdk/_template/.env.example b/packages/create-chat-sdk/_template/.env.example
new file mode 100644
index 000000000..502574b03
--- /dev/null
+++ b/packages/create-chat-sdk/_template/.env.example
@@ -0,0 +1,2 @@
+# Bot Configuration
+BOT_USERNAME=my-bot
diff --git a/packages/create-chat-sdk/_template/.gitignore b/packages/create-chat-sdk/_template/.gitignore
new file mode 100644
index 000000000..b41fed88a
--- /dev/null
+++ b/packages/create-chat-sdk/_template/.gitignore
@@ -0,0 +1,16 @@
+# dependencies
+node_modules/
+
+# next.js
+.next/
+out/
+
+# env
+.env
+.env.local
+.env.*.local
+
+# misc
+.DS_Store
+*.tsbuildinfo
+next-env.d.ts
diff --git a/packages/create-chat-sdk/_template/AGENTS.md b/packages/create-chat-sdk/_template/AGENTS.md
new file mode 100644
index 000000000..afd31b839
--- /dev/null
+++ b/packages/create-chat-sdk/_template/AGENTS.md
@@ -0,0 +1,48 @@
+# AGENTS.md
+
+This is a chat bot built with [Chat SDK](https://chat-sdk.dev), a unified TypeScript SDK by Vercel for building bots across Slack, Teams, Google Chat, Discord, WhatsApp, and more.
+
+## Commands
+
+```bash
+npm run dev # Start the dev server
+npm run build # Production build
+npm run start # Start production server
+```
+
+## Project structure
+
+```
+src/
+ lib/bot.ts # Bot config — adapters, state, handlers
+ app/api/webhooks/[platform]/route.ts # Webhook route (all platforms)
+.env.example # Required environment variables
+next.config.ts # Next.js config (serverExternalPackages if needed)
+```
+
+## How it works
+
+1. Each chat platform sends webhooks to `/api/webhooks/{platform}` (e.g. `/api/webhooks/slack`).
+2. The route handler in `route.ts` delegates to the bot's webhook handler for that platform.
+3. The bot is configured in `src/lib/bot.ts` with platform adapters, a state adapter, and message handlers.
+
+## Key concepts
+
+- **Adapters** connect the bot to chat platforms. Each adapter handles webhook verification, message parsing, and platform-specific formatting.
+- **State adapter** provides persistence for subscriptions and distributed locking (e.g. Redis, PostgreSQL). In-memory state is for development only.
+- **Handlers** respond to events:
+ - `onNewMention` — bot is @mentioned in a new thread
+ - `onSubscribedMessage` — new message in a thread the bot is subscribed to
+ - `onNewMessage` — messages matching a pattern (e.g. regex, keyword)
+ - `onReaction` — reaction added to a message
+ - `onSlashCommand` — slash command invoked (Slack, Discord)
+- **Thread** represents a conversation. Use `thread.post()` to send messages, `thread.subscribe()` to listen for follow-ups.
+- **Cards** are rich messages built with JSX (using `jsxImportSource: "chat"` in tsconfig). Import components from `chat/cards`.
+
+## Environment variables
+
+Use your 'chat-sdk' skill and other applicable skills while working on this project.
+
+## Docs
+
+Full documentation: https://chat-sdk.dev/docs
diff --git a/packages/create-chat-sdk/_template/README.md b/packages/create-chat-sdk/_template/README.md
new file mode 100644
index 000000000..2fc6c4f1c
--- /dev/null
+++ b/packages/create-chat-sdk/_template/README.md
@@ -0,0 +1,48 @@
+# Chat SDK Bot
+
+A chat bot built with [Chat SDK](https://chat-sdk.dev), a unified TypeScript SDK by Vercel for building bots across Slack, Teams, Google Chat, Discord, WhatsApp, and more.
+
+## Getting Started
+
+1. Copy the example environment file and fill in your credentials:
+
+```bash
+cp .env.example .env.local
+```
+
+2. Start the dev server:
+
+```bash
+npm run dev
+```
+
+3. Expose your local server to the internet (e.g. with [ngrok](https://ngrok.com)) and configure your platform's webhook URL to point to:
+
+```
+https:///api/webhooks/
+```
+
+Replace `` with `slack`, `teams`, `gchat`, `discord`, etc.
+
+## Project Structure
+
+```
+src/
+ lib/bot.ts Bot configuration and handlers
+ app/api/webhooks/[platform]/route.ts Webhook endpoint (all platforms)
+.env.example Required environment variables
+```
+
+## Scripts
+
+| Command | Description |
+| --- | --- |
+| `npm run dev` | Start the development server |
+| `npm run build` | Create a production build |
+| `npm run start` | Start the production server |
+
+## Learn More
+
+- [Chat SDK Documentation](https://chat-sdk.dev/docs)
+- [Adapter Setup Guides](https://chat-sdk.dev/docs/adapters)
+- [GitHub Repository](https://github.com/vercel/chat)
diff --git a/packages/create-chat-sdk/_template/next.config.ts b/packages/create-chat-sdk/_template/next.config.ts
new file mode 100644
index 000000000..cb651cdc0
--- /dev/null
+++ b/packages/create-chat-sdk/_template/next.config.ts
@@ -0,0 +1,5 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {};
+
+export default nextConfig;
diff --git a/packages/create-chat-sdk/_template/package.json b/packages/create-chat-sdk/_template/package.json
new file mode 100644
index 000000000..92559f58b
--- /dev/null
+++ b/packages/create-chat-sdk/_template/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "chat-sdk-bot",
+ "description": "Chatbot powered by Chat SDK",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "chat": "latest",
+ "next": "^16.2.3",
+ "react": "^19.2.5",
+ "react-dom": "^19.2.5"
+ },
+ "devDependencies": {
+ "@types/node": "^25.6.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "typescript": "^6.0.2"
+ }
+}
diff --git a/packages/create-chat-sdk/_template/src/app/api/webhooks/[platform]/route.ts b/packages/create-chat-sdk/_template/src/app/api/webhooks/[platform]/route.ts
new file mode 100644
index 000000000..698a6274d
--- /dev/null
+++ b/packages/create-chat-sdk/_template/src/app/api/webhooks/[platform]/route.ts
@@ -0,0 +1,21 @@
+import { after } from "next/server";
+import { bot } from "@/lib/bot";
+
+type Platform = keyof typeof bot.webhooks;
+
+export async function POST(
+ request: Request,
+ context: RouteContext<"/api/webhooks/[platform]">
+) {
+ const { platform } = await context.params;
+
+ const handler = bot.webhooks[platform as Platform];
+
+ if (!handler) {
+ return new Response(`Unknown platform: ${platform}`, { status: 404 });
+ }
+
+ return handler(request, {
+ waitUntil: (task) => after(() => task),
+ });
+}
diff --git a/packages/create-chat-sdk/_template/tsconfig.json b/packages/create-chat-sdk/_template/tsconfig.json
new file mode 100644
index 000000000..9ea68feb6
--- /dev/null
+++ b/packages/create-chat-sdk/_template/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["dom", "dom.iterable", "ES2022"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "chat",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/create-chat-sdk/adapters.json b/packages/create-chat-sdk/adapters.json
new file mode 100644
index 000000000..096e432ea
--- /dev/null
+++ b/packages/create-chat-sdk/adapters.json
@@ -0,0 +1,386 @@
+{
+ "platformAdapters": [
+ {
+ "name": "Slack",
+ "value": "slack",
+ "package": "@chat-adapter/slack",
+ "factoryFn": "createSlackAdapter",
+ "typeName": "SlackAdapter",
+ "category": "Messaging Platforms",
+ "envVars": [
+ {
+ "name": "SLACK_SIGNING_SECRET",
+ "description": "Slack app signing secret",
+ "required": true
+ },
+ {
+ "name": "SLACK_BOT_TOKEN",
+ "description": "Slack bot OAuth token (xoxb-...)",
+ "required": true
+ }
+ ],
+ "serverExternalPackages": []
+ },
+ {
+ "name": "Microsoft Teams",
+ "value": "teams",
+ "package": "@chat-adapter/teams",
+ "factoryFn": "createTeamsAdapter",
+ "typeName": "TeamsAdapter",
+ "category": "Messaging Platforms",
+ "envVars": [
+ {
+ "name": "TEAMS_APP_ID",
+ "description": "Teams app (client) ID",
+ "required": true
+ },
+ {
+ "name": "TEAMS_APP_PASSWORD",
+ "description": "Teams app password (client secret)",
+ "required": true
+ },
+ {
+ "name": "TEAMS_APP_TENANT_ID",
+ "description": "Azure AD tenant ID (for single-tenant apps)",
+ "required": false
+ }
+ ],
+ "serverExternalPackages": []
+ },
+ {
+ "name": "Google Chat",
+ "value": "gchat",
+ "package": "@chat-adapter/gchat",
+ "factoryFn": "createGoogleChatAdapter",
+ "typeName": "GoogleChatAdapter",
+ "category": "Messaging Platforms",
+ "envVars": [
+ {
+ "name": "GOOGLE_CHAT_CREDENTIALS",
+ "description": "Service account JSON string",
+ "required": true
+ },
+ {
+ "name": "GOOGLE_CHAT_USE_ADC",
+ "description": "Use Application Default Credentials (set to \"true\")",
+ "required": false
+ }
+ ],
+ "serverExternalPackages": []
+ },
+ {
+ "name": "Discord",
+ "value": "discord",
+ "package": "@chat-adapter/discord",
+ "factoryFn": "createDiscordAdapter",
+ "typeName": "DiscordAdapter",
+ "category": "Messaging Platforms",
+ "envVars": [
+ {
+ "name": "DISCORD_BOT_TOKEN",
+ "description": "Discord bot token",
+ "required": true
+ },
+ {
+ "name": "DISCORD_PUBLIC_KEY",
+ "description": "Discord application public key",
+ "required": true
+ },
+ {
+ "name": "DISCORD_APPLICATION_ID",
+ "description": "Discord application ID",
+ "required": true
+ }
+ ],
+ "serverExternalPackages": [
+ "discord.js",
+ "@discordjs/ws",
+ "@discordjs/voice",
+ "zlib-sync",
+ "bufferutil",
+ "utf-8-validate"
+ ]
+ },
+ {
+ "name": "Telegram",
+ "value": "telegram",
+ "package": "@chat-adapter/telegram",
+ "factoryFn": "createTelegramAdapter",
+ "typeName": "TelegramAdapter",
+ "category": "Messaging Platforms",
+ "envVars": [
+ {
+ "name": "TELEGRAM_BOT_TOKEN",
+ "description": "Telegram bot token from @BotFather",
+ "required": true
+ },
+ {
+ "name": "TELEGRAM_WEBHOOK_SECRET_TOKEN",
+ "description": "Secret token for webhook verification",
+ "required": false
+ }
+ ],
+ "serverExternalPackages": []
+ },
+ {
+ "name": "WhatsApp",
+ "value": "whatsapp",
+ "package": "@chat-adapter/whatsapp",
+ "factoryFn": "createWhatsAppAdapter",
+ "typeName": "WhatsAppAdapter",
+ "category": "Messaging Platforms",
+ "envVars": [
+ {
+ "name": "WHATSAPP_ACCESS_TOKEN",
+ "description": "WhatsApp Cloud API access token",
+ "required": true
+ },
+ {
+ "name": "WHATSAPP_PHONE_NUMBER_ID",
+ "description": "WhatsApp phone number ID",
+ "required": true
+ },
+ {
+ "name": "WHATSAPP_APP_SECRET",
+ "description": "WhatsApp app secret for webhook verification",
+ "required": false
+ },
+ {
+ "name": "WHATSAPP_VERIFY_TOKEN",
+ "description": "Webhook verification token",
+ "required": false
+ }
+ ],
+ "serverExternalPackages": []
+ },
+ {
+ "name": "GitHub",
+ "value": "github",
+ "package": "@chat-adapter/github",
+ "factoryFn": "createGitHubAdapter",
+ "typeName": "GitHubAdapter",
+ "category": "Developer Tools",
+ "envVars": [
+ {
+ "name": "GITHUB_WEBHOOK_SECRET",
+ "description": "GitHub webhook secret",
+ "required": true
+ },
+ {
+ "name": "GITHUB_TOKEN",
+ "description": "GitHub personal access token (simple auth)",
+ "required": false
+ },
+ {
+ "name": "GITHUB_APP_ID",
+ "description": "GitHub App ID (app auth)",
+ "required": false
+ },
+ {
+ "name": "GITHUB_PRIVATE_KEY",
+ "description": "GitHub App private key (app auth)",
+ "required": false
+ }
+ ],
+ "serverExternalPackages": []
+ },
+ {
+ "name": "Linear",
+ "value": "linear",
+ "package": "@chat-adapter/linear",
+ "factoryFn": "createLinearAdapter",
+ "typeName": "LinearAdapter",
+ "category": "Developer Tools",
+ "envVars": [
+ {
+ "name": "LINEAR_WEBHOOK_SECRET",
+ "description": "Linear webhook signing secret",
+ "required": true
+ },
+ {
+ "name": "LINEAR_API_KEY",
+ "description": "Linear API key",
+ "required": false
+ },
+ {
+ "name": "LINEAR_ACCESS_TOKEN",
+ "description": "Linear OAuth access token",
+ "required": false
+ }
+ ],
+ "serverExternalPackages": []
+ },
+ {
+ "name": "Beeper Matrix",
+ "value": "matrix",
+ "package": "@beeper/chat-adapter-matrix",
+ "factoryFn": "createMatrixAdapter",
+ "typeName": "MatrixAdapter",
+ "category": "Messaging Platforms",
+ "envVars": [
+ {
+ "name": "MATRIX_BASE_URL",
+ "description": "Matrix homeserver URL",
+ "required": true
+ },
+ {
+ "name": "MATRIX_ACCESS_TOKEN",
+ "description": "Matrix access token",
+ "required": true
+ },
+ {
+ "name": "MATRIX_USER_ID",
+ "description": "Matrix user ID",
+ "required": true
+ },
+ {
+ "name": "MATRIX_RECOVERY_KEY",
+ "description": "Matrix recovery key",
+ "required": false
+ }
+ ],
+ "serverExternalPackages": []
+ },
+ {
+ "name": "Photon iMessage",
+ "value": "imessage",
+ "package": "chat-adapter-imessage",
+ "factoryFn": "createiMessageAdapter",
+ "typeName": "iMessageAdapter",
+ "category": "Messaging Platforms",
+ "envVars": [
+ {
+ "name": "IMESSAGE_SERVER_URL",
+ "description": "Photon iMessage server URL",
+ "required": true
+ },
+ {
+ "name": "IMESSAGE_API_KEY",
+ "description": "Photon iMessage API key",
+ "required": true
+ },
+ {
+ "name": "IMESSAGE_LOCAL",
+ "description": "Use local on-device iMessage integration",
+ "required": false
+ }
+ ],
+ "serverExternalPackages": []
+ },
+ {
+ "name": "Resend",
+ "value": "resend",
+ "package": "@resend/chat-sdk-adapter",
+ "factoryFn": "createResendAdapter",
+ "typeName": "ResendAdapter",
+ "category": "Developer Tools",
+ "envVars": [
+ {
+ "name": "RESEND_API_KEY",
+ "description": "Resend API key",
+ "required": true
+ },
+ {
+ "name": "RESEND_WEBHOOK_SECRET",
+ "description": "Resend webhook signing secret",
+ "required": true
+ }
+ ],
+ "serverExternalPackages": []
+ },
+ {
+ "name": "Zernio",
+ "value": "zernio",
+ "package": "@zernio/chat-sdk-adapter",
+ "factoryFn": "createZernioAdapter",
+ "typeName": "ZernioAdapter",
+ "category": "Messaging Platforms",
+ "envVars": [
+ {
+ "name": "ZERNIO_API_KEY",
+ "description": "Zernio API key",
+ "required": true
+ },
+ {
+ "name": "ZERNIO_WEBHOOK_SECRET",
+ "description": "Zernio webhook signing secret",
+ "required": false
+ }
+ ],
+ "serverExternalPackages": []
+ },
+ {
+ "name": "Liveblocks",
+ "value": "liveblocks",
+ "package": "@liveblocks/chat-sdk-adapter",
+ "factoryFn": "createLiveblocksAdapter",
+ "typeName": "LiveblocksAdapter",
+ "category": "Developer Tools",
+ "envVars": [
+ {
+ "name": "LIVEBLOCKS_SECRET_KEY",
+ "description": "Liveblocks secret key",
+ "required": true
+ },
+ {
+ "name": "LIVEBLOCKS_WEBHOOK_SECRET",
+ "description": "Liveblocks webhook signing secret",
+ "required": true
+ }
+ ],
+ "serverExternalPackages": []
+ }
+ ],
+ "stateAdapters": [
+ {
+ "name": "In-Memory",
+ "value": "memory",
+ "package": "@chat-adapter/state-memory",
+ "factoryFn": "createMemoryState",
+ "hint": "development only",
+ "envVars": []
+ },
+ {
+ "name": "Redis",
+ "value": "redis",
+ "package": "@chat-adapter/state-redis",
+ "factoryFn": "createRedisState",
+ "hint": "production - node-redis driver",
+ "envVars": [
+ {
+ "name": "REDIS_URL",
+ "description": "Redis connection URL",
+ "required": true
+ }
+ ]
+ },
+ {
+ "name": "ioredis",
+ "value": "ioredis",
+ "package": "@chat-adapter/state-ioredis",
+ "factoryFn": "createIoRedisState",
+ "hint": "production - ioredis driver",
+ "envVars": [
+ {
+ "name": "REDIS_URL",
+ "description": "Redis connection URL",
+ "required": true
+ }
+ ]
+ },
+ {
+ "name": "PostgreSQL",
+ "value": "pg",
+ "package": "@chat-adapter/state-pg",
+ "factoryFn": "createPostgresState",
+ "hint": "production",
+ "envVars": [
+ {
+ "name": "POSTGRES_URL",
+ "description": "PostgreSQL connection URL (or DATABASE_URL)",
+ "required": true
+ }
+ ]
+ }
+ ]
+}
diff --git a/packages/create-chat-sdk/package.json b/packages/create-chat-sdk/package.json
new file mode 100644
index 000000000..6e73d7a15
--- /dev/null
+++ b/packages/create-chat-sdk/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "create-chat-sdk",
+ "description": "Create a new Chat SDK bot project",
+ "keywords": [
+ "vercel",
+ "chat-sdk",
+ "bot",
+ "scaffold",
+ "create",
+ "cli"
+ ],
+ "version": "0.1.0",
+ "type": "module",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "bin": {
+ "create-chat-sdk": "./dist/index.js"
+ },
+ "files": [
+ "dist",
+ "_template",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "test": "vitest run --coverage",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@clack/prompts": "^0.10.0",
+ "commander": "^13.0.0",
+ "execa": "^9.5.2",
+ "picocolors": "^1.1.1"
+ },
+ "devDependencies": {
+ "@types/node": "^25.3.2",
+ "@vitest/coverage-v8": "^4.0.18",
+ "tsup": "^8.3.5",
+ "typescript": "^5.9.3",
+ "vitest": "^4.0.18"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vercel/chat.git",
+ "directory": "packages/create-chat-sdk"
+ },
+ "homepage": "https://chat-sdk.dev",
+ "bugs": {
+ "url": "https://github.com/vercel/chat/issues"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "license": "MIT"
+}
diff --git a/packages/create-chat-sdk/src/cli.test.ts b/packages/create-chat-sdk/src/cli.test.ts
new file mode 100644
index 000000000..f8566edfe
--- /dev/null
+++ b/packages/create-chat-sdk/src/cli.test.ts
@@ -0,0 +1,268 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ProjectConfig } from "./types.js";
+
+vi.mock("@clack/prompts", () => ({
+ intro: vi.fn(),
+ note: vi.fn(),
+ outro: vi.fn(),
+}));
+
+vi.mock("./prompts.js", () => ({
+ runPrompts: vi.fn(),
+}));
+
+vi.mock("./scaffold.js", () => ({
+ scaffold: vi.fn().mockResolvedValue(undefined),
+}));
+
+import { intro, note, outro } from "@clack/prompts";
+import { buildAdapterList, createProgram } from "./cli.js";
+import { runPrompts } from "./prompts.js";
+import { scaffold } from "./scaffold.js";
+
+let exitSpy: ReturnType;
+let consoleSpy: ReturnType;
+
+const fakeConfig: ProjectConfig = {
+ name: "my-bot",
+ description: "A bot",
+ platformAdapters: [],
+ stateAdapter: {
+ name: "In-Memory",
+ value: "memory",
+ package: "@chat-adapter/state-memory",
+ factoryFn: "createMemoryState",
+ hint: "development only",
+ envVars: [],
+ },
+ shouldInstall: false,
+ packageManager: "npm",
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
+ throw new Error("process.exit");
+ });
+ consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ exitSpy.mockRestore();
+ consoleSpy.mockRestore();
+});
+
+describe("buildAdapterList", () => {
+ it("groups platform adapters by category", () => {
+ const result = buildAdapterList();
+ expect(result).toContain("Messaging Platforms:");
+ expect(result).toContain("slack");
+ expect(result).toContain("Developer Tools:");
+ expect(result).toContain("github");
+ });
+
+ it("includes state adapters", () => {
+ const result = buildAdapterList();
+ expect(result).toContain("State:");
+ expect(result).toContain("memory");
+ expect(result).toContain("redis");
+ });
+});
+
+describe("createProgram", () => {
+ it("returns a Commander program", () => {
+ const prog = createProgram();
+ expect(prog.name()).toBe("create-chat-sdk");
+ });
+
+ describe("--help", () => {
+ it("includes SDK description", () => {
+ const prog = createProgram();
+ const help = prog.helpInformation();
+ expect(help).toContain("Chat SDK is a unified TypeScript SDK by Vercel");
+ });
+
+ it("lists available adapters", () => {
+ const prog = createProgram();
+ let helpText = "";
+ prog.configureOutput({
+ writeOut: (str) => {
+ helpText += str;
+ },
+ });
+ prog.outputHelp();
+ expect(helpText).toContain("Available adapters:");
+ expect(helpText).toContain("Messaging Platforms:");
+ expect(helpText).toContain("State:");
+ });
+
+ it("shows examples", () => {
+ const prog = createProgram();
+ let helpText = "";
+ prog.configureOutput({
+ writeOut: (str) => {
+ helpText += str;
+ },
+ });
+ prog.outputHelp();
+ expect(helpText).toContain("Examples:");
+ expect(helpText).toContain("$ create-chat-sdk my-bot");
+ });
+ });
+
+ describe("action - successful flow", () => {
+ it("calls runPrompts and scaffold", async () => {
+ vi.mocked(runPrompts).mockResolvedValueOnce(fakeConfig);
+ const prog = createProgram();
+ await prog.parseAsync(["node", "create-chat-sdk", "my-bot", "-yq"]);
+
+ expect(runPrompts).toHaveBeenCalled();
+ expect(scaffold).toHaveBeenCalledWith(fakeConfig, true, true);
+ });
+
+ it("shows intro, note, and outro when not quiet", async () => {
+ vi.mocked(runPrompts).mockResolvedValueOnce(fakeConfig);
+ const prog = createProgram();
+ await prog.parseAsync(["node", "create-chat-sdk", "my-bot", "-y"]);
+
+ expect(intro).toHaveBeenCalled();
+ expect(note).toHaveBeenCalled();
+ expect(outro).toHaveBeenCalledWith(expect.stringContaining("Done!"));
+ });
+
+ it("hides intro, note, and outro when quiet", async () => {
+ vi.mocked(runPrompts).mockResolvedValueOnce(fakeConfig);
+ const prog = createProgram();
+ await prog.parseAsync(["node", "create-chat-sdk", "my-bot", "-yq"]);
+
+ expect(intro).not.toHaveBeenCalled();
+ expect(note).not.toHaveBeenCalled();
+ expect(outro).not.toHaveBeenCalled();
+ });
+
+ it("defaults yes and quiet to false when not passed", async () => {
+ vi.mocked(runPrompts).mockResolvedValueOnce(fakeConfig);
+ const prog = createProgram();
+ await prog.parseAsync(["node", "create-chat-sdk", "my-bot"]);
+
+ expect(runPrompts).toHaveBeenCalledWith(
+ expect.anything(),
+ "my-bot",
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ false,
+ false
+ );
+ });
+
+ it("passes flags to runPrompts", async () => {
+ vi.mocked(runPrompts).mockResolvedValueOnce(fakeConfig);
+ const prog = createProgram();
+ await prog.parseAsync([
+ "node",
+ "create-chat-sdk",
+ "my-bot",
+ "-d",
+ "desc",
+ "--adapter",
+ "slack",
+ "redis",
+ "--pm",
+ "pnpm",
+ "-yq",
+ ]);
+
+ expect(runPrompts).toHaveBeenCalledWith(
+ expect.anything(),
+ "my-bot",
+ "desc",
+ ["slack", "redis"],
+ "pnpm",
+ undefined,
+ true,
+ true
+ );
+ });
+
+ it("passes --no-install to runPrompts", async () => {
+ vi.mocked(runPrompts).mockResolvedValueOnce(fakeConfig);
+ const prog = createProgram();
+ await prog.parseAsync([
+ "node",
+ "create-chat-sdk",
+ "my-bot",
+ "--no-install",
+ ]);
+
+ expect(runPrompts).toHaveBeenCalledWith(
+ expect.anything(),
+ "my-bot",
+ undefined,
+ undefined,
+ undefined,
+ false,
+ false,
+ false
+ );
+ });
+
+ it("rejects unknown package managers", async () => {
+ const prog = createProgram();
+ prog.exitOverride();
+ prog.configureOutput({ writeErr: () => {} });
+
+ await expect(
+ prog.parseAsync(["node", "create-chat-sdk", "my-bot", "--pm", "pmpm"])
+ ).rejects.toThrow("expected npm, yarn, pnpm, or bun");
+ expect(runPrompts).not.toHaveBeenCalled();
+ });
+
+ it("shows a clean error when setup fails", async () => {
+ vi.mocked(runPrompts).mockRejectedValueOnce(new Error("bad adapter"));
+ const prog = createProgram();
+
+ await expect(
+ prog.parseAsync(["node", "create-chat-sdk", "my-bot", "-y"])
+ ).rejects.toThrow("process.exit");
+ expect(outro).toHaveBeenCalledWith(
+ expect.stringContaining("bad adapter")
+ );
+ expect(exitSpy).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe("action - cancelled flow", () => {
+ it("calls process.exit when prompts return null", async () => {
+ vi.mocked(runPrompts).mockResolvedValueOnce(null);
+ const prog = createProgram();
+
+ await expect(
+ prog.parseAsync(["node", "create-chat-sdk", "my-bot", "-yq"])
+ ).rejects.toThrow("process.exit");
+ expect(exitSpy).toHaveBeenCalledWith(0);
+ expect(scaffold).not.toHaveBeenCalled();
+ });
+
+ it("shows cancelled outro when not quiet", async () => {
+ vi.mocked(runPrompts).mockResolvedValueOnce(null);
+ const prog = createProgram();
+
+ await expect(
+ prog.parseAsync(["node", "create-chat-sdk", "my-bot", "-y"])
+ ).rejects.toThrow("process.exit");
+ expect(outro).toHaveBeenCalledWith(expect.stringContaining("Cancelled"));
+ });
+
+ it("skips cancelled outro when quiet", async () => {
+ vi.mocked(runPrompts).mockResolvedValueOnce(null);
+ const prog = createProgram();
+
+ await expect(
+ prog.parseAsync(["node", "create-chat-sdk", "my-bot", "-yq"])
+ ).rejects.toThrow("process.exit");
+ expect(outro).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/create-chat-sdk/src/cli.ts b/packages/create-chat-sdk/src/cli.ts
new file mode 100644
index 000000000..0479887e1
--- /dev/null
+++ b/packages/create-chat-sdk/src/cli.ts
@@ -0,0 +1,158 @@
+import { intro, note, outro } from "@clack/prompts";
+import { Command, InvalidArgumentError } from "commander";
+import pc from "picocolors";
+import adapters from "../adapters.json" with { type: "json" };
+import { runPrompts } from "./prompts.js";
+import { scaffold } from "./scaffold.js";
+import type { AdaptersConfig, PackageManager, ProjectConfig } from "./types.js";
+
+const config = adapters as AdaptersConfig;
+const packageManagers = new Set(["npm", "yarn", "pnpm", "bun"]);
+
+function parsePackageManager(value: string): PackageManager {
+ if (packageManagers.has(value)) {
+ return value as PackageManager;
+ }
+ throw new InvalidArgumentError("expected npm, yarn, pnpm, or bun");
+}
+
+export function buildAdapterList(): string {
+ const categories = new Map();
+ for (const a of config.platformAdapters) {
+ if (!categories.has(a.category)) {
+ categories.set(a.category, []);
+ }
+ categories.get(a.category)?.push(a.value);
+ }
+
+ const lines: string[] = [];
+ for (const [category, values] of categories) {
+ lines.push(` ${category}: ${values.join(", ")}`);
+ }
+ lines.push(` State: ${config.stateAdapters.map((a) => a.value).join(", ")}`);
+ return lines.join("\n");
+}
+
+export function createProgram() {
+ const prog = new Command();
+
+ prog
+ .name("create-chat-sdk")
+ .description(
+ [
+ "Scaffold a new Chat SDK bot project.",
+ "",
+ "Chat SDK is a unified TypeScript SDK by Vercel for building chat bots",
+ "across Slack, Teams, Google Chat, Discord, WhatsApp, and more.",
+ "Docs: https://chat-sdk.dev/docs",
+ ].join("\n")
+ )
+ .argument("[name]", "name of the project")
+ .option("-d, --description ", "project description")
+ .option(
+ "--adapter ",
+ "platform or state adapters to include (skips interactive prompt)"
+ )
+ .option(
+ "--pm ",
+ "package manager to use (npm, yarn, pnpm, bun)",
+ parsePackageManager
+ )
+ .option("-y, --yes", "skip prompts and accept defaults")
+ .option("--no-install", "skip dependency installation")
+ .option("-q, --quiet", "suppress non-essential output")
+ .option("--no-color", "disable color output (respects NO_COLOR)")
+ .addHelpText(
+ "after",
+ [
+ "",
+ "Available adapters:",
+ buildAdapterList(),
+ "",
+ "Examples:",
+ " $ create-chat-sdk my-bot",
+ " $ create-chat-sdk my-bot -d 'My awesome bot' --adapter slack teams redis",
+ " $ create-chat-sdk --adapter discord telegram pg",
+ "",
+ ].join("\n")
+ )
+ .action(
+ async (
+ name?: string,
+ opts?: {
+ description?: string;
+ adapter?: string[];
+ install?: boolean;
+ pm?: PackageManager;
+ yes?: boolean;
+ quiet?: boolean;
+ }
+ ) => {
+ const quiet = opts?.quiet ?? false;
+ const yes = opts?.yes ?? false;
+ const pm = opts?.pm;
+ const install = opts?.install === false ? false : undefined;
+
+ if (!quiet) {
+ console.log();
+ intro(pc.bgCyan(pc.black(" create-chat-sdk ")));
+ }
+
+ let result: ProjectConfig | null;
+ try {
+ result = await runPrompts(
+ config,
+ name,
+ opts?.description,
+ opts?.adapter,
+ pm,
+ install,
+ yes,
+ quiet
+ );
+ } catch (error) {
+ if (!quiet) {
+ const message =
+ error instanceof Error ? error.message : String(error);
+ outro(pc.red(message));
+ }
+ process.exit(1);
+ }
+
+ if (!result) {
+ if (!quiet) {
+ outro(pc.gray("Cancelled."));
+ }
+ process.exit(0);
+ }
+
+ try {
+ await scaffold(result, yes, quiet);
+
+ if (!quiet) {
+ note(
+ [
+ `cd ${result.name}`,
+ "cp .env.example .env.local",
+ `${result.packageManager} run dev`,
+ ].join("\n"),
+ "Next steps"
+ );
+
+ outro(
+ `${pc.green("Done!")} Visit ${pc.cyan("https://chat-sdk.dev/docs")} for the docs.`
+ );
+ }
+ } catch (error) {
+ if (!quiet) {
+ const message =
+ error instanceof Error ? error.message : String(error);
+ outro(pc.red(message));
+ }
+ process.exit(1);
+ }
+ }
+ );
+
+ return prog;
+}
diff --git a/packages/create-chat-sdk/src/index.ts b/packages/create-chat-sdk/src/index.ts
new file mode 100644
index 000000000..ba346009a
--- /dev/null
+++ b/packages/create-chat-sdk/src/index.ts
@@ -0,0 +1,3 @@
+import { createProgram } from "./cli.js";
+
+createProgram().parse();
diff --git a/packages/create-chat-sdk/src/prompts.test.ts b/packages/create-chat-sdk/src/prompts.test.ts
new file mode 100644
index 000000000..e9f43cf0f
--- /dev/null
+++ b/packages/create-chat-sdk/src/prompts.test.ts
@@ -0,0 +1,528 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { AdaptersConfig, PlatformAdapter, StateAdapter } from "./types.js";
+
+const CANCEL = Symbol("cancel");
+
+vi.mock("@clack/prompts", () => ({
+ text: vi.fn(),
+ confirm: vi.fn(),
+ select: vi.fn(),
+ groupMultiselect: vi.fn(),
+ isCancel: vi.fn((value: unknown) => value === CANCEL),
+ log: { info: vi.fn(), warn: vi.fn() },
+}));
+
+import {
+ confirm,
+ groupMultiselect,
+ isCancel,
+ log,
+ select,
+ text,
+} from "@clack/prompts";
+import { runPrompts } from "./prompts.js";
+
+const slackAdapter: PlatformAdapter = {
+ name: "Slack",
+ value: "slack",
+ package: "@chat-adapter/slack",
+ factoryFn: "createSlackAdapter",
+ typeName: "SlackAdapter",
+ category: "Messaging Platforms",
+ envVars: [],
+ serverExternalPackages: [],
+};
+
+const teamsAdapter: PlatformAdapter = {
+ name: "Microsoft Teams",
+ value: "teams",
+ package: "@chat-adapter/teams",
+ factoryFn: "createTeamsAdapter",
+ typeName: "TeamsAdapter",
+ category: "Messaging Platforms",
+ envVars: [],
+ serverExternalPackages: [],
+};
+
+const githubAdapter: PlatformAdapter = {
+ name: "GitHub",
+ value: "github",
+ package: "@chat-adapter/github",
+ factoryFn: "createGitHubAdapter",
+ typeName: "GitHubAdapter",
+ category: "Developer Tools",
+ envVars: [],
+ serverExternalPackages: [],
+};
+
+const memoryState: StateAdapter = {
+ name: "In-Memory",
+ value: "memory",
+ package: "@chat-adapter/state-memory",
+ factoryFn: "createMemoryState",
+ hint: "development only",
+ envVars: [],
+};
+
+const redisState: StateAdapter = {
+ name: "Redis",
+ value: "redis",
+ package: "@chat-adapter/state-redis",
+ factoryFn: "createRedisState",
+ hint: "production",
+ envVars: [
+ { name: "REDIS_URL", description: "Redis connection URL", required: true },
+ ],
+};
+
+const adapters: AdaptersConfig = {
+ platformAdapters: [slackAdapter, teamsAdapter, githubAdapter],
+ stateAdapters: [memoryState, redisState],
+};
+
+function mockFullFlow(
+ overrides: {
+ name?: string;
+ description?: string;
+ platforms?: string[];
+ state?: string;
+ install?: boolean;
+ } = {}
+) {
+ vi.mocked(text)
+ .mockResolvedValueOnce(overrides.name ?? "my-bot")
+ .mockResolvedValueOnce(overrides.description ?? "A bot");
+ vi.mocked(groupMultiselect).mockResolvedValueOnce(
+ overrides.platforms ?? ["slack"]
+ );
+ vi.mocked(select).mockResolvedValueOnce(overrides.state ?? "memory");
+ vi.mocked(confirm).mockResolvedValueOnce(overrides.install ?? true);
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(isCancel).mockImplementation((value: unknown) => value === CANCEL);
+ process.env.npm_config_user_agent = "";
+});
+
+afterEach(() => {
+ process.env.npm_config_user_agent = "";
+});
+
+describe("runPrompts", () => {
+ describe("interactive flow", () => {
+ it("returns full config from prompts", async () => {
+ mockFullFlow();
+ const result = await runPrompts(adapters);
+
+ expect(result).toEqual({
+ name: "my-bot",
+ description: "A bot",
+ platformAdapters: [slackAdapter],
+ stateAdapter: memoryState,
+ shouldInstall: true,
+ packageManager: "npm",
+ });
+ });
+
+ it("returns null when name is cancelled", async () => {
+ vi.mocked(text).mockResolvedValueOnce(CANCEL as never);
+ expect(await runPrompts(adapters)).toBeNull();
+ });
+
+ it("returns null when description is cancelled", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce(CANCEL as never);
+ expect(await runPrompts(adapters)).toBeNull();
+ });
+
+ it("returns null when platform selection is cancelled", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(groupMultiselect).mockResolvedValueOnce(CANCEL as never);
+ expect(await runPrompts(adapters)).toBeNull();
+ });
+
+ it("returns null when state selection is cancelled", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]);
+ vi.mocked(select).mockResolvedValueOnce(CANCEL as never);
+ expect(await runPrompts(adapters)).toBeNull();
+ });
+
+ it("returns null when install confirm is cancelled", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]);
+ vi.mocked(select).mockResolvedValueOnce("memory");
+ vi.mocked(confirm).mockResolvedValueOnce(CANCEL as never);
+ expect(await runPrompts(adapters)).toBeNull();
+ });
+
+ it("throws when selected state adapter is not found", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]);
+ vi.mocked(select).mockResolvedValueOnce("nonexistent");
+ await expect(runPrompts(adapters)).rejects.toThrow(
+ "Unknown state adapter: nonexistent"
+ );
+ });
+
+ it("groups platform options by category", async () => {
+ mockFullFlow();
+ await runPrompts(adapters);
+
+ const call = vi.mocked(groupMultiselect).mock.calls[0]?.[0];
+ expect(call?.options).toEqual({
+ "Messaging Platforms": [
+ { label: "Slack", value: "slack" },
+ { label: "Microsoft Teams", value: "teams" },
+ ],
+ "Developer Tools": [{ label: "GitHub", value: "github" }],
+ });
+ });
+ });
+
+ describe("flag overrides", () => {
+ it("skips name prompt when initial name provided", async () => {
+ vi.mocked(text).mockResolvedValueOnce("desc");
+ vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]);
+ vi.mocked(select).mockResolvedValueOnce("memory");
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ const result = await runPrompts(adapters, "flagged-name");
+ expect(result?.name).toBe("flagged-name");
+ expect(vi.mocked(text)).toHaveBeenCalledTimes(1);
+ });
+
+ it("skips description prompt when initial description provided", async () => {
+ mockFullFlow();
+ const result = await runPrompts(adapters, undefined, "flagged desc");
+ expect(result?.description).toBe("flagged desc");
+ expect(vi.mocked(text)).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses default description when --yes", async () => {
+ vi.mocked(text).mockResolvedValueOnce("my-bot");
+
+ const result = await runPrompts(
+ adapters,
+ undefined,
+ undefined,
+ ["slack"],
+ undefined,
+ undefined,
+ true
+ );
+
+ expect(result?.description).toBe("");
+ expect(vi.mocked(text)).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses empty string when description prompt returns empty", async () => {
+ vi.mocked(text).mockResolvedValueOnce("my-bot").mockResolvedValueOnce("");
+ vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]);
+ vi.mocked(select).mockResolvedValueOnce("memory");
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ const result = await runPrompts(adapters);
+ expect(result?.name).toBe("my-bot");
+ expect(result?.description).toBe("");
+ });
+
+ it("skips platform prompt when adapters flagged", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(select).mockResolvedValueOnce("memory");
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ const result = await runPrompts(adapters, undefined, undefined, [
+ "slack",
+ ]);
+ expect(result?.platformAdapters).toEqual([slackAdapter]);
+ expect(groupMultiselect).not.toHaveBeenCalled();
+ });
+
+ it("skips state prompt when state adapter flagged", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ const result = await runPrompts(adapters, undefined, undefined, [
+ "slack",
+ "redis",
+ ]);
+ expect(result?.stateAdapter).toEqual(redisState);
+ expect(select).not.toHaveBeenCalled();
+ });
+
+ it("uses default state adapter when --yes and state is not flagged", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]);
+
+ const result = await runPrompts(
+ adapters,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ true
+ );
+ expect(result?.stateAdapter).toEqual(memoryState);
+ expect(select).not.toHaveBeenCalled();
+ });
+
+ it("uses no platform adapters when --yes and adapters are not flagged", async () => {
+ vi.mocked(text).mockResolvedValueOnce("my-bot");
+
+ const result = await runPrompts(
+ adapters,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ true
+ );
+
+ expect(result?.platformAdapters).toEqual([]);
+ expect(groupMultiselect).not.toHaveBeenCalled();
+ });
+
+ it("skips install prompt when --yes", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]);
+ vi.mocked(select).mockResolvedValueOnce("memory");
+
+ const result = await runPrompts(
+ adapters,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ true
+ );
+ expect(result?.shouldInstall).toBe(true);
+ expect(confirm).not.toHaveBeenCalled();
+ });
+
+ it("throws on unknown adapter flags", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(select).mockResolvedValueOnce("memory");
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ await expect(
+ runPrompts(adapters, undefined, undefined, ["slack", "bogus"])
+ ).rejects.toThrow("Unknown adapter value: bogus");
+ });
+
+ it("throws on multiple state adapters", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]);
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ await expect(
+ runPrompts(adapters, undefined, undefined, ["memory", "redis"])
+ ).rejects.toThrow(
+ 'Choose one state adapter. Received "memory" and "redis"'
+ );
+ });
+
+ it("dedupes repeated platform adapters", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(select).mockResolvedValueOnce("memory");
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ const result = await runPrompts(adapters, undefined, undefined, [
+ "slack",
+ "slack",
+ ]);
+
+ expect(result?.platformAdapters).toEqual([slackAdapter]);
+ });
+
+ it("logs selected adapters when not quiet", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ await runPrompts(
+ adapters,
+ undefined,
+ undefined,
+ ["slack", "redis"],
+ undefined,
+ undefined,
+ false,
+ false
+ );
+ expect(log.info).toHaveBeenCalledWith("Platform adapters: Slack");
+ expect(log.info).toHaveBeenCalledWith("State adapter: Redis");
+ });
+
+ it("suppresses info logs in quiet mode", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ await runPrompts(
+ adapters,
+ undefined,
+ undefined,
+ ["slack", "redis"],
+ undefined,
+ undefined,
+ false,
+ true
+ );
+ expect(log.info).not.toHaveBeenCalled();
+ });
+
+ it("skips install prompt when install is provided", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]);
+ vi.mocked(select).mockResolvedValueOnce("memory");
+
+ const result = await runPrompts(
+ adapters,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ false
+ );
+ expect(result?.shouldInstall).toBe(false);
+ expect(confirm).not.toHaveBeenCalled();
+ });
+
+ it("skips platform prompt when only state is flagged", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ const result = await runPrompts(adapters, undefined, undefined, [
+ "redis",
+ ]);
+ expect(groupMultiselect).not.toHaveBeenCalled();
+ expect(result?.platformAdapters).toEqual([]);
+ expect(result?.stateAdapter).toEqual(redisState);
+ });
+ });
+
+ describe("name validation", () => {
+ it("validates empty name", async () => {
+ mockFullFlow();
+ await runPrompts(adapters);
+
+ const textCall = vi.mocked(text).mock.calls[0]?.[0] as {
+ validate?: (value: string) => string | undefined;
+ };
+ expect(textCall.validate?.("")).toBe("Project name is required");
+ expect(textCall.validate?.(" ")).toBe("Project name is required");
+ });
+
+ it("validates invalid package name", async () => {
+ mockFullFlow();
+ await runPrompts(adapters);
+
+ const textCall = vi.mocked(text).mock.calls[0]?.[0] as {
+ validate?: (value: string) => string | undefined;
+ };
+ expect(textCall.validate?.("bad name!")).toBe(
+ "Use a valid npm package name, like my-bot or @acme/my-bot"
+ );
+ expect(textCall.validate?.("@scope-name")).toBe(
+ "Use a valid npm package name, like my-bot or @acme/my-bot"
+ );
+ expect(textCall.validate?.("BadName")).toBe(
+ "Use a valid npm package name, like my-bot or @acme/my-bot"
+ );
+ });
+
+ it("accepts valid package names", async () => {
+ mockFullFlow();
+ await runPrompts(adapters);
+
+ const textCall = vi.mocked(text).mock.calls[0]?.[0] as {
+ validate?: (value: string) => string | undefined;
+ };
+ expect(textCall.validate?.("my-bot")).toBeUndefined();
+ expect(textCall.validate?.("my_bot.v2")).toBeUndefined();
+ expect(textCall.validate?.("@acme/my-bot")).toBeUndefined();
+ });
+
+ it("validates initial name", async () => {
+ await expect(runPrompts(adapters, "bad name!")).rejects.toThrow(
+ "Use a valid npm package name, like my-bot or @acme/my-bot"
+ );
+ });
+ });
+
+ describe("package manager detection", () => {
+ it("detects pnpm", async () => {
+ process.env.npm_config_user_agent = "pnpm/9.0.0";
+ mockFullFlow();
+ const result = await runPrompts(adapters);
+ expect(result?.packageManager).toBe("pnpm");
+ });
+
+ it("detects yarn", async () => {
+ process.env.npm_config_user_agent = "yarn/4.0.0";
+ mockFullFlow();
+ const result = await runPrompts(adapters);
+ expect(result?.packageManager).toBe("yarn");
+ });
+
+ it("detects bun", async () => {
+ process.env.npm_config_user_agent = "bun/1.0.0";
+ mockFullFlow();
+ const result = await runPrompts(adapters);
+ expect(result?.packageManager).toBe("bun");
+ });
+
+ it("defaults to npm", async () => {
+ mockFullFlow();
+ const result = await runPrompts(adapters);
+ expect(result?.packageManager).toBe("npm");
+ });
+
+ it("uses --pm override", async () => {
+ process.env.npm_config_user_agent = "pnpm/9.0.0";
+ mockFullFlow();
+ const result = await runPrompts(
+ adapters,
+ undefined,
+ undefined,
+ undefined,
+ "bun"
+ );
+ expect(result?.packageManager).toBe("bun");
+ });
+ });
+});
diff --git a/packages/create-chat-sdk/src/prompts.ts b/packages/create-chat-sdk/src/prompts.ts
new file mode 100644
index 000000000..b8d3a6080
--- /dev/null
+++ b/packages/create-chat-sdk/src/prompts.ts
@@ -0,0 +1,228 @@
+import {
+ confirm,
+ groupMultiselect,
+ isCancel,
+ log,
+ select,
+ text,
+} from "@clack/prompts";
+import type {
+ AdaptersConfig,
+ PackageManager,
+ PlatformAdapter,
+ ProjectConfig,
+ StateAdapter,
+} from "./types.js";
+
+const PACKAGE_NAME_PATTERN =
+ /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
+
+function validatePackageName(value: string): string | undefined {
+ const name = value.trim();
+ if (!name) {
+ return "Project name is required";
+ }
+ if (
+ name.startsWith(".") ||
+ name.startsWith("_") ||
+ name.includes("..") ||
+ !PACKAGE_NAME_PATTERN.test(name)
+ ) {
+ return "Use a valid npm package name, like my-bot or @acme/my-bot";
+ }
+}
+
+function resolveAdapterFlags(adapters: AdaptersConfig, values: string[]) {
+ const platforms: PlatformAdapter[] = [];
+ const platformValues = new Set();
+ let state: StateAdapter | undefined;
+ const unknown: string[] = [];
+
+ for (const v of values) {
+ const platform = adapters.platformAdapters.find((a) => a.value === v);
+ if (platform) {
+ if (!platformValues.has(platform.value)) {
+ platformValues.add(platform.value);
+ platforms.push(platform);
+ }
+ continue;
+ }
+ const s = adapters.stateAdapters.find((a) => a.value === v);
+ if (s) {
+ if (state) {
+ throw new Error(
+ `Choose one state adapter. Received "${state.value}" and "${s.value}"`
+ );
+ }
+ state = s;
+ continue;
+ }
+ unknown.push(v);
+ }
+
+ if (unknown.length > 0) {
+ const available = [
+ ...adapters.platformAdapters.map((a) => a.value),
+ ...adapters.stateAdapters.map((a) => a.value),
+ ].join(", ");
+ throw new Error(
+ `Unknown adapter value: ${unknown.join(", ")}. Available values: ${available}`
+ );
+ }
+
+ return { platforms, state };
+}
+
+export async function runPrompts(
+ adapters: AdaptersConfig,
+ initialName?: string,
+ initialDescription?: string,
+ initialAdapters?: string[],
+ initialPm?: PackageManager,
+ initialInstall?: boolean,
+ yes = false,
+ quiet = false
+): Promise {
+ const name = initialName
+ ? initialName.trim()
+ : await text({
+ message: "Project name:",
+ placeholder: "my-bot",
+ validate: validatePackageName,
+ });
+ if (isCancel(name)) {
+ return null;
+ }
+ const nameError = validatePackageName(name);
+ if (nameError) {
+ throw new Error(nameError);
+ }
+
+ let description: string;
+
+ if (initialDescription != null) {
+ description = initialDescription;
+ } else if (yes) {
+ description = "";
+ } else {
+ const result = await text({
+ message: "Description:",
+ placeholder: "A Chat SDK bot",
+ defaultValue: "",
+ });
+ if (isCancel(result)) {
+ return null;
+ }
+ description = result as string;
+ }
+
+ const flagged = initialAdapters?.length
+ ? resolveAdapterFlags(adapters, initialAdapters)
+ : undefined;
+
+ let selectedPlatforms: PlatformAdapter[];
+
+ if (flagged) {
+ selectedPlatforms = flagged.platforms;
+ if (!quiet) {
+ log.info(
+ `Platform adapters: ${selectedPlatforms.map((a) => a.name).join(", ") || "none"}`
+ );
+ }
+ } else if (yes) {
+ selectedPlatforms = [];
+ } else {
+ const categories = new Map();
+ for (const a of adapters.platformAdapters) {
+ if (!categories.has(a.category)) {
+ categories.set(a.category, []);
+ }
+ categories.get(a.category)?.push({ label: a.name, value: a.value });
+ }
+
+ const platformValues = await groupMultiselect({
+ message: "Select platform adapters:",
+ options: Object.fromEntries(categories),
+ required: false,
+ });
+ if (isCancel(platformValues)) {
+ return null;
+ }
+
+ selectedPlatforms = adapters.platformAdapters.filter((a) =>
+ (platformValues as string[]).includes(a.value)
+ );
+ }
+
+ let selectedState: StateAdapter;
+
+ if (flagged?.state) {
+ selectedState = flagged.state;
+ if (!quiet) {
+ log.info(`State adapter: ${selectedState.name}`);
+ }
+ } else if (yes) {
+ const defaultState = adapters.stateAdapters[0];
+ if (!defaultState) {
+ throw new Error("No state adapters are available");
+ }
+ selectedState = defaultState;
+ if (!quiet) {
+ log.info(`State adapter: ${selectedState.name}`);
+ }
+ } else {
+ const stateValue = await select({
+ message: "Select state adapter:",
+ options: adapters.stateAdapters.map((a) => ({
+ label: a.name,
+ value: a.value,
+ hint: a.hint,
+ })),
+ });
+ if (isCancel(stateValue)) {
+ return null;
+ }
+
+ const found = adapters.stateAdapters.find((a) => a.value === stateValue);
+ if (!found) {
+ throw new Error(`Unknown state adapter: ${String(stateValue)}`);
+ }
+ selectedState = found;
+ }
+
+ let shouldInstall = initialInstall ?? true;
+
+ if (initialInstall === undefined && !yes) {
+ const result = await confirm({
+ message: "Install dependencies?",
+ initialValue: true,
+ });
+ if (isCancel(result)) {
+ return null;
+ }
+ shouldInstall = result;
+ }
+
+ return {
+ name: name as string,
+ description: description || "",
+ platformAdapters: selectedPlatforms,
+ stateAdapter: selectedState,
+ shouldInstall,
+ packageManager: initialPm ?? detectPackageManager(),
+ };
+}
+
+function detectPackageManager(): "npm" | "yarn" | "pnpm" | "bun" {
+ const agent = process.env.npm_config_user_agent || "";
+ if (agent.startsWith("pnpm")) {
+ return "pnpm";
+ }
+ if (agent.startsWith("yarn")) {
+ return "yarn";
+ }
+ if (agent.startsWith("bun")) {
+ return "bun";
+ }
+ return "npm";
+}
diff --git a/packages/create-chat-sdk/src/scaffold.test.ts b/packages/create-chat-sdk/src/scaffold.test.ts
new file mode 100644
index 000000000..8c2c3453f
--- /dev/null
+++ b/packages/create-chat-sdk/src/scaffold.test.ts
@@ -0,0 +1,385 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { PlatformAdapter, ProjectConfig, StateAdapter } from "./types.js";
+
+const CANCEL = Symbol("cancel");
+
+vi.mock("@clack/prompts", () => ({
+ confirm: vi.fn(),
+ isCancel: vi.fn((value: unknown) => value === CANCEL),
+ log: { warning: vi.fn() },
+ spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
+}));
+
+vi.mock("execa", () => ({
+ execa: vi.fn().mockResolvedValue(undefined),
+}));
+
+import { confirm, log, spinner } from "@clack/prompts";
+import { execa } from "execa";
+import { scaffold } from "./scaffold.js";
+
+const slackAdapter: PlatformAdapter = {
+ name: "Slack",
+ value: "slack",
+ package: "@chat-adapter/slack",
+ factoryFn: "createSlackAdapter",
+ typeName: "SlackAdapter",
+ category: "Messaging Platforms",
+ envVars: [
+ {
+ name: "SLACK_SIGNING_SECRET",
+ description: "Slack app signing secret",
+ required: true,
+ },
+ {
+ name: "SLACK_BOT_TOKEN",
+ description: "Slack bot OAuth token",
+ required: false,
+ },
+ ],
+ serverExternalPackages: [],
+};
+
+const discordAdapter: PlatformAdapter = {
+ name: "Discord",
+ value: "discord",
+ package: "@chat-adapter/discord",
+ factoryFn: "createDiscordAdapter",
+ typeName: "DiscordAdapter",
+ category: "Messaging Platforms",
+ envVars: [
+ {
+ name: "DISCORD_BOT_TOKEN",
+ description: "Discord bot token",
+ required: true,
+ },
+ ],
+ serverExternalPackages: ["discord.js", "@discordjs/ws"],
+};
+
+const memoryState: StateAdapter = {
+ name: "In-Memory",
+ value: "memory",
+ package: "@chat-adapter/state-memory",
+ factoryFn: "createMemoryState",
+ hint: "development only",
+ envVars: [],
+};
+
+const redisState: StateAdapter = {
+ name: "Redis",
+ value: "redis",
+ package: "@chat-adapter/state-redis",
+ factoryFn: "createRedisState",
+ hint: "production",
+ envVars: [
+ { name: "REDIS_URL", description: "Redis connection URL", required: true },
+ ],
+};
+
+let tmpDir: string;
+let cwdSpy: ReturnType;
+let exitSpy: ReturnType;
+
+function makeConfig(overrides: Partial = {}): ProjectConfig {
+ return {
+ name: "test-project",
+ description: "",
+ platformAdapters: [slackAdapter],
+ stateAdapter: memoryState,
+ shouldInstall: false,
+ packageManager: "npm",
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-chat-sdk-test-"));
+ cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tmpDir);
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
+ throw new Error("process.exit");
+ });
+});
+
+afterEach(() => {
+ cwdSpy.mockRestore();
+ exitSpy.mockRestore();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+function projectDir() {
+ return path.join(tmpDir, "test-project");
+}
+
+describe("scaffold", () => {
+ describe("file creation", () => {
+ it("copies template files to project directory", async () => {
+ await scaffold(makeConfig(), true, true);
+ expect(fs.existsSync(path.join(projectDir(), "package.json"))).toBe(true);
+ expect(fs.existsSync(path.join(projectDir(), "tsconfig.json"))).toBe(
+ true
+ );
+ expect(fs.existsSync(path.join(projectDir(), "next.config.ts"))).toBe(
+ true
+ );
+ expect(fs.existsSync(path.join(projectDir(), ".env.example"))).toBe(true);
+ expect(fs.existsSync(path.join(projectDir(), ".gitignore"))).toBe(true);
+ });
+
+ it("includes the dependencies needed for a Next.js app", async () => {
+ await scaffold(makeConfig(), true, true);
+ const pkg = JSON.parse(
+ fs.readFileSync(path.join(projectDir(), "package.json"), "utf-8")
+ ) as { dependencies: Record };
+
+ expect(pkg.dependencies.next).toBeDefined();
+ expect(pkg.dependencies.react).toBeDefined();
+ expect(pkg.dependencies["react-dom"]).toBeDefined();
+ });
+
+ it("generates bot.ts with adapter config", async () => {
+ await scaffold(makeConfig(), true, true);
+ const botFile = fs.readFileSync(
+ path.join(projectDir(), "src", "lib", "bot.ts"),
+ "utf-8"
+ );
+ expect(botFile).toContain("createSlackAdapter");
+ expect(botFile).toContain("createMemoryState");
+ });
+ });
+
+ describe("post-processing", () => {
+ it("replaces bot name in .env.example", async () => {
+ await scaffold(makeConfig({ name: "test-project" }), true, true);
+ const env = fs.readFileSync(
+ path.join(projectDir(), ".env.example"),
+ "utf-8"
+ );
+ expect(env).toContain("BOT_USERNAME=test-project");
+ });
+
+ it("appends platform adapter env vars to .env.example", async () => {
+ await scaffold(makeConfig(), true, true);
+ const env = fs.readFileSync(
+ path.join(projectDir(), ".env.example"),
+ "utf-8"
+ );
+ expect(env).toContain("# Slack");
+ expect(env).toContain("SLACK_SIGNING_SECRET=");
+ expect(env).toContain("# Slack bot OAuth token (optional)");
+ expect(env).toContain("SLACK_BOT_TOKEN=");
+ });
+
+ it("appends state adapter env vars when present", async () => {
+ await scaffold(makeConfig({ stateAdapter: redisState }), true, true);
+ const env = fs.readFileSync(
+ path.join(projectDir(), ".env.example"),
+ "utf-8"
+ );
+ expect(env).toContain("# Redis State");
+ expect(env).toContain("REDIS_URL=");
+ });
+
+ it("skips state env section when state has no env vars", async () => {
+ await scaffold(makeConfig({ stateAdapter: memoryState }), true, true);
+ const env = fs.readFileSync(
+ path.join(projectDir(), ".env.example"),
+ "utf-8"
+ );
+ expect(env).not.toContain("# In-Memory State");
+ });
+
+ it("adds serverExternalPackages to next.config.ts", async () => {
+ await scaffold(
+ makeConfig({ platformAdapters: [discordAdapter] }),
+ true,
+ true
+ );
+ const nextConfig = fs.readFileSync(
+ path.join(projectDir(), "next.config.ts"),
+ "utf-8"
+ );
+ expect(nextConfig).toContain("serverExternalPackages");
+ expect(nextConfig).toContain('"discord.js"');
+ expect(nextConfig).toContain('"@discordjs/ws"');
+ });
+
+ it("leaves next.config.ts unchanged when no external packages", async () => {
+ await scaffold(makeConfig(), true, true);
+ const nextConfig = fs.readFileSync(
+ path.join(projectDir(), "next.config.ts"),
+ "utf-8"
+ );
+ expect(nextConfig).not.toContain("serverExternalPackages");
+ });
+ });
+
+ describe("package.json population", () => {
+ it("writes name and adapter deps", async () => {
+ await scaffold(makeConfig(), true, true);
+ const pkg = JSON.parse(
+ fs.readFileSync(path.join(projectDir(), "package.json"), "utf-8")
+ ) as {
+ dependencies: Record;
+ name: string;
+ };
+
+ expect(pkg.name).toBe("test-project");
+ expect(pkg.dependencies["@chat-adapter/slack"]).toBe("latest");
+ expect(pkg.dependencies["@chat-adapter/state-memory"]).toBe("latest");
+ });
+
+ it("includes description when provided", async () => {
+ await scaffold(makeConfig({ description: "My bot" }), true, true);
+ const pkg = JSON.parse(
+ fs.readFileSync(path.join(projectDir(), "package.json"), "utf-8")
+ ) as { description: string };
+ expect(pkg.description).toBe("My bot");
+ });
+
+ it("omits description when empty", async () => {
+ await scaffold(makeConfig({ description: "" }), true, true);
+ const pkg = JSON.parse(
+ fs.readFileSync(path.join(projectDir(), "package.json"), "utf-8")
+ ) as { description?: string };
+ expect(pkg.description).toBeUndefined();
+ });
+
+ it("does not require npm when install is skipped", async () => {
+ await scaffold(makeConfig({ shouldInstall: false }), true, true);
+ expect(execa).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("directory overwrite", () => {
+ it("prompts when directory exists, is not empty, and --yes is false", async () => {
+ const dir = projectDir();
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, "file.txt"), "existing");
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ await scaffold(makeConfig(), false, true);
+ expect(confirm).toHaveBeenCalled();
+ });
+
+ it("exits when overwrite is declined", async () => {
+ const dir = projectDir();
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, "file.txt"), "existing");
+ vi.mocked(confirm).mockResolvedValueOnce(false);
+
+ await expect(scaffold(makeConfig(), false, true)).rejects.toThrow(
+ "process.exit"
+ );
+ expect(exitSpy).toHaveBeenCalledWith(0);
+ });
+
+ it("exits when overwrite is cancelled", async () => {
+ const dir = projectDir();
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, "file.txt"), "existing");
+ vi.mocked(confirm).mockResolvedValueOnce(CANCEL as never);
+
+ await expect(scaffold(makeConfig(), false, true)).rejects.toThrow(
+ "process.exit"
+ );
+ });
+
+ it("skips prompt when --yes", async () => {
+ const dir = projectDir();
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, "file.txt"), "existing");
+
+ await scaffold(makeConfig(), true, true);
+ expect(confirm).not.toHaveBeenCalled();
+ });
+
+ it("skips prompt when directory does not exist", async () => {
+ await scaffold(makeConfig(), false, true);
+ expect(confirm).not.toHaveBeenCalled();
+ });
+
+ it("skips prompt when directory is empty", async () => {
+ fs.mkdirSync(projectDir(), { recursive: true });
+ await scaffold(makeConfig(), false, true);
+ expect(confirm).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("spinners", () => {
+ it("shows spinners when not quiet", async () => {
+ await scaffold(makeConfig(), true, false);
+ expect(spinner).toHaveBeenCalled();
+ });
+
+ it("hides spinners when quiet", async () => {
+ await scaffold(makeConfig(), true, true);
+ expect(spinner).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("dependency installation", () => {
+ it("installs dependencies when shouldInstall is true", async () => {
+ await scaffold(makeConfig({ shouldInstall: true }), true, true);
+ expect(execa).toHaveBeenCalledWith(
+ "npm",
+ ["install"],
+ expect.objectContaining({ cwd: projectDir(), stdio: "pipe" })
+ );
+ });
+
+ it("uses configured package manager for install", async () => {
+ await scaffold(
+ makeConfig({ shouldInstall: true, packageManager: "pnpm" }),
+ true,
+ true
+ );
+ expect(execa).toHaveBeenCalledWith(
+ "pnpm",
+ ["install"],
+ expect.anything()
+ );
+ });
+
+ it("updates generated readme commands to the selected package manager", async () => {
+ await scaffold(
+ makeConfig({ shouldInstall: false, packageManager: "pnpm" }),
+ true,
+ true
+ );
+ const readme = fs.readFileSync(
+ path.join(projectDir(), "README.md"),
+ "utf-8"
+ );
+ expect(readme).toContain("pnpm run dev");
+ expect(readme).not.toContain("`npm run dev`");
+ });
+
+ it("skips install when shouldInstall is false", async () => {
+ await scaffold(makeConfig({ shouldInstall: false }), true, true);
+ const calls = vi.mocked(execa).mock.calls;
+ const installCalls = calls.filter(
+ (c) => c[1] && (c[1] as string[]).includes("install")
+ );
+ expect(installCalls).toHaveLength(0);
+ });
+
+ it("handles install failure gracefully", async () => {
+ vi.mocked(execa).mockRejectedValueOnce(new Error("install failed"));
+
+ await scaffold(makeConfig({ shouldInstall: true }), true, false);
+ expect(log.warning).toHaveBeenCalledWith(
+ 'Run "npm install" manually in the project directory.'
+ );
+ });
+
+ it("shows install spinner when not quiet", async () => {
+ await scaffold(makeConfig({ shouldInstall: true }), true, false);
+ expect(spinner).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/packages/create-chat-sdk/src/scaffold.ts b/packages/create-chat-sdk/src/scaffold.ts
new file mode 100644
index 000000000..9c85fdbc4
--- /dev/null
+++ b/packages/create-chat-sdk/src/scaffold.ts
@@ -0,0 +1,167 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { confirm, isCancel, log, spinner } from "@clack/prompts";
+import { execa } from "execa";
+import { botTs } from "./templates.js";
+import type { ProjectConfig } from "./types.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+function copyDir(src: string, dest: string) {
+ fs.mkdirSync(dest, { recursive: true });
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
+ const srcPath = path.join(src, entry.name);
+ const destPath = path.join(dest, entry.name);
+ if (entry.isDirectory()) {
+ copyDir(srcPath, destPath);
+ } else {
+ fs.copyFileSync(srcPath, destPath);
+ }
+ }
+}
+
+function readFile(projectDir: string, filePath: string): string {
+ return fs.readFileSync(path.join(projectDir, filePath), "utf-8");
+}
+
+function writeFile(projectDir: string, filePath: string, content: string) {
+ const fullPath = path.join(projectDir, filePath);
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+ fs.writeFileSync(fullPath, content);
+}
+
+function writeJsonFile(projectDir: string, filePath: string, value: unknown) {
+ writeFile(projectDir, filePath, `${JSON.stringify(value, null, 2)}\n`);
+}
+
+function postProcessPackageJson(projectDir: string, config: ProjectConfig) {
+ const pkg = JSON.parse(readFile(projectDir, "package.json")) as {
+ dependencies?: Record;
+ description?: string;
+ name?: string;
+ };
+
+ pkg.name = config.name;
+ if (config.description) {
+ pkg.description = config.description;
+ } else {
+ pkg.description = undefined;
+ }
+
+ pkg.dependencies ??= {};
+ for (const adapter of config.platformAdapters) {
+ pkg.dependencies[adapter.package] = "latest";
+ }
+ pkg.dependencies[config.stateAdapter.package] = "latest";
+
+ writeJsonFile(projectDir, "package.json", pkg);
+}
+
+function postProcessReadme(projectDir: string, config: ProjectConfig) {
+ const content = readFile(projectDir, "README.md")
+ .replace("npm run dev", `${config.packageManager} run dev`)
+ .replace("| `npm run dev` |", `| \`${config.packageManager} run dev\` |`)
+ .replace("npm run build", `${config.packageManager} run build`)
+ .replace("npm run start", `${config.packageManager} run start`);
+ writeFile(projectDir, "README.md", content);
+}
+
+function postProcessEnvExample(projectDir: string, config: ProjectConfig) {
+ let content = readFile(projectDir, ".env.example").replace(
+ "BOT_USERNAME=my-bot",
+ `BOT_USERNAME=${config.name}`
+ );
+
+ for (const adapter of config.platformAdapters) {
+ content += `\n# ${adapter.name}\n`;
+ for (const env of adapter.envVars) {
+ const suffix = env.required ? "" : " (optional)";
+ content += `# ${env.description}${suffix}\n`;
+ content += `${env.name}=\n`;
+ }
+ }
+
+ if (config.stateAdapter.envVars.length > 0) {
+ content += `\n# ${config.stateAdapter.name} State\n`;
+ for (const env of config.stateAdapter.envVars) {
+ content += `# ${env.description}\n`;
+ content += `${env.name}=\n`;
+ }
+ }
+
+ writeFile(projectDir, ".env.example", content);
+}
+
+function postProcessNextConfig(projectDir: string, config: ProjectConfig) {
+ const externalPkgs = config.platformAdapters.flatMap(
+ (a) => a.serverExternalPackages
+ );
+ if (externalPkgs.length === 0) {
+ return;
+ }
+
+ const pkgList = externalPkgs.map((p) => ` "${p}",`).join("\n");
+ const content = readFile(projectDir, "next.config.ts").replace(
+ "const nextConfig: NextConfig = {};",
+ `const nextConfig: NextConfig = {\n serverExternalPackages: [\n${pkgList}\n ],\n};`
+ );
+ writeFile(projectDir, "next.config.ts", content);
+}
+
+export async function scaffold(
+ config: ProjectConfig,
+ yes = false,
+ quiet = false
+) {
+ const projectDir = path.resolve(process.cwd(), config.name);
+
+ if (
+ fs.existsSync(projectDir) &&
+ fs.readdirSync(projectDir).length > 0 &&
+ !yes
+ ) {
+ const shouldContinue = await confirm({
+ message: `Directory "${config.name}" already exists and is not empty. Continue?`,
+ initialValue: false,
+ });
+ if (!shouldContinue || isCancel(shouldContinue)) {
+ process.exit(0);
+ }
+ }
+
+ const s = quiet ? null : spinner();
+ s?.start("Creating project files");
+
+ const templateDir = path.resolve(__dirname, "..", "_template");
+ copyDir(templateDir, projectDir);
+
+ postProcessEnvExample(projectDir, config);
+ postProcessNextConfig(projectDir, config);
+ postProcessPackageJson(projectDir, config);
+ postProcessReadme(projectDir, config);
+
+ writeFile(projectDir, "src/lib/bot.ts", botTs(config));
+
+ s?.stop("Project files created!");
+
+ if (config.shouldInstall) {
+ const installSpinner = quiet ? null : spinner();
+ installSpinner?.start(
+ `Installing dependencies with ${config.packageManager}`
+ );
+
+ try {
+ await execa(config.packageManager, ["install"], {
+ cwd: projectDir,
+ stdio: "pipe",
+ });
+ installSpinner?.stop("Dependencies installed.");
+ } catch {
+ installSpinner?.stop("Failed to install dependencies.");
+ log.warning(
+ `Run "${config.packageManager} install" manually in the project directory.`
+ );
+ }
+ }
+}
diff --git a/packages/create-chat-sdk/src/templates.test.ts b/packages/create-chat-sdk/src/templates.test.ts
new file mode 100644
index 000000000..ba79a9a6d
--- /dev/null
+++ b/packages/create-chat-sdk/src/templates.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from "vitest";
+import { botTs } from "./templates.js";
+import type { PlatformAdapter, ProjectConfig, StateAdapter } from "./types.js";
+
+const slackAdapter: PlatformAdapter = {
+ name: "Slack",
+ value: "slack",
+ package: "@chat-adapter/slack",
+ factoryFn: "createSlackAdapter",
+ typeName: "SlackAdapter",
+ category: "Messaging Platforms",
+ envVars: [],
+ serverExternalPackages: [],
+};
+
+const teamsAdapter: PlatformAdapter = {
+ name: "Microsoft Teams",
+ value: "teams",
+ package: "@chat-adapter/teams",
+ factoryFn: "createTeamsAdapter",
+ typeName: "TeamsAdapter",
+ category: "Messaging Platforms",
+ envVars: [],
+ serverExternalPackages: [],
+};
+
+const memoryState: StateAdapter = {
+ name: "In-Memory",
+ value: "memory",
+ package: "@chat-adapter/state-memory",
+ factoryFn: "createMemoryState",
+ hint: "development only",
+ envVars: [],
+};
+
+const redisState: StateAdapter = {
+ name: "Redis",
+ value: "redis",
+ package: "@chat-adapter/state-redis",
+ factoryFn: "createRedisState",
+ hint: "production",
+ envVars: [
+ { name: "REDIS_URL", description: "Redis connection URL", required: true },
+ ],
+};
+
+function makeConfig(overrides: Partial = {}): ProjectConfig {
+ return {
+ name: "test-bot",
+ description: "A test bot",
+ platformAdapters: [slackAdapter],
+ stateAdapter: memoryState,
+ shouldInstall: false,
+ packageManager: "npm",
+ ...overrides,
+ };
+}
+
+describe("botTs", () => {
+ it("generates imports for each platform adapter", () => {
+ const result = botTs(
+ makeConfig({ platformAdapters: [slackAdapter, teamsAdapter] })
+ );
+ expect(result).toContain(
+ 'import { createSlackAdapter } from "@chat-adapter/slack";'
+ );
+ expect(result).toContain(
+ 'import { createTeamsAdapter } from "@chat-adapter/teams";'
+ );
+ });
+
+ it("generates import for state adapter", () => {
+ const result = botTs(makeConfig({ stateAdapter: redisState }));
+ expect(result).toContain(
+ 'import { createRedisState } from "@chat-adapter/state-redis";'
+ );
+ });
+
+ it("always imports Chat from 'chat'", () => {
+ const result = botTs(makeConfig());
+ expect(result).toContain('import { Chat } from "chat";');
+ });
+
+ it("creates adapter entries in the adapters block", () => {
+ const result = botTs(
+ makeConfig({ platformAdapters: [slackAdapter, teamsAdapter] })
+ );
+ expect(result).toContain("slack: createSlackAdapter(),");
+ expect(result).toContain("teams: createTeamsAdapter(),");
+ });
+
+ it("uses empty adapters block when no platform adapters", () => {
+ const result = botTs(makeConfig({ platformAdapters: [] }));
+ expect(result).toContain("adapters: {},");
+ });
+
+ it("uses bot name from config", () => {
+ const result = botTs(makeConfig({ name: "my-cool-bot" }));
+ expect(result).toContain('"my-cool-bot"');
+ });
+
+ it("calls state factory in the state property", () => {
+ const result = botTs(makeConfig({ stateAdapter: memoryState }));
+ expect(result).toContain("state: createMemoryState(),");
+ });
+
+ it("includes onNewMention handler", () => {
+ const result = botTs(makeConfig());
+ expect(result).toContain("bot.onNewMention(");
+ expect(result).toContain("await thread.subscribe();");
+ });
+
+ it("includes onSubscribedMessage handler", () => {
+ const result = botTs(makeConfig());
+ expect(result).toContain("bot.onSubscribedMessage(");
+ expect(result).toContain("await thread.post(");
+ });
+});
diff --git a/packages/create-chat-sdk/src/templates.ts b/packages/create-chat-sdk/src/templates.ts
new file mode 100644
index 000000000..224f4539f
--- /dev/null
+++ b/packages/create-chat-sdk/src/templates.ts
@@ -0,0 +1,37 @@
+import type { ProjectConfig } from "./types.js";
+
+export function botTs(config: ProjectConfig): string {
+ const imports: string[] = [];
+
+ for (const a of config.platformAdapters) {
+ imports.push(`import { ${a.factoryFn} } from "${a.package}";`);
+ }
+ imports.push(
+ `import { ${config.stateAdapter.factoryFn} } from "${config.stateAdapter.package}";`
+ );
+ imports.push('import { Chat } from "chat";');
+
+ const adapterEntries = config.platformAdapters
+ .map((a) => ` ${a.value}: ${a.factoryFn}(),`)
+ .join("\n");
+
+ const adaptersBlock = adapterEntries ? `{\n${adapterEntries}\n }` : "{}";
+
+ return `${imports.join("\n")}
+
+export const bot = new Chat({
+ userName: process.env.BOT_USERNAME || "${config.name}",
+ adapters: ${adaptersBlock},
+ state: ${config.stateAdapter.factoryFn}(),
+});
+
+bot.onNewMention(async (thread, message) => {
+ await thread.subscribe();
+ await thread.post(\`Hello, \${message.author.fullName}! I'm listening to this thread.\`);
+});
+
+bot.onSubscribedMessage(async (thread, message) => {
+ await thread.post(\`You said: \${message.text}\`);
+});
+`;
+}
diff --git a/packages/create-chat-sdk/src/types.ts b/packages/create-chat-sdk/src/types.ts
new file mode 100644
index 000000000..57236d1fb
--- /dev/null
+++ b/packages/create-chat-sdk/src/types.ts
@@ -0,0 +1,41 @@
+export interface EnvVar {
+ description: string;
+ name: string;
+ required: boolean;
+}
+
+export interface PlatformAdapter {
+ category: string;
+ envVars: EnvVar[];
+ factoryFn: string;
+ name: string;
+ package: string;
+ serverExternalPackages: string[];
+ typeName: string;
+ value: string;
+}
+
+export interface StateAdapter {
+ envVars: EnvVar[];
+ factoryFn: string;
+ hint: string;
+ name: string;
+ package: string;
+ value: string;
+}
+
+export interface AdaptersConfig {
+ platformAdapters: PlatformAdapter[];
+ stateAdapters: StateAdapter[];
+}
+
+export type PackageManager = "npm" | "yarn" | "pnpm" | "bun";
+
+export interface ProjectConfig {
+ description: string;
+ name: string;
+ packageManager: PackageManager;
+ platformAdapters: PlatformAdapter[];
+ shouldInstall: boolean;
+ stateAdapter: StateAdapter;
+}
diff --git a/packages/create-chat-sdk/tsconfig.json b/packages/create-chat-sdk/tsconfig.json
new file mode 100644
index 000000000..e00f90b71
--- /dev/null
+++ b/packages/create-chat-sdk/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "declaration": false,
+ "declarationMap": false,
+ "sourceMap": false
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "_template"]
+}
diff --git a/packages/create-chat-sdk/tsup.config.ts b/packages/create-chat-sdk/tsup.config.ts
new file mode 100644
index 000000000..959f4f334
--- /dev/null
+++ b/packages/create-chat-sdk/tsup.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+ entry: ["src/index.ts"],
+ format: ["esm"],
+ clean: true,
+ sourcemap: false,
+ banner: {
+ js: "#!/usr/bin/env node",
+ },
+});
diff --git a/packages/create-chat-sdk/vitest.config.ts b/packages/create-chat-sdk/vitest.config.ts
new file mode 100644
index 000000000..c34af6930
--- /dev/null
+++ b/packages/create-chat-sdk/vitest.config.ts
@@ -0,0 +1,14 @@
+import { defineProject } from "vitest/config";
+
+export default defineProject({
+ test: {
+ globals: true,
+ environment: "node",
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "json-summary"],
+ include: ["src/**/*.ts"],
+ exclude: ["src/**/*.test.ts", "src/index.ts"],
+ },
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e99b9038f..4538d4f0f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -673,6 +673,37 @@ importers:
specifier: ^4.3.6
version: 4.3.6
+ packages/create-chat-sdk:
+ dependencies:
+ '@clack/prompts':
+ specifier: ^0.10.0
+ version: 0.10.1
+ commander:
+ specifier: ^13.0.0
+ version: 13.1.0
+ execa:
+ specifier: ^9.5.2
+ version: 9.6.1
+ picocolors:
+ specifier: ^1.1.1
+ version: 1.1.1
+ devDependencies:
+ '@types/node':
+ specifier: ^25.3.2
+ version: 25.3.2
+ '@vitest/coverage-v8':
+ specifier: ^4.0.18
+ version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.22.3))
+ tsup:
+ specifier: ^8.3.5
+ version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.15)(tsx@4.22.3)(typescript@5.9.3)
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vitest:
+ specifier: ^4.0.18
+ version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.22.3)
+
packages/integration-tests:
dependencies:
'@chat-adapter/discord':
@@ -1190,9 +1221,15 @@ packages:
'@chevrotain/types@11.1.2':
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
+ '@clack/core@0.4.2':
+ resolution: {integrity: sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==}
+
'@clack/core@1.0.1':
resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==}
+ '@clack/prompts@0.10.1':
+ resolution: {integrity: sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==}
+
'@clack/prompts@1.0.1':
resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==}
@@ -4775,6 +4812,10 @@ packages:
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+ commander@13.1.0:
+ resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
+ engines: {node: '>=18'}
+
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
@@ -8789,11 +8830,22 @@ snapshots:
'@chevrotain/types@11.1.2': {}
+ '@clack/core@0.4.2':
+ dependencies:
+ picocolors: 1.1.1
+ sisteransi: 1.0.5
+
'@clack/core@1.0.1':
dependencies:
picocolors: 1.1.1
sisteransi: 1.0.5
+ '@clack/prompts@0.10.1':
+ dependencies:
+ '@clack/core': 0.4.2
+ picocolors: 1.1.1
+ sisteransi: 1.0.5
+
'@clack/prompts@1.0.1':
dependencies:
'@clack/core': 1.0.1
@@ -12427,6 +12479,8 @@ snapshots:
comma-separated-tokens@2.0.3: {}
+ commander@13.1.0: {}
+
commander@14.0.3: {}
commander@4.1.1: {}
diff --git a/skills/chat/SKILL.md b/skills/chat/SKILL.md
index b40526ba5..4ac70a825 100644
--- a/skills/chat/SKILL.md
+++ b/skills/chat/SKILL.md
@@ -1,12 +1,37 @@
---
name: chat-sdk
-description: Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to build a Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, or WhatsApp bot, handle mentions, direct messages, subscribed threads, reactions, slash commands, cards, modals, files, or AI streaming, set up webhook routes or multi-adapter bots, send rich cards or streamed AI responses to chat platforms, or build a custom adapter or state adapter.
+description: Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to build a Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, or WhatsApp bot, handle mentions, direct messages, subscribed threads, reactions, slash commands, cards, modals, files, or AI streaming, set up webhook routes or multi-adapter bots, send rich cards or streamed AI responses to chat platforms, scaffold a new bot project with create-chat-sdk, or build a custom adapter or state adapter.
---
# Chat SDK
Unified TypeScript SDK for building chat bots across Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. Write bot logic once, deploy everywhere.
+## Scaffold a new project
+
+The `create-chat-sdk` CLI scaffolds a complete Next.js bot project with adapters, environment variables, and a webhook route:
+
+```bash
+npm create chat-sdk@latest my-bot
+```
+
+It supports non-interactive usage for CI or scripting:
+
+```bash
+npm create chat-sdk@latest my-bot --adapter slack discord redis -d "My bot" --pm pnpm -y
+```
+
+Key flags: `--adapter ` (platform or state adapters), `-d ` (description), `--pm ` (npm/yarn/pnpm/bun), `-y` (skip prompts), `--no-install` (skip dependency installation), `-q` (quiet output). Run `npm create chat-sdk@latest -- --help` for the full list.
+
+The scaffolded project structure:
+
+```
+src/
+ lib/bot.ts # Bot config - adapters, state, handlers
+ app/api/webhooks/[platform]/route.ts # Webhook route
+.env.example # Required env vars
+```
+
## Start with published sources
When Chat SDK is installed in a user project, inspect the published files that ship in `node_modules`: