diff --git a/CLAUDE.md b/CLAUDE.md
index ddcae2b8..3537444d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -38,9 +38,11 @@ pnpm --filter chat build
pnpm --filter @chat-adapter/slack build
pnpm --filter @chat-adapter/gchat build
pnpm --filter @chat-adapter/teams build
+pnpm --filter create-bot build
# Run tests for a specific package
pnpm --filter chat test
+pnpm --filter create-bot test
pnpm --filter @chat-adapter/integration-tests test
# Run a single test file
@@ -65,6 +67,7 @@ This is a **pnpm monorepo** using **Turborepo** for build orchestration. All pac
- **`packages/state-memory`** - In-memory state adapter (for development/testing)
- **`packages/state-redis`** - Redis state adapter (for production)
- **`packages/adapter-whatsapp`** - WhatsApp adapter using Meta Cloud API
+- **`packages/create-bot`** - `create-bot` CLI for scaffolding new Chat SDK bot projects
- **`packages/integration-tests`** - Integration tests against real platform APIs
- **`examples/nextjs-chat`** - Example Next.js app showing how to use the SDK
diff --git a/apps/docs/content/docs/contributing/building.mdx b/apps/docs/content/docs/contributing/building.mdx
index a731590f..a63ad670 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-bot`](/docs/create-bot) 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-bot.mdx b/apps/docs/content/docs/create-bot.mdx
new file mode 100644
index 00000000..5818089f
--- /dev/null
+++ b/apps/docs/content/docs/create-bot.mdx
@@ -0,0 +1,86 @@
+---
+title: create-bot
+description: Scaffold a new Chat SDK bot project with a single command.
+---
+
+`create-bot` 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"
+npx create-bot my-bot
+```
+
+```bash tab="pnpm"
+pnpm create bot my-bot
+```
+
+```bash tab="yarn"
+yarn create bot my-bot
+```
+
+```bash tab="bun"
+bunx create-bot 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-bot` fully scriptable for CI, automation, and AI agents:
+
+```bash
+npx create-bot 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 confirmation prompts (accept defaults) |
+| `-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 3270e92d..ecbec472 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-bot`. 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 b81253c1..8f3ec7cc 100644
--- a/apps/docs/content/docs/index.mdx
+++ b/apps/docs/content/docs/index.mdx
@@ -18,6 +18,16 @@ 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-bot`:
+
+```bash
+npx create-bot my-bot
+```
+
+This scaffolds a Next.js app with your chosen adapters, environment variables, and a webhook route — ready to run. See the [create-bot docs](/docs/create-bot) for all options.
+
## How it works
Chat SDK has three core concepts:
diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json
index 34ab76b5..11c025f2 100644
--- a/apps/docs/content/docs/meta.json
+++ b/apps/docs/content/docs/meta.json
@@ -3,6 +3,7 @@
"pages": [
"index",
"getting-started",
+ "create-bot",
"---Usage---",
"usage",
"threads-messages-channels",
diff --git a/biome.jsonc b/biome.jsonc
index 68a031a9..38238816 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -29,6 +29,7 @@
"files": {
"includes": [
"**/*",
+ "!packages/create-bot/_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 eab64222..6cd44642 100644
--- a/knip.json
+++ b/knip.json
@@ -2,7 +2,7 @@
"$schema": "https://unpkg.com/knip@5/schema.json",
"ignoreBinaries": ["vercel"],
"ignoreDependencies": ["@biomejs/biome"],
- "ignore": ["apps/docs/**/*"],
+ "ignore": ["apps/docs/**/*", "packages/create-bot/_template/**/*"],
"rules": {
"duplicates": "off",
"types": "off"
diff --git a/packages/create-bot/AGENTS.md b/packages/create-bot/AGENTS.md
new file mode 100644
index 00000000..5e697566
--- /dev/null
+++ b/packages/create-bot/AGENTS.md
@@ -0,0 +1,19 @@
+# AGENTS.md
+
+`create-bot` is a CLI tool that scaffolds new [Chat SDK](https://chat-sdk.dev) bot projects.
+
+## Commands
+
+```bash
+pnpm --filter create-bot build # Build with tsup
+pnpm --filter create-bot typecheck # Type-check
+pnpm --filter create-bot 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-bot.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-bot/ARCHITECTURE.md b/packages/create-bot/ARCHITECTURE.md
new file mode 100644
index 00000000..0c07c602
--- /dev/null
+++ b/packages/create-bot/ARCHITECTURE.md
@@ -0,0 +1,81 @@
+# Architecture
+
+`create-bot` is a CLI that scaffolds new [Chat SDK](https://chat-sdk.dev) bot projects. It is published as the `create-bot` npm package and lives in the Chat SDK monorepo at `packages/create-bot`.
+
+## Project structure
+
+```
+packages/create-bot/
+├── 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 (npx create-bot 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-bot/CLAUDE.md b/packages/create-bot/CLAUDE.md
new file mode 100644
index 00000000..eef4bd20
--- /dev/null
+++ b/packages/create-bot/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
\ No newline at end of file
diff --git a/packages/create-bot/README.md b/packages/create-bot/README.md
new file mode 100644
index 00000000..0dceabdf
--- /dev/null
+++ b/packages/create-bot/README.md
@@ -0,0 +1,129 @@
+# create-bot
+
+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
+npx create-bot my-bot
+```
+
+Or with your preferred package manager:
+
+```bash
+pnpm create bot my-bot
+yarn create bot my-bot
+bunx create-bot my-bot
+```
+
+The CLI walks you through selecting platform adapters, a state adapter, and installs dependencies for you.
+
+## Usage
+
+```
+Usage: create-bot [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 confirmation prompts (accept defaults)
+ -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
+npx create-bot
+```
+
+Provide a name and let the CLI prompt for the rest:
+
+```bash
+npx create-bot my-bot
+```
+
+Skip adapter prompts by passing them directly:
+
+```bash
+npx create-bot my-bot --adapter slack teams redis
+```
+
+Fully non-interactive:
+
+```bash
+npx create-bot my-bot -d 'My awesome bot' --adapter slack redis -y
+```
+
+Silent non-interactive (for CI/scripts):
+
+```bash
+npx create-bot my-bot --adapter slack pg -yq --pm pnpm
+```
+
+## 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-bot/_template/.agents/skills/chat-sdk/SKILL.md b/packages/create-bot/_template/.agents/skills/chat-sdk/SKILL.md
new file mode 100644
index 00000000..91f0239c
--- /dev/null
+++ b/packages/create-bot/_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-bot.
+ 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-bot", "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-bot` CLI scaffolds a complete Next.js bot project with adapters, environment variables, and a webhook route:
+
+```bash
+npx create-bot my-bot
+```
+
+It supports non-interactive usage for CI or scripting:
+
+```bash
+npx create-bot my-bot --adapter slack discord --adapter redis -d "My bot" --pm pnpm -y
+```
+
+Key flags: `--adapter ` (platform or state adapters), `-d ` (description), `--pm ` (npm/yarn/pnpm/bun), `-y` (skip confirmations), `-q` (quiet output). Run `npx create-bot --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.
\ No newline at end of file
diff --git a/packages/create-bot/_template/.env.example b/packages/create-bot/_template/.env.example
new file mode 100644
index 00000000..502574b0
--- /dev/null
+++ b/packages/create-bot/_template/.env.example
@@ -0,0 +1,2 @@
+# Bot Configuration
+BOT_USERNAME=my-bot
diff --git a/packages/create-bot/_template/.gitignore b/packages/create-bot/_template/.gitignore
new file mode 100644
index 00000000..b41fed88
--- /dev/null
+++ b/packages/create-bot/_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-bot/_template/AGENTS.md b/packages/create-bot/_template/AGENTS.md
new file mode 100644
index 00000000..afd31b83
--- /dev/null
+++ b/packages/create-bot/_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-bot/_template/README.md b/packages/create-bot/_template/README.md
new file mode 100644
index 00000000..2fc6c4f1
--- /dev/null
+++ b/packages/create-bot/_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-bot/_template/next.config.ts b/packages/create-bot/_template/next.config.ts
new file mode 100644
index 00000000..cb651cdc
--- /dev/null
+++ b/packages/create-bot/_template/next.config.ts
@@ -0,0 +1,5 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {};
+
+export default nextConfig;
diff --git a/packages/create-bot/_template/package.json b/packages/create-bot/_template/package.json
new file mode 100644
index 00000000..148a4444
--- /dev/null
+++ b/packages/create-bot/_template/package.json
@@ -0,0 +1,21 @@
+{
+ "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"
+ },
+ "devDependencies": {
+ "@types/node": "^25.6.0",
+ "@types/react": "^19.2.14",
+ "typescript": "^6.0.2"
+ }
+}
diff --git a/packages/create-bot/_template/src/app/api/webhooks/[platform]/route.ts b/packages/create-bot/_template/src/app/api/webhooks/[platform]/route.ts
new file mode 100644
index 00000000..d900a6e2
--- /dev/null
+++ b/packages/create-bot/_template/src/app/api/webhooks/[platform]/route.ts
@@ -0,0 +1,22 @@
+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-bot/_template/tsconfig.json b/packages/create-bot/_template/tsconfig.json
new file mode 100644
index 00000000..9ea68feb
--- /dev/null
+++ b/packages/create-bot/_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-bot/adapters.json b/packages/create-bot/adapters.json
new file mode 100644
index 00000000..ad50c3d0
--- /dev/null
+++ b/packages/create-bot/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-bot/package.json b/packages/create-bot/package.json
new file mode 100644
index 00000000..7848e7a5
--- /dev/null
+++ b/packages/create-bot/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "create-bot",
+ "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-bot": "./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-bot"
+ },
+ "homepage": "https://chat-sdk.dev",
+ "bugs": {
+ "url": "https://github.com/vercel/chat/issues"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "license": "MIT"
+}
diff --git a/packages/create-bot/src/cli.test.ts b/packages/create-bot/src/cli.test.ts
new file mode 100644
index 00000000..b45beed1
--- /dev/null
+++ b/packages/create-bot/src/cli.test.ts
@@ -0,0 +1,220 @@
+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-bot");
+ });
+
+ 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-bot 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-bot", "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-bot", "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-bot", "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-bot", "my-bot"]);
+
+ expect(runPrompts).toHaveBeenCalledWith(
+ expect.anything(),
+ "my-bot",
+ undefined,
+ undefined,
+ undefined,
+ false,
+ false
+ );
+ });
+
+ it("passes flags to runPrompts", async () => {
+ vi.mocked(runPrompts).mockResolvedValueOnce(fakeConfig);
+ const prog = createProgram();
+ await prog.parseAsync([
+ "node",
+ "create-bot",
+ "my-bot",
+ "-d",
+ "desc",
+ "--adapter",
+ "slack",
+ "redis",
+ "--pm",
+ "pnpm",
+ "-yq",
+ ]);
+
+ expect(runPrompts).toHaveBeenCalledWith(
+ expect.anything(),
+ "my-bot",
+ "desc",
+ ["slack", "redis"],
+ "pnpm",
+ true,
+ true
+ );
+ });
+ });
+
+ 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-bot", "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-bot", "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-bot", "my-bot", "-yq"])
+ ).rejects.toThrow("process.exit");
+ expect(outro).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/create-bot/src/cli.ts b/packages/create-bot/src/cli.ts
new file mode 100644
index 00000000..dc95c9c6
--- /dev/null
+++ b/packages/create-bot/src/cli.ts
@@ -0,0 +1,122 @@
+import { intro, note, outro } from "@clack/prompts";
+import { Command } 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 } from "./types.js";
+
+const config = adapters as AdaptersConfig;
+
+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-bot")
+ .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)")
+ .option("-y, --yes", "skip confirmation prompts (accept defaults)")
+ .option("-q, --quiet", "suppress non-essential output")
+ .option("--no-color", "disable color output (respects NO_COLOR)")
+ .addHelpText(
+ "after",
+ [
+ "",
+ "Available adapters:",
+ buildAdapterList(),
+ "",
+ "Examples:",
+ " $ create-bot my-bot",
+ " $ create-bot my-bot -d 'My awesome bot' --adapter slack teams redis",
+ " $ create-bot --adapter discord telegram pg",
+ "",
+ ].join("\n")
+ )
+ .action(
+ async (
+ name?: string,
+ opts?: {
+ description?: string;
+ adapter?: string[];
+ pm?: string;
+ yes?: boolean;
+ quiet?: boolean;
+ }
+ ) => {
+ const quiet = opts?.quiet ?? false;
+ const yes = opts?.yes ?? false;
+ const pm = opts?.pm as PackageManager | undefined;
+
+ if (!quiet) {
+ console.log();
+ intro(pc.bgCyan(pc.black(" create-bot ")));
+ }
+
+ const result = await runPrompts(
+ config,
+ name,
+ opts?.description,
+ opts?.adapter,
+ pm,
+ yes,
+ quiet
+ );
+ if (!result) {
+ if (!quiet) {
+ outro(pc.gray("Cancelled."));
+ }
+ process.exit(0);
+ }
+
+ 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.`
+ );
+ }
+ }
+ );
+
+ return prog;
+}
diff --git a/packages/create-bot/src/index.ts b/packages/create-bot/src/index.ts
new file mode 100644
index 00000000..ba346009
--- /dev/null
+++ b/packages/create-bot/src/index.ts
@@ -0,0 +1,3 @@
+import { createProgram } from "./cli.js";
+
+createProgram().parse();
diff --git a/packages/create-bot/src/prompts.test.ts b/packages/create-bot/src/prompts.test.ts
new file mode 100644
index 00000000..8c6ae208
--- /dev/null
+++ b/packages/create-bot/src/prompts.test.ts
@@ -0,0 +1,426 @@
+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 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("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,
+ true
+ );
+ expect(result?.shouldInstall).toBe(true);
+ expect(confirm).not.toHaveBeenCalled();
+ });
+
+ it("warns on unknown adapter flags", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(select).mockResolvedValueOnce("memory");
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ await runPrompts(adapters, undefined, undefined, ["slack", "bogus"]);
+ expect(log.warn).toHaveBeenCalledWith("Unknown adapter(s): bogus");
+ });
+
+ it("warns on multiple state adapters and uses last", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(groupMultiselect).mockResolvedValueOnce(["slack"]);
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ const result = await runPrompts(adapters, undefined, undefined, [
+ "memory",
+ "redis",
+ ]);
+ expect(log.warn).toHaveBeenCalledWith(
+ 'Multiple state adapters passed; using "redis"'
+ );
+ expect(result?.stateAdapter).toEqual(redisState);
+ });
+
+ 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,
+ 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,
+ false,
+ true
+ );
+ expect(log.info).not.toHaveBeenCalled();
+ });
+
+ it("still prompts state when only platforms flagged", async () => {
+ vi.mocked(text)
+ .mockResolvedValueOnce("my-bot")
+ .mockResolvedValueOnce("desc");
+ vi.mocked(select).mockResolvedValueOnce("redis");
+ vi.mocked(confirm).mockResolvedValueOnce(true);
+
+ const result = await runPrompts(adapters, undefined, undefined, [
+ "slack",
+ ]);
+ expect(select).toHaveBeenCalled();
+ 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("Invalid package name");
+ });
+
+ 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?.("@scope-name")).toBeUndefined();
+ });
+ });
+
+ 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-bot/src/prompts.ts b/packages/create-bot/src/prompts.ts
new file mode 100644
index 00000000..36e899bc
--- /dev/null
+++ b/packages/create-bot/src/prompts.ts
@@ -0,0 +1,189 @@
+import {
+ confirm,
+ groupMultiselect,
+ isCancel,
+ log,
+ select,
+ text,
+} from "@clack/prompts";
+import type {
+ AdaptersConfig,
+ PackageManager,
+ PlatformAdapter,
+ ProjectConfig,
+ StateAdapter,
+} from "./types.js";
+
+const VALID_PKG_NAME = /^[a-z0-9@._-]+$/i;
+
+function resolveAdapterFlags(adapters: AdaptersConfig, values: string[]) {
+ const platforms: PlatformAdapter[] = [];
+ let state: StateAdapter | undefined;
+ const unknown: string[] = [];
+
+ for (const v of values) {
+ const platform = adapters.platformAdapters.find((a) => a.value === v);
+ if (platform) {
+ platforms.push(platform);
+ continue;
+ }
+ const s = adapters.stateAdapters.find((a) => a.value === v);
+ if (s) {
+ if (state) {
+ log.warn(`Multiple state adapters passed; using "${s.value}"`);
+ }
+ state = s;
+ continue;
+ }
+ unknown.push(v);
+ }
+
+ if (unknown.length > 0) {
+ log.warn(`Unknown adapter(s): ${unknown.join(", ")}`);
+ }
+
+ return { platforms, state };
+}
+
+export async function runPrompts(
+ adapters: AdaptersConfig,
+ initialName?: string,
+ initialDescription?: string,
+ initialAdapters?: string[],
+ initialPm?: PackageManager,
+ yes = false,
+ quiet = false
+): Promise {
+ const name =
+ initialName ??
+ (await text({
+ message: "Project name:",
+ placeholder: "my-bot",
+ validate: (value) => {
+ if (!value.trim()) {
+ return "Project name is required";
+ }
+ if (!VALID_PKG_NAME.test(value)) {
+ return "Invalid package name";
+ }
+ },
+ }));
+ if (isCancel(name)) {
+ return null;
+ }
+
+ let description: string;
+
+ if (initialDescription != null) {
+ description = initialDescription;
+ } 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?.platforms.length) {
+ selectedPlatforms = flagged.platforms;
+ if (!quiet) {
+ log.info(
+ `Platform adapters: ${selectedPlatforms.map((a) => a.name).join(", ")}`
+ );
+ }
+ } 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 {
+ 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 = true;
+
+ if (!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-bot/src/scaffold.test.ts b/packages/create-bot/src/scaffold.test.ts
new file mode 100644
index 00000000..e16ec5e0
--- /dev/null
+++ b/packages/create-bot/src/scaffold.test.ts
@@ -0,0 +1,357 @@
+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-bot-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("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("calls npm pkg set with name and adapter deps", async () => {
+ await scaffold(makeConfig(), true, true);
+ expect(execa).toHaveBeenCalledWith(
+ "npm",
+ expect.arrayContaining([
+ "pkg",
+ "set",
+ "name=test-project",
+ "dependencies.@chat-adapter/slack=latest",
+ "dependencies.@chat-adapter/state-memory=latest",
+ ]),
+ expect.objectContaining({ cwd: projectDir() })
+ );
+ });
+
+ it("includes description when provided", async () => {
+ await scaffold(makeConfig({ description: "My bot" }), true, true);
+ expect(execa).toHaveBeenCalledWith(
+ "npm",
+ expect.arrayContaining(["description=My bot"]),
+ expect.anything()
+ );
+ });
+
+ it("omits description when empty", async () => {
+ await scaffold(makeConfig({ description: "" }), true, true);
+ const args = vi.mocked(execa).mock.calls[0]?.[1] as string[];
+ expect(args.some((a) => a.startsWith("description="))).toBe(false);
+ });
+ });
+
+ 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("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)
+ .mockResolvedValueOnce(undefined as never)
+ .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-bot/src/scaffold.ts b/packages/create-bot/src/scaffold.ts
new file mode 100644
index 00000000..c869b04d
--- /dev/null
+++ b/packages/create-bot/src/scaffold.ts
@@ -0,0 +1,139 @@
+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 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);
+
+ const pkgArgs = [`name=${config.name}`];
+ if (config.description) {
+ pkgArgs.push(`description=${config.description}`);
+ }
+ for (const adapter of config.platformAdapters) {
+ pkgArgs.push(`dependencies.${adapter.package}=latest`);
+ }
+ pkgArgs.push(`dependencies.${config.stateAdapter.package}=latest`);
+ await execa("npm", ["pkg", "set", ...pkgArgs], { cwd: projectDir });
+
+ 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-bot/src/templates.test.ts b/packages/create-bot/src/templates.test.ts
new file mode 100644
index 00000000..ba79a9a6
--- /dev/null
+++ b/packages/create-bot/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-bot/src/templates.ts b/packages/create-bot/src/templates.ts
new file mode 100644
index 00000000..224f4539
--- /dev/null
+++ b/packages/create-bot/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-bot/src/types.ts b/packages/create-bot/src/types.ts
new file mode 100644
index 00000000..57236d1f
--- /dev/null
+++ b/packages/create-bot/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-bot/tsconfig.json b/packages/create-bot/tsconfig.json
new file mode 100644
index 00000000..e00f90b7
--- /dev/null
+++ b/packages/create-bot/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-bot/tsup.config.ts b/packages/create-bot/tsup.config.ts
new file mode 100644
index 00000000..959f4f33
--- /dev/null
+++ b/packages/create-bot/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-bot/vitest.config.ts b/packages/create-bot/vitest.config.ts
new file mode 100644
index 00000000..c34af693
--- /dev/null
+++ b/packages/create-bot/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/packages/integration-tests/src/chat-skill-test-utils.ts b/packages/integration-tests/src/chat-skill-test-utils.ts
index 62e937fa..9b4cf2ad 100644
--- a/packages/integration-tests/src/chat-skill-test-utils.ts
+++ b/packages/integration-tests/src/chat-skill-test-utils.ts
@@ -7,7 +7,7 @@ import {
rmSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
-import { join, relative } from "node:path";
+import { join } from "node:path";
import {
ADAPTERS_JSON_PATH,
CHAT_SKILL_PATH,
@@ -42,32 +42,6 @@ function hasPackageName(
return typeof entry.packageName === "string" && entry.packageName.length > 0;
}
-export function isCommunityEntry(
- entry: AdapterCatalogEntry
-): entry is AdapterCatalogEntry & { packageName: string } {
- return entry.community === true && hasPackageName(entry);
-}
-
-export function isOfficialPlatformEntry(
- entry: AdapterCatalogEntry
-): entry is AdapterCatalogEntry & { packageName: string } {
- return (
- entry.type === "platform" &&
- !(entry.community || entry.comingSoon) &&
- hasPackageName(entry)
- );
-}
-
-export function isOfficialStateEntry(
- entry: AdapterCatalogEntry
-): entry is AdapterCatalogEntry & { packageName: string } {
- return (
- entry.type === "state" &&
- !(entry.community || entry.comingSoon) &&
- hasPackageName(entry)
- );
-}
-
export function isOfficialCatalogEntry(
entry: AdapterCatalogEntry
): entry is AdapterCatalogEntry & { packageName: string } {
@@ -122,51 +96,6 @@ export function cleanupPackArtifacts(): void {
rmSync(PACK_DEST_ROOT, { recursive: true, force: true });
}
-export function extractSection(markdown: string, heading: string): string {
- const lines = markdown.split("\n");
- const headingLine = `### ${heading}`;
- const startIndex = lines.findIndex((line) => line.trim() === headingLine);
-
- invariant(startIndex !== -1, `Missing section "${heading}" in SKILL.md`);
-
- const sectionLines: string[] = [];
- for (let i = startIndex + 1; i < lines.length; i++) {
- const line = lines[i];
- if (line.startsWith("### ") || line.startsWith("## ")) {
- break;
- }
- sectionLines.push(line);
- }
-
- return sectionLines.join("\n").trim();
-}
-
-export function extractMarkdownTableRows(section: string): string[][] {
- const tableLines = section
- .split("\n")
- .map((line) => line.trim())
- .filter((line) => line.startsWith("|"));
-
- if (tableLines.length < 3) {
- return [];
- }
-
- return tableLines.slice(2).map((line) =>
- line
- .split("|")
- .slice(1, -1)
- .map((cell) => cell.trim().replace(/^`|`$/g, ""))
- );
-}
-
-export function extractBulletItems(section: string): string[] {
- return section
- .split("\n")
- .map((line) => line.trim())
- .filter((line) => line.startsWith("- "))
- .map((line) => line.slice(2).trim().replace(/^`|`$/g, ""));
-}
-
export function extractPublishedPaths(markdown: string): string[] {
const paths = new Set();
@@ -268,36 +197,3 @@ export function getPackedTarballEntries(packageName: string): string[] {
PACKED_TARBALL_ENTRIES.set(packageName, entries);
return entries;
}
-
-export function extractFactoryName(packageName: string): string {
- const packageDir = PACKAGE_DIRS_BY_NAME.get(packageName);
- invariant(packageDir, `Missing local package for ${packageName}`);
-
- const sourcePath = join(packageDir, "src/index.ts");
- invariant(
- existsSync(sourcePath),
- `Expected ${packageName} to have ${relative(REPO_ROOT, sourcePath)}`
- );
-
- const source = readFileSync(sourcePath, "utf-8");
- const factoryNames = [
- ...new Set(
- [...source.matchAll(/export function (create[A-Za-z0-9_]+)\(/g)].map(
- (match) => match[1]
- )
- ),
- ];
-
- invariant(
- factoryNames.length === 1,
- `${packageName} should export exactly one create* factory`
- );
-
- const [factoryName] = factoryNames;
- invariant(
- factoryName,
- `${packageName} should export exactly one create* factory`
- );
-
- return factoryName;
-}
diff --git a/packages/integration-tests/src/chat-skill.test.ts b/packages/integration-tests/src/chat-skill.test.ts
index 8bb06372..a2c27b79 100644
--- a/packages/integration-tests/src/chat-skill.test.ts
+++ b/packages/integration-tests/src/chat-skill.test.ts
@@ -3,17 +3,10 @@ import {
ADAPTER_CATALOG,
CHAT_SKILL,
cleanupPackArtifacts,
- extractBulletItems,
- extractFactoryName,
- extractMarkdownTableRows,
extractPublishedPaths,
- extractSection,
getPackedTarballEntries,
invariant,
- isCommunityEntry,
isOfficialCatalogEntry,
- isOfficialPlatformEntry,
- isOfficialStateEntry,
parsePublishedPath,
} from "./chat-skill-test-utils";
@@ -87,65 +80,7 @@ describe("skills/chat/SKILL.md", () => {
}
});
- it("should list all official platform adapters with correct factories", () => {
- const section = extractSection(CHAT_SKILL, "Official platform adapters");
- const actualRows = extractMarkdownTableRows(section).map(
- ([name, packageName, factory]) => ({
- name,
- packageName,
- factory,
- })
- );
-
- const expectedRows = ADAPTER_CATALOG.filter(isOfficialPlatformEntry).map(
- (entry) => ({
- name: entry.name,
- packageName: entry.packageName,
- factory: extractFactoryName(entry.packageName),
- })
- );
-
- expect(actualRows).toEqual(expectedRows);
- });
-
- it("should list all official state adapters with correct factories", () => {
- const section = extractSection(CHAT_SKILL, "Official state adapters");
- const actualRows = extractMarkdownTableRows(section).map(
- ([name, packageName, factory]) => ({
- name,
- packageName,
- factory,
- })
- );
-
- const expectedRows = ADAPTER_CATALOG.filter(isOfficialStateEntry).map(
- (entry) => ({
- name: entry.name,
- packageName: entry.packageName,
- factory: extractFactoryName(entry.packageName),
- })
- );
-
- expect(actualRows).toEqual(expectedRows);
- });
-
- it("should list all community adapters", () => {
- const section = extractSection(CHAT_SKILL, "Community adapters");
- const actualItems = extractBulletItems(section);
- const expectedItems = ADAPTER_CATALOG.filter(isCommunityEntry).map(
- (entry) => entry.packageName
- );
-
- expect(actualItems).toEqual(expectedItems);
- });
-
- it("should list all coming-soon platform entries", () => {
- const section = extractSection(CHAT_SKILL, "Coming-soon platform entries");
- const actualItems = extractBulletItems(section);
- const expectedItems = ADAPTER_CATALOG.filter(
- (entry) => entry.type === "platform" && entry.comingSoon
- ).map((entry) => entry.name);
-
- expect(actualItems).toEqual(expectedItems);
+ it("should link to the adapters page instead of listing them inline", () => {
+ expect(CHAT_SKILL).toContain("https://chat-sdk.dev/adapters");
});
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 52d206dc..e046d204 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -522,6 +522,37 @@ importers:
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.21.0)
+ packages/create-bot:
+ 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.21.0))
+ tsup:
+ specifier: ^8.3.5
+ version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(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.21.0)
+
packages/integration-tests:
dependencies:
'@chat-adapter/discord':
@@ -867,9 +898,15 @@ packages:
'@chevrotain/utils@11.0.3':
resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==}
+ '@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==}
@@ -2510,6 +2547,9 @@ packages:
resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
+ '@sec-ant/readable-stream@0.4.1':
+ resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
+
'@shikijs/core@3.22.0':
resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==}
@@ -2537,6 +2577,10 @@ packages:
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
+ '@sindresorhus/merge-streams@4.0.0':
+ resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
+ engines: {node: '>=18'}
+
'@slack/logger@4.0.0':
resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==}
engines: {node: '>= 18', npm: '>= 8.6.0'}
@@ -3261,6 +3305,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'}
@@ -3696,6 +3744,10 @@ packages:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
+ execa@9.6.1:
+ resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
+ engines: {node: ^18.19.0 || >=20.5.0}
+
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -3743,6 +3795,10 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
+ figures@6.1.0:
+ resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
+ engines: {node: '>=18'}
+
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -3919,6 +3975,10 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
+ get-stream@9.0.1:
+ resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
+ engines: {node: '>=18'}
+
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
@@ -4062,6 +4122,10 @@ packages:
resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==}
hasBin: true
+ human-signals@8.0.1:
+ resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
+ engines: {node: '>=18.18.0'}
+
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -4148,10 +4212,18 @@ packages:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
+ is-stream@4.0.1:
+ resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
+ engines: {node: '>=18'}
+
is-subdir@1.2.0:
resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==}
engines: {node: '>=4'}
+ is-unicode-supported@2.1.0:
+ resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
+ engines: {node: '>=18'}
+
is-windows@1.0.2:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
@@ -4820,6 +4892,10 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ npm-run-path@6.0.0:
+ resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
+ engines: {node: '>=18'}
+
npm-to-yarn@3.0.1:
resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -4907,6 +4983,10 @@ packages:
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+ parse-ms@4.0.0:
+ resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
+ engines: {node: '>=18'}
+
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
@@ -4928,6 +5008,10 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
+ path-key@4.0.0:
+ resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
+ engines: {node: '>=12'}
+
path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
@@ -5062,6 +5146,10 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
+ pretty-ms@9.3.0:
+ resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
+ engines: {node: '>=18'}
+
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -5471,6 +5559,10 @@ packages:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
+ strip-final-newline@4.0.0:
+ resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
+ engines: {node: '>=18'}
+
strip-json-comments@5.0.3:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
@@ -5691,6 +5783,10 @@ packages:
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
engines: {node: '>=18.17'}
+ unicorn-magic@0.3.0:
+ resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
+ engines: {node: '>=18'}
+
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -5957,6 +6053,10 @@ packages:
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
+ yoctocolors@2.1.2:
+ resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
+ engines: {node: '>=18'}
+
youtube-video-element@1.8.1:
resolution: {integrity: sha512-+5UuAGaj+5AnBf39huLVpy/4dLtR0rmJP1TxOHVZ81bac4ZHFpTtQ4Dz2FAn2GPnfXISezvUEaQoAdFW4hH9Xg==}
@@ -6244,11 +6344,22 @@ snapshots:
'@chevrotain/utils@11.0.3': {}
+ '@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
@@ -7803,6 +7914,8 @@ snapshots:
'@sapphire/snowflake@3.5.3': {}
+ '@sec-ant/readable-stream@0.4.1': {}
+
'@shikijs/core@3.22.0':
dependencies:
'@shikijs/types': 3.22.0
@@ -7850,6 +7963,8 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {}
+ '@sindresorhus/merge-streams@4.0.0': {}
+
'@slack/logger@4.0.0':
dependencies:
'@types/node': 25.3.2
@@ -8535,6 +8650,8 @@ snapshots:
comma-separated-tokens@2.0.3: {}
+ commander@13.1.0: {}
+
commander@14.0.3: {}
commander@4.1.1: {}
@@ -9021,6 +9138,21 @@ snapshots:
eventsource-parser@3.0.6: {}
+ execa@9.6.1:
+ dependencies:
+ '@sindresorhus/merge-streams': 4.0.0
+ cross-spawn: 7.0.6
+ figures: 6.1.0
+ get-stream: 9.0.1
+ human-signals: 8.0.1
+ is-plain-obj: 4.1.0
+ is-stream: 4.0.1
+ npm-run-path: 6.0.0
+ pretty-ms: 9.3.0
+ signal-exit: 4.1.0
+ strip-final-newline: 4.0.0
+ yoctocolors: 2.1.2
+
expect-type@1.3.0: {}
express@5.2.1:
@@ -9093,6 +9225,10 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
+ figures@6.1.0:
+ dependencies:
+ is-unicode-supported: 2.1.0
+
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -9305,6 +9441,11 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
+ get-stream@9.0.1:
+ dependencies:
+ '@sec-ant/readable-stream': 0.4.1
+ is-stream: 4.0.1
+
get-tsconfig@4.13.0:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -9566,6 +9707,8 @@ snapshots:
human-id@4.1.3: {}
+ human-signals@8.0.1: {}
+
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@@ -9637,10 +9780,14 @@ snapshots:
is-stream@2.0.1: {}
+ is-stream@4.0.1: {}
+
is-subdir@1.2.0:
dependencies:
better-path-resolve: 1.0.0
+ is-unicode-supported@2.1.0: {}
+
is-windows@1.0.2: {}
isexe@2.0.0: {}
@@ -10557,6 +10704,11 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
+ npm-run-path@6.0.0:
+ dependencies:
+ path-key: 4.0.0
+ unicorn-magic: 0.3.0
+
npm-to-yarn@3.0.1: {}
nypm@0.6.5:
@@ -10662,6 +10814,8 @@ snapshots:
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
+ parse-ms@4.0.0: {}
+
parse5@7.3.0:
dependencies:
entities: 6.0.1
@@ -10676,6 +10830,8 @@ snapshots:
path-key@3.1.1: {}
+ path-key@4.0.0: {}
+
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
@@ -10793,6 +10949,10 @@ snapshots:
prettier@2.8.8: {}
+ pretty-ms@9.3.0:
+ dependencies:
+ parse-ms: 4.0.0
+
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -11408,6 +11568,8 @@ snapshots:
strip-bom@3.0.0: {}
+ strip-final-newline@4.0.0: {}
+
strip-json-comments@5.0.3: {}
style-to-js@1.1.21:
@@ -11593,6 +11755,8 @@ snapshots:
undici@6.21.3: {}
+ unicorn-magic@0.3.0: {}
+
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@@ -11830,6 +11994,8 @@ snapshots:
yallist@4.0.0: {}
+ yoctocolors@2.1.2: {}
+
youtube-video-element@1.8.1: {}
zod@4.3.3: {}
diff --git a/skills/chat/SKILL.md b/skills/chat/SKILL.md
index 349d9b9c..9f09f3a9 100644
--- a/skills/chat/SKILL.md
+++ b/skills/chat/SKILL.md
@@ -7,16 +7,44 @@ description: >
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.
+ (5) Build or maintain a custom adapter or state adapter,
+ (6) Scaffold a new bot project with create-bot.
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", and building bots that work across multiple chat platforms.
+ "state adapter", "build adapter", "create-bot", "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-bot` CLI scaffolds a complete Next.js bot project with adapters, environment variables, and a webhook route:
+
+```bash
+npx create-bot my-bot
+```
+
+It supports non-interactive usage for CI or scripting:
+
+```bash
+npx create-bot my-bot --adapter slack discord --adapter redis -d "My bot" --pm pnpm -y
+```
+
+Key flags: `--adapter ` (platform or state adapters), `-d ` (description), `--pm ` (npm/yarn/pnpm/bun), `-y` (skip confirmations), `-q` (quiet output). Run `npx create-bot --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`:
@@ -32,6 +60,7 @@ 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
@@ -90,21 +119,23 @@ bot.onSubscribedMessage(async (thread, message) => {
## 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 |
+
+| 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`.
@@ -124,6 +155,7 @@ bot.onNewMention(async (thread, message) => {
```
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
@@ -133,9 +165,11 @@ Key details:
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
@@ -152,56 +186,18 @@ await thread.post(
## Adapter inventory
-### Official platform adapters
-
-| Platform | Package | Factory |
-|---------|---------|---------|
-| Slack | `@chat-adapter/slack` | `createSlackAdapter` |
-| Microsoft Teams | `@chat-adapter/teams` | `createTeamsAdapter` |
-| Google Chat | `@chat-adapter/gchat` | `createGoogleChatAdapter` |
-| Discord | `@chat-adapter/discord` | `createDiscordAdapter` |
-| GitHub | `@chat-adapter/github` | `createGitHubAdapter` |
-| Linear | `@chat-adapter/linear` | `createLinearAdapter` |
-| Telegram | `@chat-adapter/telegram` | `createTelegramAdapter` |
-| WhatsApp Business Cloud | `@chat-adapter/whatsapp` | `createWhatsAppAdapter` |
-
-### Official state adapters
-
-| State backend | Package | Factory |
-|--------------|---------|---------|
-| Redis | `@chat-adapter/state-redis` | `createRedisState` |
-| ioredis | `@chat-adapter/state-ioredis` | `createIoRedisState` |
-| PostgreSQL | `@chat-adapter/state-pg` | `createPostgresState` |
-| Memory | `@chat-adapter/state-memory` | `createMemoryState` |
-
-### Community adapters
-
-- `chat-state-cloudflare-do`
-- `@beeper/chat-adapter-matrix`
-- `chat-adapter-imessage`
-- `@bitbasti/chat-adapter-webex`
-- `@resend/chat-sdk-adapter`
-- `@zernio/chat-sdk-adapter`
-- `chat-adapter-baileys`
-- `@liveblocks/chat-sdk-adapter`
-- `chat-adapter-sendblue`
-- `chat-adapter-zalo`
-
-### Coming-soon platform entries
-
-- Instagram
-- Signal
-- X
-- Messenger
+For the full list of official, vendor-official, and community adapters (platform and state), see [https://chat-sdk.dev/adapters](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
@@ -210,4 +206,4 @@ A custom adapter needs request verification, webhook parsing, message/thread/cha
## 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.
+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.
\ No newline at end of file