# App config
Source: https://docs.waniwani.ai/kit/app-config
waniwani.config.ts names the MCP server, hands the host an overview at connect time, and tunes the template's search tool and tracking.
Every kit app has one `waniwani.config.ts` at its root, default-exporting `defineApp({ ... })`. The CLI reads it to name the server, and the runtime forwards the rest to the host and to the SDK.
```ts waniwani.config.ts theme={null}
import { defineApp } from "@waniwani/kit";
export default defineApp({
name: "oney",
title: "Oney: split your payment",
overview: `You help shoppers split a purchase into instalments with Oney.
RULES:
- Never quote a monthly amount yourself. Call check-eligibility and let it do the arithmetic.
- Never list the plans in text. Show the select-plan widget and let it render them.`,
});
```
## Options
The MCP server name, e.g. `oney-split-payment`. Hosts show `title` to humans and use this one as the id.
Shown to humans in connector UIs.
Defaults to the `version` in the app's `package.json`.
What this app is and how its tools fit together, handed to the host LLM once in the `initialize` handshake: which tool to reach for, what order things happen in, how to read what comes back, tone, guardrails. Reaches the wire as the MCP server's `instructions`.
How a single tool behaves belongs in that tool's own `description`. A description travels with every `tools/list` and reaches the model at the moment it is choosing that tool. The overview is read once at connect, so a client that connected before an edit keeps the old copy until it reconnects, and a host is free to drop it altogether. Put nothing load-bearing in it.
Tune, or decline, the `search` tool the distribution template ships on top of the [knowledge base](/sdk/knowledge-base/overview).
Whether the template registers the tool at all. `false` is the only way an app can decline it. A deployment with no corpus behind it is better off without the tool than with one answering confidently out of the wrong file.
Passages to ask for, 1 to 20.
Similarity floor, 0 to 1, under which a passage is dropped rather than ranked last.
Exact-match filter on chunk metadata. A passage must carry all of these pairs to come back.
Give up on a slow search and answer as though nothing matched.
Name the source document on each passage.
Framing prepended to the answer text. Retrieved passages are third-party text on their way into a prompt; this is where an app says they are reference material rather than instructions.
The whole answer when no passage comes back: nothing matched, the search outran `timeoutMs`, or the knowledge base failed. An app whose corpus carries regulated information should write both halves in its own words, the refusal the user reads and the human to go to instead. `preamble` is not applied on top of it.
Status text the host shows while the call is in flight.
Status text the host shows once the call has returned.
Forwarded whole to the SDK's [`withWaniwani()`](/sdk/configuration/wrap-server). Set `WANIWANI_API_KEY` in the app's `.env` for events to reach the platform; without it tracking is a no-op.
One category for every tool, or a function mapping a tool name to a category. Categories: `pricing`, `product_info`, `availability`, `support`, `other`.
Merged into every tracked event.
Flush the tracking transport after each tool call. This is the one that matters on serverless: an invocation frozen between tool calls takes any unsent batch with it.
Put widget tracking config in each tool response's `_meta.waniwani`, so a widget in the browser can send its own events.
Field names to strip from location `_meta` before events are sent. Pass `["latitude", "longitude"]` to drop coordinates and keep the rest.
Replace flow state fields marked with `redacted()` before they are tracked. Wire it to an env var to keep real values in development and redact in production.
## Where each value ends up
| Option | Reaches |
| ----------------- | ----------------------------------------------------------------- |
| `name`, `version` | the `McpServer` constructor |
| `title` | `serverInfo.title`, shown by connector UIs |
| `overview` | `instructions` in the `initialize` response |
| `search` | the template's `search` tool, through the generated `waniwani.ts` |
| `tracking` | `withWaniwani()` around the server |
# Commands
Source: https://docs.waniwani.ai/kit/commands
The six waniwani commands, the four stages every one of them runs (scan, check, codegen, run), and what the build check catches before a request does.
```bash theme={null}
waniwani init [dir] # scaffold an app folder, install, ready to dev
waniwani check # validate the folder
waniwani dev # generate + dev server + regenerate on change
waniwani build # generate + production build
waniwani start # run the production build
waniwani eject [--out dir] # hand the plumbing over and step out
```
Running `waniwani` with no command is `dev`. The scaffolded `package.json` maps `check`, `dev`, `build` and `start` to npm scripts, so `npm run dev` and `waniwani dev` are the same thing.
`init` writes files and stops there; its prompts and flags are on the [Quickstart](/kit/quickstart#what-init-asks). Every other command runs the same four stages before doing its own work.
## The four stages
```mermaid theme={null}
flowchart LR
subgraph app["oney/ (what you own)"]
cfg["waniwani.config.ts"]
tools["tools/*.ts"]
widgets["widgets/<name>/ widget.ts + ui.tsx"]
flows["flows/*.ts"]
api["api/**/*.ts"]
wk["well-known/**/*.ts"]
end
subgraph tpl["WaniWani-AI/mcp-distribution-template (public, separate repo)"]
raw["vite.config.ts · package.json · tsconfig.json src/index.css (Tailwind) alpic.json · Dockerfile"]
end
subgraph cli["@waniwani/kit (what we own)"]
scan["scan convention → manifest"]
check["check fail at build time"]
gen["codegen emit a real project"]
runtime["src/server.ts registerApp()"]
end
subgraph out[".waniwani/ (build output, disposable)"]
server["src/server.ts · src/waniwani.ts"]
views["src/views/*.tsx"]
appsrc["src/app/ (your source, copied)"]
deployfiles["Dockerfile · alpic.json"]
end
app --> scan --> check --> gen --> out
runtime -.imported by.-> server
raw -.fetched at a pinned SHA, copied byte for byte.-> deployfiles
out --> deploy["dev · build · start"]
app -.waniwani eject.-> ejected["a plain repo no CLI, no @waniwani/kit"]
```
1. **scan** walks the folder and turns convention into a manifest.
2. **check** validates structure from the filesystem, then imports every server-safe module for real.
3. **codegen** resolves the distribution template at a pinned commit, copies its plumbing byte for byte, generates registration and view entries from the manifest, and copies your source under `src/app/`.
4. **run** hands the result to the framework's `dev`, `build` or `start`, with the output rewritten in Waniwani's voice.
`.waniwani/` is disposable and safe to delete. Keep it out of git, the way `.next/` is; `init` writes that line into `.gitignore` for you.
## dev
`dev` watches the folder, mirrors changes into `.waniwani/`, and leaves nodemon and Vite HMR to do the rest. An edit to a tool reaches the MCP endpoint in about a second. The MCP endpoint is `/mcp` on the dev port, and the dev server's root serves the framework's own page for calling tools without a chat client.
To reach the dev server from ChatGPT or Claude, expose it with a [tunnel](/sdk/guides/tunnel), or bind the repo to a hosted agent with [`@waniwani/cli`](/sdk/cli/overview) and run against the hosted playground.
## What the build check catches
Errors that would otherwise surface as a 500 at request time, or as a widget that silently never renders:
```text theme={null}
✗ Build check failed
widgets/broken
└ missing widget.ts
every widget folder needs a widget.ts with `export default defineWidget({ ... })`
flows/split-payment.ts
└ showWidget references the widget "select-plans", which does not exist
known widgets: broken, select-plan
```
Structure comes from the filesystem. The rest comes from importing every server-safe module, so a flow that fails to compile fails the build, as does a missing default export, a tool with no description, or a runtime configuration mistake:
```text theme={null}
flows/no-store.ts
└ failed to load
[waniwani] createFlow "no_store": no flow store configured. …
```
`check` reads the app's `.env` before importing anything, for the same reason `dev` does: a flow whose store comes from `WANIWANI_API_KEY` would otherwise fail its own check over a variable sitting in the file next to it.
## Environment variables the CLI reads
| Variable | Effect |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `WANIWANI_DEBUG=1` | Print the CLI's own diagnostics: which template was resolved, how many files it copied, stack traces. |
| `WANIWANI_OFFLINE=1` | Skip the npm lookup `init` and `check` make for the newest SDK; see [Flows](/kit/flows#which-sdk-version-an-app-gets). |
| `WANIWANI_TEMPLATE=` | Override the distribution template for one command, with a branch ref or a local checkout. `--template ` does the same per invocation. |
Variables for the app itself, such as `WANIWANI_API_KEY`, go in the app's `.env`; see [Deploy](/kit/deploy#secrets-and-environment-variables).
# Deploy
Source: https://docs.waniwani.ai/kit/deploy
Deploying a kit app is a git push. Vercel needs a four-line vercel.json, Docker and Alpic read their config from the build, and secrets come from the app's .env or the platform.
`waniwani init` asks where the app deploys, because the answer decides the one config file the repo carries:
```text theme={null}
◆ Where will this deploy?
│ ● Vercel (git push, or `vercel deploy --prebuilt`)
│ ○ Docker
│ ○ Alpic
│ ○ I don't know yet
│ ↑/↓ to navigate • Enter: confirm
└
```
Pass `--host vercel`, `--host container`, `--host alpic` or `--host none` to answer ahead of time.
## Vercel
Only Vercel leaves anything behind, and it is four lines:
```json vercel.json theme={null}
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": null
}
```
`framework: null` selects the `Other` preset. That one key is the only thing a repo cannot say any other way: the preset is a project setting Vercel resolves *before* the build command runs, so a project whose dashboard says `Next.js` or `Express` fails on the preset and never reaches the build. `Other` is what runs the `build` script and adopts what the build produced.
```text theme={null}
Error: No Next.js version detected.
```
Nothing else belongs in that file. `waniwani build` writes a Build Output tree inside `.waniwani/` (the bundled function, the static assets, the routing config) and the build's last step moves it to `.vercel/output` at the app root, the one path where Vercel adopts one. A `buildCommand` would restate the `build` script that already runs, and a `routes` table would duplicate routing the build writes. Both go stale against a kit that moved on; `framework: null` is a fact about the project, so it never changes.
```bash theme={null}
git push # a git-connected project builds and serves it
vercel deploy --prebuilt # or upload the tree a local build produced
```
A prebuilt deploy skips the preset question entirely, since it uploads the tree and asks Vercel to build nothing.
One thing the kit decides on the app's behalf, in that tree's own routing table:
```json theme={null}
{ "src": "/api(/.*)?", "dest": "/mcp" }
```
Vercel reserves a root `api/` directory. It compiles every file under one into a serverless function of its own, and an endpoint module is not a Vercel handler, since `defineEndpoint({ ... })` is an object. The reservation cannot be waived, because the file list is read before the build command runs:
```text theme={null}
Error: File not found: /vercel/path0/api/cal/book.ts
```
So the route goes in ahead of the tree's `filesystem` handler, which is the phase those functions sit in. `/api/*` reaches the server the kit built, and the ones Vercel made are never routed to. They still cost build time, two dead functions per app.
An app carrying a `vercel.json` from an earlier setup has to lose everything in it but `framework`. A `buildCommand` that stages the tree by hand deletes what the build just placed. `waniwani check` names the keys that fight the build.
## Docker
The build writes a `Dockerfile` and `.dockerignore` into `.waniwani/`, so the image is built from there:
```bash theme={null}
waniwani build
docker build .waniwani
```
## Alpic
`alpic.json` also comes out of the build:
```bash theme={null}
waniwani build
cd .waniwani && alpic deploy
```
## Secrets and environment variables
`.env` and `.env.local` sit next to `waniwani.config.ts`, and every command reads them before it runs anything. A variable already exported in the shell or set by CI wins over both files, and a hosted deploy sets its variables on the platform and reads no file at all.
```bash .env theme={null}
# Optional. Without it the app still runs: flows use MemoryKvStore and
# withWaniwani degrades to a no-op. With it, flow state is hosted and tracking
# reaches app.waniwani.ai.
WANIWANI_API_KEY=
```
Loading them this early is what lets a module build its client at import time:
```ts lib/waniwani.ts theme={null}
export const wani = waniwani({ apiKey: process.env.WANIWANI_API_KEY });
```
The generated project runs from `.waniwani/`, one level below the file, and a module's imports are evaluated before any line of the module that pulled it in, so neither `dotenv/config` nor a load inside generated code arrives in time. `waniwani check` reads the same files for the same reason.
On a hosted deploy, environment variables live on the platform. A Vercel project that sets its variables for production alone gets previews with none, which for an app whose flow reads `WANIWANI_API_KEY` at import time means a function that fails to boot. Set them for every environment the project serves.
Get an API key from the [Waniwani dashboard](/sdk/configuration/api-key). What the key turns on is described under [Waniwani Platform](/sdk/platform/overview).
# Eject
Source: https://docs.waniwani.ai/kit/eject
waniwani eject writes the server, the bundler config and the deploy files into your repo, rewrites the imports, and removes @waniwani/kit from the dependencies. One way only.
`waniwani eject` writes the plumbing into the repo itself and leaves. What comes out is the same tree a build was producing all along, with your source moved under `src/app/`:
```text theme={null}
oney/
├── src/app/{tools,widgets,flows,lib}/ your code, moved
├── src/_runtime/ the runtime, vendored as source
├── src/{server,waniwani}.ts entry and registration
├── src/views/.tsx view entries
├── vite.config.ts alpic.json vercel.json bundling and deploy
├── Dockerfile .dockerignore container deploy
└── tsconfig.json package.json
```
Every `@waniwani/kit` import gets rewritten to `./_runtime/…`, and the dependency drops out of `package.json`. From then on the repo runs on Skybridge's own CLI (`dev`, `build`, `start`) with no Waniwani in the loop.
```bash theme={null}
waniwani eject # in place
waniwani eject --out ../oney-ejected
```
Ejecting in place moves the files instead of copying them, so the originals go once the copy is on disk and the repo is never left holding two versions of a file that can drift. `--out ` leaves the source repo untouched. Either way the CLI prints what moved. Eject refuses to overwrite existing plumbing unless you pass `--force`, and it runs one way, with nothing to turn an ejected repo back.
## Why the source moves under src/
Your source has to move under `src/` for the compiled server to land where Skybridge's entry wrapper looks for it. `tsc` derives `rootDir` from the widest common ancestor of the files it compiles, so source sitting beside `src/` rather than inside it pushes every emitted path down a level and the wrapper's import misses.
## The trade eject makes
What an ejected repo gives up is the generator, and with it:
* **the build check**, so `showWidget("typo")` becomes a runtime failure again
* **name-from-filesystem**, so adding a widget means editing `src/waniwani.ts` and adding an entry under `src/views/`
* **runtime fixes**, since `src/_runtime/` is a fork from the moment it lands
# Endpoints
Source: https://docs.waniwani.ai/kit/endpoints
api/ holds HTTP endpoints the widget calls from the browser, invisible to the model. well-known/ serves the same kind of handler at the root of the domain.
A widget runs in an iframe on another origin, and it can call its own server without going through the model at all. Booking a slot, loading a calendar, looking up a price, receiving a webhook: `api/` is where those live. The path comes from the file's position, the folder name included, so there is nothing to keep in step with the `fetch()` on the other side:
```text theme={null}
api/cal/slots.ts → /api/cal/slots
api/webhooks/stripe.ts → /api/webhooks/stripe
api/cal/index.ts → /api/cal
```
```ts api/cal/slots.ts theme={null}
import { defineEndpoint } from "@waniwani/kit";
import { fetchCalSlots } from "../../lib/cal.js";
export default defineEndpoint({
method: "post",
handler: async (req, res) => {
const { timeZone } = req.body;
res.json({ slots: await fetchCalSlots(regionFor(timeZone)) });
},
});
```
The widget reaches it at the origin the host hands the view, which is the dev port locally and the deployed origin inside ChatGPT or Claude:
```tsx theme={null}
const apiUrl = (path: string) => `${window.skybridge?.serverUrl ?? ""}${path}`;
const response = await fetch(apiUrl("/api/cal/slots"), { method: "POST", body });
```
## What the runtime adds
Four things arrive with every endpoint, so no app writes them:
| | What the runtime does |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **CORS** | on by default, preflight included, advertising the methods `method` declares and no others |
| **JSON body** | `express.json()`, because the framework installs no parser of its own and `req.body` would be `undefined` |
| **method guard** | anything outside `method` gets a 405 and an `Allow` header, instead of reaching a handler written for a POST |
| **errors** | a handler that throws answers JSON with the message, logged as `[waniwani] endpoint "/api/..." failed`, so a `fetch()` waiting on JSON never receives an HTML error page |
Restrict the endpoint to these methods. Leaving it off accepts every method.
`false` opts out of CORS.
`false` opts out of the JSON body parser.
An Express handler. Throwing is safe: the runtime answers 500 and logs.
**Reach for a tool instead when the model is the caller.** An endpoint never shows up in `tools/list` and the model cannot see that it was called, so it costs the conversation nothing. That suits a calendar the widget paints for itself, and rules it out for anything the model has to reason about or quote back. See [Tools](/kit/tools).
Endpoints share the process with `/mcp`, so `lib/` is one set of modules for both, and the build check prints what it mounted:
```text theme={null}
✓ Build check passed — 1 widget, 1 flow, 3 endpoints
widget show-book-call
flow demo-qualification
api /api/cal/book
api /api/cal/slots
well-known /.well-known/openai-apps-challenge
```
## well-known/ is for the root of the domain
Some paths are not the app's to name. `/.well-known/openai-apps-challenge` proves to OpenAI that a deployment is yours; `security.txt` and `apple-app-site-association` answer to standards of their own. Whoever reads them looks at the root of the domain or nowhere, so `/api/` is not an option and neither is a config key.
`well-known/` is `api/` with a different prefix. It takes the same `defineEndpoint`, gets CORS, the method guard and the error envelope from the runtime, and the path still comes from the file's position:
```text theme={null}
well-known/openai-apps-challenge.ts → /.well-known/openai-apps-challenge
well-known/security.txt.ts → /.well-known/security.txt
```
The folder on disk has no dot. npm strips a `.`-prefixed directory out of a published tarball and the generator treats dotfiles as tooling rather than source, so a literal `.well-known/` would never reach the build. The dot goes back on when the URL is built.
A handler runs per request, which is what a value that differs per environment needs:
```ts well-known/openai-apps-challenge.ts theme={null}
import { defineEndpoint } from "@waniwani/kit";
export default defineEndpoint({
method: "get",
handler: (_req, res) => {
const token = process.env.OPENAI_APPS_CHALLENGE_TOKEN;
if (!token) return void res.status(404).json({ error: "not configured" });
res.type("text/plain").send(token);
},
});
```
The verifier compares the body byte for byte, and `res.send(string)` on its own would label it `text/html`, hence `res.type("text/plain")`. A 404 when the token is unset makes an unconfigured environment look unclaimed.
Two names are refused: `oauth-authorization-server` and `oauth-protected-resource`. The framework serves those itself once an app configures OAuth, and an app's endpoints mount ahead of the framework's, so a file at either one would answer a client's discovery request with a body it cannot use. `waniwani check` says so by name.
On Vercel this needs no routing of its own. The reservation that forces a route for `/api/*` does not exist under `.well-known`, and the build writes no static file there, so the request misses the filesystem phase and the catch-all already in the tree carries it to the server. See [Deploy](/kit/deploy).
# Flows
Source: https://docs.waniwani.ai/kit/flows
A flow in a kit app is an @waniwani/sdk flow used unchanged. Default-export createFlow(...).compile() from flows/ and the runtime registers it as a tool.
A flow is an SDK primitive used unchanged. `createFlow(...).compile()` returns something the runtime registers directly, so everything the [SDK documents about flows](/sdk/flows/overview) applies here as written.
```ts flows/split-payment.ts theme={null}
import { createFlow, END, MemoryKvStore, START } from "@waniwani/sdk/mcp";
export default createFlow({ id: "split_payment", title, description, state })
.addNode({
id: "ask_amount",
run: ({ interrupt }) => interrupt({ amount: { question: "How much is the basket?" } }),
})
.addNode({
id: "show_plans",
run: ({ state, showWidget }) =>
showWidget({ tool: "select-plan", field: "selectedPlanId", data: { /* … */ } }),
})
.addEdge(START, "ask_amount")
.addEdge("ask_amount", "show_plans")
.addEdge("show_plans", END)
.compile({ store: new MemoryKvStore() });
```
The tool name comes from the flow's `id`, so this one registers as `split_payment`. The filename only has to sit under `flows/`.
`showWidget({ tool: "select-plan" })` names a [widget](/kit/widgets) by its folder name, and the build check verifies that the folder exists. A typo fails `waniwani check` with the list of known widgets rather than a runtime error in a conversation.
## The store
`compile()` needs a store. `MemoryKvStore` keeps state in the process and is enough for local development. For production, pass one of the [KV store adapters](/sdk/flows/kv-store), or set `WANIWANI_API_KEY` in the app's `.env` to use the platform's hosted state. The env file is loaded before any module is imported, so a store built from `process.env` at the top of the file works; see [Deploy](/kit/deploy#secrets-and-environment-variables).
A flow whose store is missing fails the build check with the SDK's own message:
```text theme={null}
flows/no-store.ts
└ failed to load
[waniwani] createFlow "no_store": no flow store configured. …
```
## Which SDK version an app gets
`@waniwani/sdk` is a peer dependency. The app's own `package.json` names the version, and the kit states only the floor underneath it, so an app that upgrades keeps that choice through every build. Nothing rewrites the range.
`waniwani init` writes the newest published SDK it can reach, capped with a caret, and falls back to the declared floor when npm is unreachable. Set `WANIWANI_OFFLINE=1` to skip the lookup entirely.
The SDK is 0.x, where a caret stops at the next minor. `^0.20.0` picks up 0.20.1 on the next install and never crosses to 0.21 on its own. When a newer minor is published, `waniwani check` says so and names the one-line edit. Taking it is the app's call, since under 0.x a minor is a breaking change.
## Further reading
Nodes, edges, interrupts and state, in the SDK docs.
Complete `createFlow` recipes: sales, lead generation, booking, quotes.
# Folder convention
Source: https://docs.waniwani.ai/kit/folder-convention
Which folders @waniwani/kit reads, what each one becomes on the MCP server, and how tool names are derived from the filesystem.
```text theme={null}
oney/
├── waniwani.config.ts defineApp({ name, title, overview })
├── tools/
│ └── check-eligibility.ts export default defineTool({ ..., run })
├── widgets/
│ └── select-plan/
│ ├── widget.ts export default defineWidget({ ..., data })
│ └── ui.tsx export default function Component()
├── flows/
│ └── split-payment.ts export default createFlow(...).compile() ← SDK
├── api/
│ └── cal/slots.ts export default defineEndpoint({ ..., handler })
├── well-known/
│ └── openai-apps-challenge.ts ditto, served at the root of the domain
└── lib/ anything else is just modules
```
Names come from the filesystem, verbatim. `tools/check-eligibility.ts` registers as `check-eligibility`, and `widgets/select-plan/` registers as `select-plan`. Nothing has to be listed in a registry, so no widget can sit defined and unwired.
| Folder | Becomes | Notes |
| ---------------------- | ----------------------------------------------- | ------------------------------------------------------------------- |
| `tools/.ts` | one MCP tool | `.ts`, `.tsx` and `.mts` are picked up |
| `widgets//` | one MCP tool plus a `ui://` resource | needs `widget.ts` and `ui.tsx` |
| `flows/.ts` | one MCP tool, registered from the SDK unchanged | whatever `.compile()` returns |
| `api/.ts` | one HTTP endpoint at `/api/` | for the browser, invisible to the model |
| `well-known/.ts` | the same endpoint at `/.well-known/` | for whoever asked the app to prove itself; the folder loses its dot |
| anything else | plain modules | the CLI leaves it alone |
The app folder imports `@waniwani/kit`, plus `@waniwani/sdk` when it uses flows, and nothing else. Skybridge, transports and build configuration all stay outside it.
## Each folder in depth
`waniwani.config.ts`: name, title, the overview the host reads at connect, search and tracking options.
One file per tool, with Zod shapes for input and output.
Why a widget is two files, and the hooks `ui.tsx` gets.
A compiled SDK flow, default-exported and registered as is.
`api/` for the widget's own server calls, `well-known/` for the root of the domain.
Tailwind utilities in `ui.tsx`, with the tokens coming from the template's stylesheet.
## What lives outside the folder
`.waniwani/` is build output, the way `.next/` is. Every command regenerates it and it stays out of git. Your `.env` and `.env.local` sit next to `waniwani.config.ts` and are read before any command runs; see [Deploy](/kit/deploy#secrets-and-environment-variables).
# What is the Kit?
Source: https://docs.waniwani.ai/kit/introduction
@waniwani/kit builds an MCP app from a folder. You write tools, widgets and flows; one CLI turns the folder into a deployable MCP server and owns every piece of plumbing.
`@waniwani/kit` builds an MCP app as a folder. You write tools, widgets and flows into a directory, and one CLI turns that directory into a deployable MCP server. Your repo holds none of the plumbing. The server bootstrap, the transport wiring and the build configuration live in the package.
Your side is the distribution MCP itself, meaning the tools, the widgets, the funnel and the content. The kit owns everything technical underneath it: the server, the transport, bundling, the deploy files, and keeping up with framework upgrades.
```text theme={null}
oney/ # what you write
├── waniwani.config.ts
├── tools/check-eligibility.ts
├── widgets/select-plan/{widget.ts,ui.tsx}
└── flows/split-payment.ts
waniwani build # → .waniwani/, an ordinary npm project
```
Scaffold an app with `init` and have the dev server running in one command.
Which folder becomes which MCP surface, and how names are derived.
Two files per widget, one schema for input, output and props.
Vercel, Docker or Alpic. The build writes the config, so deploying is a push.
## The three packages
Three packages ship under the `@waniwani` scope. The pair people mix up is the kit and the SDK, so start there.
| | What it is | You use it when |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **`@waniwani/sdk`** | A **library**. Flows (typed state graphs that compile to one MCP tool), event tracking, knowledge base, chat widget. You supply the `McpServer`, the transport and the build. | You already have an MCP server, or you want one you control down to the last line, and you want funnels, tracking or a knowledge base inside it. |
| **`@waniwani/kit`** | A **framework**. Folder convention, build CLI, shared server runtime. It owns the server, the transport, the bundler and the deploy files, so your repo can hold none of them. | You want to ship an MCP app and own no plumbing. |
| **`@waniwani/cli`** | The **platform CLI**. `login`, `logout`, `switch`, `connect`. OAuth into Waniwani, bind a repo to a hosted agent, run against the hosted playground. | You want your local server wired to app.waniwani.ai. |
The SDK is documented under the [SDK tab](/sdk/introduction) and the platform CLI on [its own page](/sdk/cli/overview).
## Kit against SDK, in code
With the SDK, the server is a file you write and keep:
```ts src/server.ts theme={null}
// yours to maintain
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const server = new McpServer({ name: "oney", version: "1.0.0" });
server.registerTool(
{ name: "check-eligibility", title, description, inputSchema, annotations },
async (input) => { /* … */ },
);
await flow.register(server);
await server.connect(new StreamableHTTPServerTransport(/* … */));
```
Around that file you also own a `tsconfig.json`, a bundler config for any widget UI, a `Dockerfile` and the deploy config each host wants.
With the kit, you write the part that answers the question and nothing around it:
```ts tools/check-eligibility.ts theme={null}
export default defineTool({ title, description, input, output, hints, run });
```
The kit finds that file, derives its tool name, registers it, bundles any widget that goes with it, and emits a deployable project. One copy of the plumbing exists, it lives in the package, and fixing it costs one publish plus a dependency bump per app.
The kit depends on the SDK, and the SDK knows nothing about the kit. Inside a kit app, `createFlow(...)` comes from the SDK, while `defineApp`, `defineTool`, `defineWidget` and the server that registers them come from the kit.
## What the kit gives you
* **Names from the filesystem.** `tools/check-eligibility.ts` registers as `check-eligibility`. Nothing is listed in a registry, so no widget can sit defined and unwired.
* **Failures surface at build time.** Structure is validated from the filesystem, then every server-safe module is imported for real. A flow that names a widget which does not exist fails the build instead of a request.
* **One runtime for every app.** Error envelopes, annotation defaults, the CSP block and tracking through `withWaniwani` sit in one place.
* **Leaving is one command.** `waniwani eject` writes the plumbing into your repo and steps out. See [Eject](/kit/eject).
## Next
Run through the [Quickstart](/kit/quickstart), or read the [folder convention](/kit/folder-convention) first if you would rather know the rules before typing.
# Quickstart
Source: https://docs.waniwani.ai/kit/quickstart
Scaffold an MCP app with `npx @waniwani/kit init`, or write the four files by hand: an app config, a tool, and a two-file widget.
```bash theme={null}
npx @waniwani/kit init oney
cd oney && npm run dev
```
`init` writes a folder that already answers: an app config, one tool, and the widget that displays what the tool returned. It installs, and the dev server is one command away. Running it inside an existing repo merges into that repo's `package.json` and `.gitignore` instead of replacing them.
## What init asks
In a terminal it asks three questions, arrow keys and Enter:
```text theme={null}
┌ A new MCP app
│
◇ App name
│ oney
│
◆ What should it come with?
│ ● A tool and a widget (the hand-off between them, wired up)
│ ○ Just a tool
│ ↑/↓ to navigate • Enter: confirm
└
```
The third question is where the app deploys (Vercel, Docker, Alpic, or "I don't know yet"). That answer decides the one config file the repo carries; see [Deploy](/kit/deploy).
Every question has a flag that answers it ahead of time, and a question whose answer is already in hand is skipped:
| Flag | Answers |
| ----------------------------------------- | ----------------------------------------------------------------------------------------- |
| `--name ` | App name |
| `--minimal` | Just a tool, no widget |
| `--host ` | Where it deploys |
| `--yes` | Every default, no questions. Also what happens with no terminal to ask in: a pipe, or CI. |
| `--no-install` | Skip the install step |
| `--force` | Overwrite files that already exist |
Where the app lands follows the argument. `init oney` creates `oney/`, `init .` uses the current folder, and a bare `init` asks for a name and reads the answer as both: a name of its own creates `.//`, while the offered default, your current folder's name, scaffolds in place.
## The same app, by hand
The rest of this page is what those files hold, written out. [examples/oney](https://github.com/WaniWani-AI/kit/tree/main/examples/oney) is the same app finished, if you would rather read it than type it.
```bash theme={null}
mkdir oney && cd oney
npm init -y
npm i @waniwani/kit @waniwani/sdk react react-dom zod
```
Set `"type": "module"` and the scripts:
```json package.json theme={null}
{
"type": "module",
"scripts": {
"check": "waniwani check",
"dev": "waniwani dev",
"build": "waniwani build",
"start": "waniwani start"
}
}
```
The kit asks for `@waniwani/sdk` 0.20 or later, React 19 and Zod 4 as peers.
```ts waniwani.config.ts theme={null}
import { defineApp } from "@waniwani/kit";
export default defineApp({
name: "oney",
title: "Oney: split your payment",
overview: `You help shoppers split a purchase into instalments with Oney.
RULES:
- Never quote a monthly amount yourself. Call check-eligibility and let it do the arithmetic.
- Never list the plans in text. Show the select-plan widget and let it render them.`,
});
```
`overview` reaches the host LLM once, in the `initialize` handshake. It says what the app is and which tool to reach for when. How a single tool behaves goes in that tool's own `description`, which travels with every `tools/list`. The full option list is on [App config](/kit/app-config).
The filename becomes the tool name.
```ts tools/check-eligibility.ts theme={null}
import { defineTool } from "@waniwani/kit";
import { z } from "zod";
import { buildPlans } from "../lib/plans.js";
export default defineTool({
title: "Check instalment eligibility",
description:
"Work out which instalment plans a basket qualifies for. Call this before showing any plans, and before quoting any figure.",
input: {
amount: z.number().positive().describe("Basket total in euros, e.g. 249.90"),
country: z.enum(["FR", "ES", "PT"]).default("FR"),
},
output: {
eligible: z.boolean(),
plans: z.array(z.object({ id: z.string(), monthly: z.number(), fee: z.number() })),
},
hints: { readOnly: true },
run: ({ amount, country }) =>
amount < 50
? { eligible: false, plans: [] }
: { eligible: true, plans: buildPlans(amount, country) },
});
```
`input` and `output` are Zod shapes, written as plain objects rather than `z.object({ … })`. `hints` becomes MCP annotations, with the runtime filling in the `title` that Claude's Connectors Directory requires. More on [Tools](/kit/tools).
The folder name becomes the tool name, and the widget takes two files.
```ts widgets/select-plan/widget.ts theme={null}
import { defineWidget } from "@waniwani/kit";
import { z } from "zod";
const plan = z.object({
id: z.string(),
label: z.string().describe("Short label, e.g. '3×'."),
monthly: z.number(),
fee: z.number(),
});
export default defineWidget({
title: "Choose an instalment plan",
description:
"Show the instalment plan picker. Call this once check-eligibility has returned plans, passing them straight through. The widget renders every figure itself: do NOT list the plans in text.",
data: {
amount: z.number(),
plans: z.array(plan).describe("Plans returned by check-eligibility, unmodified."),
},
llmText: (data) =>
`The picker is on screen with ${data.plans.length} plans. Wait for the user to pick one.`,
});
```
```tsx widgets/select-plan/ui.tsx theme={null}
import { useSendFollowUpMessage, useWidget } from "@waniwani/kit/web";
import widget from "./widget.js";
export default function SelectPlan() {
const { data } = useWidget(widget);
const sendFollowUp = useSendFollowUpMessage();
if (!data) return
Loading your plans…
;
return (
{data.plans.map((plan) => (
))}
);
}
```
`widget.ts` is imported by the server and by the browser bundle, so it stays free of React and CSS. Why the split exists is on [Widgets](/kit/widgets).
```bash theme={null}
npm run dev
```
`dev` watches the folder, mirrors changes into `.waniwani/`, and leaves nodemon and Vite HMR to do the rest. An edit to `tools/check-eligibility.ts` reaches the MCP endpoint in about a second. Point a client at `/mcp`, or open the dev server's root in a browser to call the tools without a chat client.
## Next
Every folder the kit reads, and what each one becomes.
Drop a compiled SDK flow into `flows/` and it registers as a tool.
`check`, `dev`, `build`, `start` and the four stages they share.
What `git push` needs on Vercel, Docker and Alpic.
# Styling
Source: https://docs.waniwani.ai/kit/styling
Widgets are styled with Tailwind utility classes in ui.tsx. The design tokens and the dark variant come from the template's stylesheet, and no app-level CSS is bundled.
A widget styles itself with utility classes in its `ui.tsx`. There is no `styles.css` at any level of an app folder, and nothing imports one:
```tsx theme={null}
```
`text-ink-muted` comes from the distribution template's `src/index.css`, which is the Tailwind entry and the design system in one file:
```css src/index.css theme={null}
@import "tailwindcss";
/* The host hands the colour scheme to the view rather than to the browser, so
`dark:` hangs off a class instead of `prefers-color-scheme`. */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: "Inter", system-ui, sans-serif;
--color-ink: #0a1334;
--color-ink-muted: #5a628a;
--color-surface: #ffffff;
}
```
Every entry under `@theme` becomes a utility, so `--color-ink` gives you `text-ink` and `bg-ink`. Rebranding every app on the template is a matter of editing those four values in the template repo. The generator writes one import of that file into each `src/views/.tsx` entry, and since each view is its own bundle, Tailwind emits only the utilities that view's source uses.
## The dark class
A view is mounted alone in its own iframe, so there is no shared ancestor to hang the variant off. Each widget puts the class on its own root, driven by the theme the host reports:
```tsx theme={null}
import { useLayout } from "@waniwani/kit/web";
const { theme } = useLayout();
return
…
;
```
## The stylesheet's origins
`src/index.css` pulls Inter from Google Fonts, and a host that enforces the widget CSP drops undeclared requests without erroring, so the font falls back and the widget looks subtly wrong. Codegen reads the origins off the stylesheet and the runtime merges them into every widget's `resourceDomains`, alongside whatever the widget declares itself. `fonts.googleapis.com` brings `fonts.gstatic.com` with it, since the second is only reachable by following the first.
## Why app-level CSS is refused
Tailwind v4 rejects `@apply` in any file that has not imported Tailwind itself:
```text theme={null}
Cannot apply unknown utility class `text-ink`. Are you using CSS modules or
similar and missing `@reference`?
```
Fixing that from an app folder means writing a `@reference` at a path into the generated tree, which does not exist in the author's own repo. One place for a class name beats two, so the build check names a stray `styles.css` rather than letting it sit there doing nothing:
```text theme={null}
widgets/select-plan/styles.css
└ app CSS is not bundled — nothing imports this file
style with Tailwind utility classes in ui.tsx; the template's src/index.css
carries the @theme tokens and the `dark` variant
```
# Tools
Source: https://docs.waniwani.ai/kit/tools
One file under tools/ is one MCP tool. defineTool takes a title, a description, Zod shapes for input and output, hints that become annotations, and a run function.
A file at `tools/.ts` that default-exports `defineTool({ ... })` registers as the MCP tool ``. Rename the file and the tool renames with it. `.ts`, `.tsx` and `.mts` are picked up.
```ts tools/check-eligibility.ts theme={null}
import { defineTool } from "@waniwani/kit";
import { z } from "zod";
import { buildPlans } from "../lib/plans.js";
export default defineTool({
title: "Check instalment eligibility",
description:
"Work out which instalment plans a basket qualifies for. Call this before showing any plans, and before quoting any figure.",
input: {
amount: z.number().positive().describe("Basket total in euros, e.g. 249.90"),
country: z.enum(["FR", "ES", "PT"]).default("FR"),
},
output: {
eligible: z.boolean(),
plans: z.array(z.object({ id: z.string(), monthly: z.number(), fee: z.number() })),
},
hints: { readOnly: true },
run: ({ amount, country }) =>
amount < 50
? { eligible: false, plans: [] }
: { eligible: true, plans: buildPlans(amount, country) },
});
```
## Fields
Shown to humans in connector UIs.
LLM-facing. The only thing the model reads before deciding to call this, so it says when to call it and what not to do instead. A tool with no description fails the build check.
A Zod shape written as a plain object, `{ amount: z.number() }` rather than `z.object({ ... })`. `.describe()` on a field reaches the model as that argument's description. `run` receives the parsed value, typed off this shape.
The structured output schema, also a plain Zod shape. The runtime advertises it in `tools/list` and validates what `run` returns against it.
Behavioural hints for the host LLM. The runtime translates them into MCP `annotations` and always fills in the `title` annotation that Claude's Connectors Directory requires.
The tool only reads. Defaults to `true` for widgets.
The tool can destroy data.
The tool reaches out to the open internet.
Calling twice with the same input has the same effect as calling once.
The handler. It may be async.
## What run can return
| Return | Becomes |
| ----------------------- | ----------------------------------------------- |
| a string | the model-facing text |
| a plain object | `structuredContent`, plus a JSON text fallback |
| a full `CallToolResult` | passed through, for the rare tool that needs it |
A `run` that throws returns an error envelope telling the host to offer a retry rather than invent a result, so exceptions are safe to let propagate.
## Writing the description
The `description` is what the model sees at the moment it is choosing a tool, so the starter template's version tells the model when to call it and what to avoid:
```ts theme={null}
description:
"Find products matching what the shopper asked for. Call this before naming any product or quoting any price, and never answer either from memory. Pass the shopper's own words as the query.",
```
How the tools fit together across a conversation goes in the app's [`overview`](/kit/app-config) instead, which the host reads once at connect.
## Tool or endpoint
A tool appears in `tools/list` and costs a turn, and the model can reason about what came back. An [endpoint](/kit/endpoints) under `api/` is invisible to the model and is called by the widget itself from the browser. Reach for a tool when the model is the caller.
# Widgets
Source: https://docs.waniwani.ai/kit/widgets
A widget is a folder with two files: widget.ts holds the contract and ui.tsx the React component. One data schema serves as tool input, structured output and component props.
A folder at `widgets//` registers as the MCP tool `` plus a `ui://` resource the host renders. It needs two files: `widget.ts`, which default-exports `defineWidget({ ... })`, and `ui.tsx`, which default-exports a React component.
```ts widgets/select-plan/widget.ts theme={null}
import { defineWidget } from "@waniwani/kit";
import { z } from "zod";
const plan = z.object({
id: z.string(),
label: z.string().describe("Short label, e.g. '3×'."),
monthly: z.number(),
fee: z.number(),
});
export default defineWidget({
title: "Choose an instalment plan",
description:
"Show the instalment plan picker. Call this once check-eligibility has returned plans, passing them straight through. The widget renders every figure itself: do NOT list the plans in text.",
data: {
amount: z.number(),
plans: z.array(plan).describe("Plans returned by check-eligibility, unmodified."),
},
llmText: (data) =>
`The picker is on screen with ${data.plans.length} plans. Wait for the user to pick one.`,
});
```
```tsx widgets/select-plan/ui.tsx theme={null}
import { useLayout, useSendFollowUpMessage, useWidget } from "@waniwani/kit/web";
import widget from "./widget.js";
export default function SelectPlan() {
const { data } = useWidget(widget);
const sendFollowUp = useSendFollowUpMessage();
const { theme } = useLayout();
const root = theme === "dark" ? "dark" : "";
if (!data) return
Loading your plans…
;
return (
{data.plans.map((plan) => (
))}
);
}
```
## Why a widget is two files
`widget.ts` gets imported by the server and by the browser bundle, so it stays free of React and CSS. It carries one `data` schema, which serves as the tool's input schema, its structured output, and the type the component receives:
```tsx theme={null}
const { data } = useWidget(widget); // typed off `data`, no generated helpers
```
The usual approach puts `generateHelpers()` in a shared file typed against the server, which makes a widget's type depend on the server's shape. Here the widget owns its own contract, so the two cannot drift.
`data` arrives as soon as the host has the tool input, which on most hosts happens before the server responds, so render optimistically and reach for `isReady` when you need the final value.
## Contract fields
Shown to humans in connector UIs, and used as the `title` annotation.
LLM-facing. When to show this widget, and how to frame it. The starter template's version tells the model what to say before calling and what not to repeat afterwards.
A plain Zod shape. Input schema, structured output and component props, all from this one definition.
Same as on a [tool](/kit/tools#fields). `readOnly` defaults to `true` for widgets.
Text handed to the model alongside the rendered widget. Use it to say what the model should not repeat, and what it should wait for. Without it the runtime sends a default that tells the model the widget displays all the detail itself and to wait for the user to interact with it.
Optional server-side loader, for widgets whose data comes from an API rather than from the model. Defaults to echoing the input through. A `load` that throws returns an error envelope telling the host something went wrong on your side and to offer a retry.
Let the host size the widget's frame to its content. A card whose height depends on its data is cut off or padded out by any fixed frame. Set it to `false` for a widget that renders its own scroll area.
Domains the widget may `fetch()`.
Domains the widget may load images, fonts and scripts from. The origins of the template's stylesheet are merged in for you; see [Styling](/kit/styling).
Domains the widget may open externally without the host's safe-link confirmation.
## Hooks in ui.tsx
Everything a component needs comes from `@waniwani/kit/web`.
| Hook | What it gives you |
| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `useWidget(widget)` | `{ data, isLoading, isReady }`, typed off the widget's `data` shape |
| `useWidgetState(initial)` | Per-widget state that survives re-renders and is handed back to the host |
| `useSendFollowUpMessage()` | Send a message as the user, which is how a click moves the conversation on |
| `useCallTool()` | Call one of the app's tools from the widget |
| `useLayout()` | The host's layout, including `theme` for the `dark` class |
| `useDisplayMode()`, `useRequestSize()`, `useRequestModal()`, `useRequestClose()` | Ask the host to change how the widget is shown |
| `useOpenExternal()`, `useSetOpenInAppUrl()` | Open links outside the host |
| `useUser()`, `useFiles()`, `useDownload()` | Read what the host shares about the user, and hand files back and forth |
| `mountView` | The entry the generator uses; you will not call it yourself |
`useWidget().data` is the model's input until the server answers, so an `if (!data)` guard only covers the first paint. Guard on `isReady` for anything that must wait for `load()`.
## What a request does
```mermaid theme={null}
sequenceDiagram
participant Host as ChatGPT / Claude
participant Server as registerApp() (the shared runtime)
participant App as your code
Host->>Server: tools/call select-plan
Server->>Server: validate against the widget's `data` schema
Server->>App: load(input), optional
App-->>Server: data
Server->>Server: structuredContent + llmText + annotations
Server-->>Host: result + ui:// resource
Host->>Server: resources/read ui://widgets/.../select-plan.html
Server-->>Host: HTML pointing at the built bundle
```
Every arrow that leaves `App` out is runtime code. Error envelopes, annotation defaults (including the `title` Claude's Connectors Directory requires), the "do not narrate the widget" instruction, the CSP block and tracking through `withWaniwani` all sit in one place, for every app.
## Calling from a flow
A [flow](/kit/flows) shows a widget by folder name, and the build check verifies that the folder exists:
```ts theme={null}
showWidget({ tool: "select-plan", field: "selectedPlanId", data: { /* … */ } })
```
# Connect your AI client
Source: https://docs.waniwani.ai/mcp-server/connect
Add the Waniwani MCP server to claude.ai, Claude Code, Cursor, or ChatGPT and sign in with your Waniwani account — no API keys.
Waniwani runs the MCP server in two regions. Add the URL for the region where your organization lives:
| Region | MCP URL |
| ------ | -------------------------------- |
| US | `https://mcp.waniwani.ai/mcp` |
| EU | `https://eu.mcp.waniwani.ai/mcp` |
It's a remote MCP server (Streamable HTTP) that authenticates with OAuth 2.1. There is no API key to paste: you add the URL, your client opens a browser window, you sign in to Waniwani and approve the requested permissions. From then on the connection acts as *you* — same organizations, same role.
Each region is an independent stack with its own data. Sign in on the endpoint that matches your **data residency** — the region you chose when you [created your organization](/platform/regions). The US endpoint won't show organizations that live in the EU, and vice versa. The examples below use the US URL; if your data is in the EU, swap in `https://eu.mcp.waniwani.ai/mcp`.
## Claude (claude.ai and desktop)
1. Open **Settings → Connectors**.
2. Click **Add custom connector**.
3. Enter `https://mcp.waniwani.ai/mcp` and confirm.
4. A Waniwani sign-in window opens — sign in and approve the permissions.
On Team and Enterprise plans, an admin may need to enable custom connectors for your workspace first.
## Claude Code
```bash theme={null}
claude mcp add --transport http waniwani https://mcp.waniwani.ai/mcp
```
Then run `/mcp` inside Claude Code and select **waniwani** to complete the browser sign-in.
## Cursor
Add the server to `~/.cursor/mcp.json` (or **Settings → MCP → Add new MCP server**):
```json ~/.cursor/mcp.json theme={null}
{
"mcpServers": {
"waniwani": {
"url": "https://mcp.waniwani.ai/mcp"
}
}
}
```
Cursor prompts you to authenticate in the browser the first time it connects.
## ChatGPT
Custom MCP connectors require developer mode:
1. Open **Settings → Apps & Connectors → Advanced settings** and enable **Developer mode**.
2. Back in **Connectors**, click **Create** and enter `https://mcp.waniwani.ai/mcp`.
3. Complete the sign-in when prompted.
Client UIs change quickly. If a menu has moved, follow your client's own guide for adding a **remote MCP server** and use the URL above — the server implements the standard discovery flow, so any MCP-capable client that supports OAuth can connect.
## What happens when you sign in
The client reads the server's metadata and learns which Waniwani stack handles sign-in for that region (`app.waniwani.ai` for US, `eu.app.waniwani.ai` for EU). It registers itself automatically — no manual client IDs.
The browser opens your usual Waniwani login. If you're already signed in, you go straight to consent.
The consent screen itemizes exactly what the connection may do (read environments and analytics, create environments and keys, and so on). You only see permissions your account is entitled to.
The client receives a token bound to this server and your approved permissions. Every call it makes runs under your identity.
## First thing to try
> Who am I signed in to Waniwani as, and what can you do for me here?
Your client will call the server's `whoami` and `search` tools and report your account, your organizations, and the available operations. From there, ask for what you want in plain language — see [the overview](/mcp-server/overview) for example prompts.
## Troubleshooting
That's by design. The permission list is narrowed to what your account is entitled to — for example, internal Waniwani staff permissions never appear for customer accounts. Approve what's shown; it's everything available to you.
Your token expired or was invalidated. Most clients refresh automatically; if yours doesn't, disconnect and reconnect the server (re-running the sign-in) to mint a fresh token.
`set_active_org` only switches to organizations you belong to — the API rejects anything else with a `NOT_ORG_MEMBER` error. Ask *"who am I?"* — the `whoami` tool lists the organizations you can act on and their ids.
Expected. Each connection carries its own active organization, independent of the dashboard — switching in one place doesn't switch the other. Ask the model to switch here too (it calls `set_active_org`), or check where you stand with *"who am I?"*.
The available surface is curated and grows over time. Ask the model to run `search` again — it returns the live catalog with exact signatures. If what you need isn't there yet, tell us.
# How it works
Source: https://docs.waniwani.ai/mcp-server/how-it-works
Four tools, OAuth scopes, org context, and sandboxed execution: what happens between your prompt and the Waniwani API.
The server is deliberately small: four tools that compose, rather than one tool per API endpoint.
## The four tools
| Tool | What it does |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `whoami` | Returns your identity: user id, granted permissions, the organizations you can act on, and which one is active. |
| `set_active_org` | Switches your active organization for subsequent calls. Membership is enforced server-side. |
| `search` | Lists the Waniwani API operations available to `execute`, with full TypeScript signatures — inputs *and* response shapes. Takes an optional keyword filter. |
| `execute` | Runs a short piece of JavaScript in an isolated sandbox. The code calls operations as `api.(input)` and returns a value. |
## The loop
A request like *"give me a digest of activity for the last 7 days"* plays out as:
1. The model calls `search("analytics")` and reads the typed signatures.
2. It writes a snippet and calls `execute`:
```js theme={null}
const envs = await api.listEnvironments();
// arguments elided — `search` returns the exact signatures
const summaries = await Promise.all(
envs.map((env) => api.getEnvironmentAnalytics(/* ... */)),
);
return summaries;
```
3. The returned data lands back in the conversation, and the model writes your digest.
Because `execute` runs real code, the model can chain, filter, and aggregate in a single round trip instead of stitching together a dozen separate tool calls. Top-level `await` is supported, and whatever the snippet `return`s comes back as the tool result.
## Org context
Operations run against your **active organization**:
* When you first connect, it starts as the organization you last used in the dashboard.
* `set_active_org` switches it, and the switch is enforced server-side: you can only activate organizations you belong to.
* The switch applies to **this connection only**. The dashboard and any other connected clients each keep their own active organization, so driving Waniwani from your AI client never silently moves your browser session — or vice versa.
* It takes effect on your next call: the server invalidates the connection's current token and your client silently refreshes into the new organization.
`whoami` always tells you where you currently stand.
## Permissions: scopes and consent
When you connect, the consent screen itemizes the connection's permissions as OAuth scopes:
| Scope | Grants |
| -------------------------- | -------------------------------------------------------------------------------- |
| `orgs:read` / `orgs:write` | See your organizations and membership / invite members |
| `mcp:read` / `mcp:write` | Read environments and analytics / create environments and API keys |
| `kb:read` / `kb:write` | Read / write the knowledge base *(operations not yet exposed through `execute`)* |
Two properties worth knowing:
* **You only see what you're entitled to.** The consent screen narrows the list to your account's role — permissions reserved for Waniwani staff never appear for customer accounts.
* **Scopes are enforced on every API call, server-side.** The MCP server doesn't get to be generous: each `api.*` operation is re-checked against your token's scopes and your org membership by the Waniwani API itself.
## Security model
* **OAuth 2.1, no API keys.** You sign in through `app.waniwani.ai` — the same login as the dashboard. The token your client receives is bound to this server specifically and to the scopes you approved.
* **The sandbox never sees your token.** Code submitted through `execute` runs in an isolated sandbox that can reach only the curated `api.*` operations. Your credentials are attached to outbound API calls *outside* the sandbox — code running inside it has no way to read or exfiltrate them.
* **Every call is your call.** All operations execute under your identity with your permissions. The connection can do nothing your account can't do in the dashboard.
* **Stateless by design.** The server keeps no session state and stores no conversation content; each request is authenticated independently by your token. Disconnecting the server from your client severs its access.
## Limits worth knowing
* The exposed operation surface is curated and grows over time — `search` is the source of truth, not this page.
* The active organization is per-connection: switching it here doesn't affect the dashboard or other connected clients, and switching in the dashboard doesn't affect this connection.
* Long or heavy aggregations should be scoped (a date range, a single environment): `execute` snippets run within a bounded execution window.
# Waniwani MCP server
Source: https://docs.waniwani.ai/mcp-server/overview
Connect Claude, Cursor, or ChatGPT to the Waniwani platform and drive it from a conversation — environments, API keys, analytics digests, session breakdowns.
The Waniwani MCP server lets your AI client operate the Waniwani platform for you. Add the server URL for your region, sign in with your Waniwani account, and ask for the things you would otherwise click through the dashboard for.
## Server URL
Use the URL for the region your organization's data lives in — the region you chose when you created the organization:
```text US theme={null}
https://mcp.waniwani.ai/mcp
```
```text EU theme={null}
https://eu.mcp.waniwani.ai/mcp
```
If you sign in to the dashboard at `app.waniwani.ai`, use the **US** URL. If you sign in at `eu.app.waniwani.ai`, use the **EU** URL. Each region is an independent stack: the US endpoint won't show organizations that live in the EU, and vice versa. See [Regions & data residency](/platform/regions) for how residency is set, and [Connect your AI client](/mcp-server/connect) for the per-client setup.
This is **Waniwani's own MCP server** — the one that drives *the platform*. It is not the MCP funnel server you build with the SDK. Everywhere else in these docs, "your MCP server" means the one you ship to your users; this tab is about the one we host for you.
## What you can ask it
* "Give me a digest of activity across my environments for the last 7 days."
* "Create a new environment called *staging* and hand me its API key."
* "Which sessions hit production yesterday? Where did people drop off?"
* "What sources are sending events to my production environment?"
* "Compare this week's activity to last week's across all my environments."
* "Invite [jane@acme.com](mailto:jane@acme.com) to our organization."
## What it can do
| Category | Examples |
| --------------------------- | ---------------------------------------------------------------------------------------------------- |
| **Environments & API keys** | List your environments, create one, issue an environment's API key |
| **Analytics & sessions** | Activity summaries over a date range, funnel flow breakdowns, per-session drill-downs, event sources |
| **Organization** | Check who you're signed in as, switch your active org, invite a teammate |
The surface grows over time, so this table stays deliberately coarse. The server's own `search` tool is the live catalog: once connected, ask *"what Waniwani operations are available?"* and your client will list every operation with its exact signature.
## How you use it
1. [Connect your client](/mcp-server/connect) — add the URL above, sign in, approve the permissions. No API keys involved.
2. Ask in plain language. The model discovers the right operations and calls them with your identity — every call is permission-checked server-side.
3. Results come back into the conversation.
Everything the connection can do is bounded by your own Waniwani account: your organizations, your role, the permissions you approved at sign-in. The mechanics are on [How it works](/mcp-server/how-it-works).
# Submit to the ChatGPT Plugin Store
Source: https://docs.waniwani.ai/platform/guides/chatgpt/overview
Start here to list your Waniwani-built MCP server in the ChatGPT Plugin Store: pick whether Waniwani submits on your behalf or you submit from your own OpenAI account, and see what each path costs you.
Listing your app in the ChatGPT Plugin Store means filling a long form at [platform.openai.com/plugins](https://platform.openai.com/plugins) and passing OpenAI's review. The first decision is who runs that form.
## Who runs the submission
| | Waniwani submits for you | You submit yourself |
| -------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------- |
| Who fills the form | Waniwani, from a seat in your organization | You |
| OpenAI account | Yours, created before anything else starts | Yours |
| Identity verification | Yours to complete, and it can take days | Same |
| Access we need | An invite to your org with "Apps Management" set to Write | None |
| "Developer" shown on the listing | Your company | Your company |
| Where the plugin entry lives | Your OpenAI organization | Your OpenAI organization |
| What you spend time on | The account setup, the invite, and one asset handover | The same setup, plus eleven tabs, tool justifications, and test cases |
| Pick this when | You want the listing out without anyone internally learning the form | Someone on your side wants to own the submission end to end |
The listing ends up in your OpenAI organization under your company name on both paths, and the assets you prepare are identical. What changes is who sits in the form. Waniwani drafts your tool justifications, test cases, and starter prompts either way.
From an empty OpenAI account to a submitted plugin: what you set up, what you invite us to, what you send, and what we do with it.
The full tab-by-tab checklist for the form: account setup, MCP, tool justification, test cases, screenshots, compliance.
## Yours either way
These stay on your side whichever path you take:
* **An OpenAI organization** with a verified developer or business identity. Verification can take days, so start it early.
* **Privacy policy and terms of service**, live on your domain and reachable without a login.
* **A monitored support channel.** Reviewers may test it.
## Check eligibility first
Confirm your app clears the [two hard eligibility limits](/platform/guides/overview#two-hard-eligibility-limits): no moving money or crypto inside the conversation, and no AI generation of images, video, or audio.
Submitting the form does not guarantee a listing. OpenAI reviews every plugin, and approval is separate from going live: once a plugin is approved, someone still has to publish it from the plugins portal.
Every tool's annotations and justification text, laid out before the form opens.
The 5 positive and 3 negative cases reviewers run against your app.
Listing the same app in Claude's Connectors Directory.
Drive traffic to your listing once it is live.
# Waniwani submits for you
Source: https://docs.waniwani.ai/platform/guides/chatgpt/we-submit
The managed path for a ChatGPT Plugin Store listing, from an empty OpenAI account onward: create the organization, verify identity, invite Waniwani, hand over the assets, and we fill and submit the form from inside your org.
The app is agreed and ready, so this page is what happens next. Two things are yours: setting up an OpenAI organization and giving us access to it, then handing over the assets. We do the rest from inside your organization, which is why the listing, the Developer name, and the plugin entry all belong to you.
Assume you have no OpenAI account today. Part 1 starts there.
## Part 1: your OpenAI organization
Do these in order. Business verification has to be submitted before you send any invite, because an unverified organization does not carry the permissions an invited seat needs. Verification also takes the longest, so start it the day you read this.
Sign up at [platform.openai.com](https://platform.openai.com). Use an address your company controls, such as a shared or role mailbox, since this account will own the plugin listing for as long as it exists. One organization per company is enough.
OpenAI blocks plugin submission until the organization has a **verified developer or business identity**, so submit individual or business verification from your organization settings as soon as the account exists. It can take days and sometimes asks for documents.
Send this before you invite anyone. Invites issued from an unverified organization do not come with the access we need, and redoing them after verification clears wastes a round trip.
Once verification is submitted and the organization shows as verified, invite the addresses your Waniwani contact gives you under [Settings → Organization → People](https://platform.openai.com/settings/organization/people). The seat needs a role with the **"Apps Management"** permission set to **"Write"**, which is what the plugin form checks. Roles are configured at [organization roles](https://platform.openai.com/settings/organization/people/roles).
We only need that permission. Nothing about your API keys, billing, or usage is part of this.
Message us once verification has cleared and the invite is accepted. We check we can reach the plugins portal in your org before you spend time on Part 2.
## Part 2: what you send us
Send it in one batch to your Waniwani contact, or to [contact@waniwani.ai](mailto:contact@waniwani.ai). Anything missing blocks the submission, so it is worth doing in one pass.
PNG, square, one light-mode version and one dark-mode version. Leave off borders and rounded corners, since ChatGPT applies circular cropping itself. The icon needs enough contrast to read on a dark background.
Four fields, all public:
* **Name.** The customer-facing product or workflow name.
* **Subtitle.** 30 characters maximum, and the limit is strict. Describe what the app does in plain words; marketing language gets flagged.
* **Description.** Two to three sentences on what the app does and why someone wants it, then three to five bullets of key capabilities. Factual, no hype. See [how to structure it](/platform/guides/chatgpt/you-submit#writing-the-description).
* **Category.** One of ChatGPT's predefined categories, matching your primary function.
Send it in English (US) at minimum. If you want other locales, send a translated subtitle and description per locale.
* **Website.** The root URL, since deep links get flagged.
* **Support.** A monitored URL or email address. Reviewers may test it.
* **Privacy policy.** A live page on your domain.
* **Terms of service.** Same.
Open each one in a private browser window before sending. A privacy policy that 404s or asks for a login is an immediate rejection, and it is the failure we see most often.
* **Regional availability.** All [ChatGPT-supported countries](https://help.openai.com/en/articles/7947663-chatgpt-supported-countries), or a specific list. Restrict it if your service only operates in certain regions.
* **Commerce.** If your app touches purchasing, tell us what you sell and where the transaction completes. For regulated products such as insurance, the chat shows a quote estimate and the user is redirected to your site to finalize.
* **Release notes.** One short paragraph describing this version. It may be shown publicly.
## Your checklist
* [ ] OpenAI account created on a company-controlled address
* [ ] Business verification submitted and cleared, before any invite goes out
* [ ] Waniwani invited afterwards, with "Apps Management" set to Write
* [ ] Verification and invite acceptance confirmed to us
* [ ] Light and dark PNG logos, square, no borders
* [ ] Name, 30-character subtitle, description, category
* [ ] Translations per extra locale, if any
* [ ] Website, support, privacy policy, and terms URLs, each verified in a private window
* [ ] Regional availability decided
* [ ] Commerce flow described, if applicable
* [ ] Release notes written
## What we do next
1. We create the plugin in your organization and enter your production MCP server URL, authentication, and content security policy on the MCP tab.
2. We complete domain verification and run the tool scan. OpenAI issues a token that has to sit at `/.well-known/openai-apps-challenge` on the challenge host. We host it ourselves on a `waniwani.run` domain, and when the host is yours we send you the token to serve at that exact path, where it stays for the whole review.
3. We fill every remaining tab: Info, Skills, Prompts, Testing, Global, Submit.
4. We send you everything we produced for your sign-off: the [tool justifications](/platform/guides/templates/tool-justification), the [test cases](/platform/guides/templates/test-cases), the starter prompts, the recording, and the screenshots. Check them against how the app behaves, since reviewers run the test cases live against it.
5. We submit, and OpenAI's review begins. Reviewer questions come to us first, and we come back to you only when the answer is yours to give.
6. On approval, nothing appears in the store until you give the go-ahead. Publishing is a separate step and we take it on your word.
None of this becomes your problem:
* Deploying your app at a stable versioned MCP URL.
* Tool annotations and CSP metadata in the server code.
* Domain verification and the tool scan.
* The demo recording and the widget screenshots, produced to OpenAI's specs and sent to you for approval.
* Reviewer demo credentials, if your app requires sign-in. We prepare an account a reviewer can use without MFA, SMS codes, email confirmation, or private-network access, since an auth flow with extra confirmation steps is one a reviewer cannot finish.
* The first draft of your tool justifications, test cases, and starter prompts.
* The eleven tabs of the form, and the back and forth during review.
Everything sits in your organization, so you keep the plugin entry whatever happens to our working relationship. Remove our seat and the listing stays where it is, editable by your own team. The [self-serve guide](/platform/guides/chatgpt/you-submit) is the same form, documented tab by tab, if you ever take it over.
What we fill in on your behalf, and the guide to follow if you take the form over yourself.
Anthropic requires the final submit from an Owner of your own Claude workspace, so that one works differently.
# Submit from your own OpenAI account
Source: https://docs.waniwani.ai/platform/guides/chatgpt/you-submit
A tab-by-tab checklist for listing your Waniwani-built MCP server as a plugin in the ChatGPT Plugin Store yourself: account setup, MCP server, tool justification, test cases, starter prompts, screenshots, and policy compliance.
This guide covers everything you need to list your MCP server as a plugin in the ChatGPT Plugin Store when someone on your side runs the form. Most of the engineering work (server, versioning, tool annotations) is already handled. Your job is to prepare the assets and decisions below, then put them into the form.
You create the plugin at [platform.openai.com/plugins](https://platform.openai.com/plugins) and fill in a form split across tabs: **Info → MCP → Skills → Prompts → Testing → Global → Submit**. This guide follows that order, with a short setup section first. OpenAI's own walkthrough is at [Submit plugins](https://learn.chatgpt.com/docs/submit-plugins).
Waniwani can [fill and submit this form for you](/platform/guides/chatgpt/we-submit) instead. You still create the OpenAI organization and verify its identity, then invite us to a seat with "Apps Management" set to Write, and we take the form from there.
Submitting the form does not guarantee a listing. OpenAI reviews every plugin, and the **Tool justification** and **Test cases** sections are where most rejections happen, so give them the most attention.
## Before you start: OpenAI account and access
Sort these out before you open the form, or you will be blocked at the first tab.
* **Apps Management role.** The account that submits needs the **"Apps Management"** permission set to **"Write"** in your organization role. Check it under [Platform → organization roles](https://platform.openai.com/settings/organization/people/roles).
* **Verified identity.** You need a **verified developer or business identity** in the OpenAI Platform. Complete individual or business verification in your organization settings first; verification can take time, so start early.
* **Public URLs live.** Website, support contact, privacy policy, and terms of service must be publicly reachable (see [Plugin info](#3-info-tab-plugin-info) below).
* **Reviewer credentials.** If your plugin requires sign-in, prepare **demo credentials** that work for a reviewer **without MFA, SMS, email confirmation, or private-network access**. Reviewers cannot complete an auth flow that has extra confirmation steps.
### Creating the plugin
1. Go to [platform.openai.com/plugins](https://platform.openai.com/plugins) and select **Create plugin**.
2. Choose a submission type:
* **Skills only.** A package of reusable workflows, no MCP server.
* **With MCP.** A plugin backed by your MCP server, either MCP-only or MCP plus skills.
For a Waniwani-built plugin, choose **With MCP**. Skills are optional (see the [Skills tab](#5-skills-tab-optional)).
## 1. MCP tab: server, versioned and deployed
The form requires a stable production MCP Server URL, entered on the **MCP** tab.
1. Go to [app.waniwani.ai/mcp](https://app.waniwani.ai/mcp) and confirm your app is listed.
2. Use the dropdown next to your app to copy its production MCP URL.
3. Verify it follows the versioned pattern, for example `https://v1..mcp.waniwani.run/mcp`.
4. On the MCP tab, enter the URL, configure authentication, set the content security policy, then complete domain verification and select **Scan Tools** so OpenAI can read your tool definitions.
**Domain verification is a common blocker.** OpenAI generates a verification token that must be served at a specific well-known path on your challenge host:
`https:///.well-known/openai-apps-challenge`
Plan for this step in advance rather than discovering it mid-form. If you are a managed Waniwani customer, coordinate the timing with us so we can host the token at that path together.
Before you run **Scan Tools**, make sure tool responses contain no secrets, undisclosed user fields, or unnecessary personal data. Reviewers inspect the scanned tool output. For managed Waniwani apps this is handled in the server code.
## 2. Logo: light and dark versions
Provide two versions of your plugin's icon.
* **Format:** PNG.
* **Shape:** square. Do not add borders or rounded corners; ChatGPT applies circular cropping automatically.
* One **light mode** version and one **dark mode** version.
## 3. Info tab: plugin info
Prepare these fields in advance. Use the **customer-facing product or workflow name**, and make sure every URL matches your verified publisher identity.
| Field | Requirements | Example |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- |
| **Name** | Your plugin's display name. How users find and recognize it. Keep it short and on-brand. | `Your Plugin Name` |
| **Subtitle** | Short, plain-language phrase. **30 characters max** (the limit is strict, count carefully). Describe the function in plain words; marketing language gets flagged. | `Find the best deals near you` |
| **Description** | Clear description of what the plugin does and why people want it. Concrete value, no exaggerated or misleading claims. Appears publicly. | See guidance below |
| **Category** | One of ChatGPT's predefined categories. Pick the one matching your primary function. | `Finance` |
| **Developer** | Your company name, shown publicly on the listing. | `Your Company` |
| **Website URL** | Your main company or product website. Use the root URL (deep links get flagged) and make sure it loads cleanly. | `https://yourcompany.com` |
| **Support URL or Email** | A working, monitored channel. Reviewers may test it. | `support@yourcompany.com` |
| **Privacy Policy URL** | A live, publicly accessible page. Confirm it in a private window: if it 404s or needs a login, it is rejected. | `https://yourcompany.com/privacy` |
| **Terms of Service URL** | A live, publicly accessible page (same check as above). | `https://yourcompany.com/terms` |
| **Demo Recording URL** | A video showing your plugin working across all main use cases and tools. | A public YouTube or Loom link |
### Writing the description
Structure it like this:
1. **Opening paragraph:** what the plugin does and the value it provides (two to three sentences).
2. **"What you can do with \[Plugin Name]":** three to five bullet points of key capabilities.
3. Keep it factual and user-centric, and avoid hype.
For tone and length, study how live plugins write theirs, for example [Compare the Market](https://chatgpt.com/apps/compare-the-market/asdk_app_69a41e8393c48191bf5b3ecfb7963af9), [Aviva](https://chatgpt.com/apps/aviva/asdk_app_69a6eb598b4c8191b10712ed489e8e11), and [Tuio](https://chatgpt.com/apps/tuio/asdk_app_6970fc7c958881918cfa39d58fc37e82).
### Recording the demo
The video must cover all main use cases and tools across every platform you support (web, iOS, Android). A practical recipe:
1. Record several runs of the plugin working across different scenarios.
2. Stitch them into one video, one scenario after another.
3. Speed it up to at least 2x. The point is for a reviewer to see what the plugin does quickly.
### If your plugin involves commerce
You will be asked to confirm your purchasing flow and to describe the products you intend to sell. For regulated products such as insurance, be transparent that you help users get a **quote estimate**: if they like it, they are redirected to your website to finalize. Do not present a "final price" inside the chat.
## 4. Tool justification (critical step)
This is the hardest part and the most common reason for rejection. For **every tool** your MCP server exposes, OpenAI requires three annotation hints, each with a written justification. If they are not set, OpenAI assumes the worst defaults: `readOnlyHint = false`, `openWorldHint = true`, `destructiveHint = true`.
| Annotation | Question it answers | Values |
| --------------- | -------------------------------------------------------------------- | -------- |
| **Read Only** | Does this tool only read data without modifying anything? | Yes / No |
| **Open World** | Does this tool call an external API or service outside your control? | Yes / No |
| **Destructive** | Can this tool delete, overwrite, or irreversibly modify data? | Yes / No |
For each annotation on each tool, the form wants a short written justification explaining why the value is accurate. Tools that touch external services may also need CSP metadata (`connect_domains`, `resource_domains`).
Use the [Tool justification template](/platform/guides/templates/tool-justification) to lay out every tool, its annotation values, and the justification text before you open the form. For managed Waniwani apps, the annotations and CSP metadata are set in the server code for you, and you receive the justifications pre-filled to review and paste.
## 5. Skills tab (optional)
Skills are self-contained, reusable workflow packages. You can submit **skills only** (no MCP server) or bundle skills alongside your plugin. If you have no skills to ship, leave this tab empty.
* Upload the final **skill bundle** with a tested file structure.
* Confirm the bundle works end to end before uploading. A broken bundle is a rejection.
For most Waniwani-built plugins you can skip this tab; talk to us if you want to package workflows as skills.
## 6. Prompts tab: starter prompts
Provide specific **starter prompts** that demonstrate your plugin's key workflows. These are the example prompts ChatGPT surfaces to users.
* Make each prompt concrete and adaptable. "Find pet insurance for a 3-year-old labrador" works; "Help me with insurance" is too generic to demonstrate anything.
* Cover your main tools and use cases, matching the positive test cases below.
## 7. Testing tab: 5 positive and 3 negative test cases
OpenAI reviewers run these to validate your plugin, and they are reviewed carefully.
* **At least 5 positive cases:** scenarios where the plugin should trigger and work. For each, the form asks for the scenario, the user prompt, which tools fire, and the expected output.
* **At least 3 negative cases:** prompts where the plugin should **not** trigger. For each, the form asks for the scenario and the prompt.
Lay them out first with the [Test cases template](/platform/guides/templates/test-cases).
If your plugin requires sign-in, provide the **reviewer demo credentials** here (or where the form asks for them). They must let a reviewer complete every test case **without MFA, SMS, email confirmation, or private-network access**.
## 8. Global tab: regional availability
Decide whether the plugin is worldwide or restricted.
* **All countries:** available in every [ChatGPT-supported country](https://help.openai.com/en/articles/7947663-chatgpt-supported-countries).
* **Specific countries:** list them. Restrict if your service only operates in certain regions or the content is only relevant there.
## 9. Translations and locale
Parts of the form must be in English.
* **English (US)** is always required as the base locale.
* You can add other locales (for example français, español, Deutsch). For each, provide a translated **Subtitle** and **Description**.
* ChatGPT may auto-translate some fields, but your own translations ensure quality.
## 10. Screenshots
Provide screenshots of your plugin's widgets in action.
* **Format:** PNG.
* **Width:** 706px. **Height:** minimum 400px, recommended maximum 860px.
* At least 1 screenshot at 2x (retina quality), maximum 4.
* Each screenshot needs an example user message that would produce what's shown.
* The first three appear on install views and are reused across sizes and locales, so lead with your strongest states.
## 11. Submit tab: policy compliance and release notes
On the final **Submit** tab you confirm that your plugin:
* Complies with [OpenAI's Terms](https://openai.com/policies/connectors-actions-terms/) and [Submission Guidelines](https://developers.openai.com/apps-sdk/app-developer-guidelines), which you have reviewed.
* Complies with applicable industry laws and regulations.
* Does **not** initiate or execute money, crypto, or investment transfers.
* Does **not** serve or display advertisements.
* Uses third-party content and API endpoints you have the rights to.
* Is **not** designed for or marketed to children under 13, and you confirm it is suitable for 13+ or 18+.
You also provide **Release Notes**, a required field that may be shown publicly, then attest that everything is accurate.
Approval is not the same as going live. After OpenAI approves your plugin, you choose **when to publish** from the plugins portal; the listing appears in the ChatGPT Plugin Store only once you publish it.
## Summary checklist
* [ ] "Apps Management" role set to Write, and developer/business identity verified
* [ ] Reviewer demo credentials that work without MFA/SMS/email confirmation
* [ ] Versioned MCP server deployed and reachable
* [ ] Domain verified at `/.well-known/openai-apps-challenge`, tools scanned
* [ ] Light and dark PNG logos (square, no borders)
* [ ] All plugin info fields prepared (mind the 30-character subtitle limit)
* [ ] Tool annotations set in the server with written justifications
* [ ] Skill bundle uploaded, if applicable
* [ ] Starter prompts written
* [ ] 5 positive and 3 negative test cases written
* [ ] Regional availability decided
* [ ] English base locale, plus translations if needed
* [ ] Screenshots (PNG, 706px wide, 400 to 860px tall, max 4)
* [ ] Demo recording ready
* [ ] Privacy Policy and Terms of Service URLs live
* [ ] Commerce disclosures prepared, if applicable
Lay out every tool's annotations and justification text before you open the form.
The 5 positive and 3 negative cases, ready to fill in.
Set up the org, invite us to a seat, hand over the assets once, and the form stops being your problem.
Drive traffic to your listing once it is live.
# Submit to the Claude Connectors Directory
Source: https://docs.waniwani.ai/platform/guides/claude
How to list your Waniwani-built MCP app in Claude's Connectors Directory: the Team/Enterprise prerequisite, what to prepare in advance, and a step-by-step walkthrough of the in-product submission portal.
This guide takes you from zero to a submitted app in Claude's [Connectors Directory](https://claude.com/docs/connectors/building/submission). Apps built on Waniwani have most of the engineering handled (server, tool annotations, assets); your job is to meet one prerequisite, prepare a handful of assets, and walk through the portal.
**You submit from your own Claude account.** Unlike ChatGPT, where Waniwani can submit on your behalf if you prefer, Anthropic's in-product portal requires your own **Claude Team or Enterprise** organization and an Owner of it. Waniwani prepares the server, annotations, assets, and the wording for every field; the final submit happens from your workspace.
## Before you start
Submission happens inside [claude.ai](https://claude.ai), not an external form. To reach the portal you need a Team or Enterprise organization (admin settings are not available on individual or Pro plans).
* [ ] Create a **Claude Team** (or Enterprise) organization at [claude.ai](https://claude.ai).
* [ ] Make sure the person submitting is an **Owner or Primary Owner**. Only Enterprise orgs can delegate this through a custom role with the *Directory management* permission; Team plans keep it with Owners.
* [ ] Find the portal at **Settings → "Your MCP servers" (Beta) → "Submit a server"** (direct link: `https://claude.ai/admin-settings/directory/submissions/new`).
Once approved, the connector appears at [claude.ai/directory](https://claude.ai/directory) for other Claude organizations to install.
## What to prepare
The portal is a wizard of about 11 steps (Introduction, Connection, Tools, Listing, Use cases, Company, Authentication, Data handling, Test and launch, Compliance, Review). Your progress is saved in the browser, but you can only have one submission open at a time, so gather everything below before you start.
### Production MCP server URL
The submission needs a stable production MCP Server URL over HTTPS.
1. Go to [app.waniwani.ai/mcp](https://app.waniwani.ai/mcp) and confirm your app is listed.
2. Use the dropdown next to your app to copy its production MCP URL.
### Tool annotations
This is the most common reason for rejection. Annotations must be set in the server code **before** submission. For managed Waniwani apps they are set for you; see the [Tool justification template](/platform/guides/templates/tool-justification) for what each value means.
What Claude enforces:
* **Every tool needs a `title`** plus the correct hint (`readOnlyHint: true` for reads, `destructiveHint: true` for writes/deletes). These control Claude's auto-permission behavior.
* **Read/write separation.** A single tool may not combine safe reads (GET/HEAD/OPTIONS) with writes (POST/PUT/PATCH/DELETE). Catch-all tools are rejected.
* **Tool names of 64 characters or fewer**, with descriptions that accurately state what the tool does and when it runs (reviewers test against real behavior).
* **Freeform or custom-query tools** (that accept caller-built endpoints, query strings, or bodies) must name or link the target API in their description.
* **No prompt-injection-style instructions** in descriptions. Describe what the tool does, not how Claude should behave. No hidden, encoded, or behavior-overriding directives.
Put the annotations inside the `annotations` object, not at the top level. Set `annotations.title` plus `annotations.readOnlyHint: true` for read-only tools, or `annotations.destructiveHint: true` for tools that write or delete. A top-level `title` alone still trips a "Missing annotations: title" error on the Tools step.
### Listing copy
Claude's character limits differ from ChatGPT's.
| Field | Requirements | Notes |
| --------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | Your connector's display name | Max 100 characters. Short, recognizable, on-brand. |
| **Slug** | URL identifier, prefilled from the name | **Permanent after submission.** Lowercase letters, numbers, and hyphens only. |
| **One-liner** | A short phrase describing function and value | Max 200 characters (the form calls this "One-liner"). Avoid marketing language. |
| **Description** | What the app does and why users value it. Appears publicly. | Max 2,000 characters. Factual, user-centric, no hype. |
| **Categories** | The 1 to 5 categories matching your primary function | The label is **Financial Services** (not "Finance"). Insurance or health apps may also fit **Consumer Health** or **Health & Life Sciences**. |
| **Icon** | Optional custom icon URL | A URL field, not a file upload. SVG and PNG both work. Preview with the **Check icon** button. |
Claude listings are much shorter than ChatGPT's: a tight one-liner plus a sentence or two, not a multi-paragraph blurb. Model yours on live consumer-facing listings with interactive widgets, such as **Era Context** ("Manage your personal finances using Claude") or **Booking.com** ("Find hotels, homes and more"), and note how short the copy is.
### Icon
The icon is an optional custom icon URL in the Listing step. Leave it blank and the directory falls back to your MCP server's favicon.
* SVG works (verified on a live submission); PNG works too. A square asset renders most consistently.
* The favicon fallback often renders dark-on-dark and disappears. Set a contrast-safe custom icon URL and preview it with the **Check icon** button.
* Design guidance is in the [MCP Apps for Claude Figma file](https://www.figma.com/community/file/1597641111449594397/mcp-apps-for-claude).
### Screenshots
Most MCP apps ship interactive widgets, so screenshots are required (a tool-only server with no UI skips this step). Hard requirements:
* **Format:** PNG (no video or GIF).
* **Width:** at least 1000px.
* **Count:** 3 to 5 images.
* **Crop to the app response only.** Do not include the prompt or Claude's chat chrome.
* **Aspect ratio:** any, kept consistent across the set.
* Provide the prompt text separately for each screenshot.
* No separate mobile assets; one batch covers all surfaces.
What to capture:
* Lead with your highest-value widget state (the populated quote card, the storefront answer, the analytics view), not an empty or loading state.
* Show populated data using the same fully-populated test account you give reviewers. No lorem ipsum.
* Use 3 to 5 distinct moments that tell a flow (answer, then personalized quote, then confirmation) rather than near-identical frames.
* Capture the widget at its natural rendered size so text stays legible at 1000px or wider.
* Avoid PII or real customer data; use representative seed data.
### Public URLs
All three must be live and public at review time.
| Asset | Requirement |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| **Documentation URL** | Public setup and usage docs, live by submission. A help-center article, a blog post, or a page in these docs all work. |
| **Privacy Policy URL** | A live, publicly accessible HTTPS page covering what data is collected, why, who sees it, retention, user control, and contact info. |
| **Support URL or email** | A monitored support channel. Required in the Listing step. |
A missing or incomplete privacy policy is an immediate rejection. Open each URL in a private window to confirm it loads without a login.
### Company and contact details
Because you submit from your own org, the listing's company details are **yours**, not Waniwani's.
* [ ] Organization name and details.
* [ ] A primary contact on a working, monitored channel (reviewers may reach out).
## The form, step by step
Work through the wizard in order. Two quirks to know up front:
* **Re-selecting the connector resets fields.** Re-verify everything after any reconnect.
* **One submission at a time.** The wizard behaves like a single session; state is saved in the browser, but you cannot have more than one form submission open at once.
### Connection
Paste your production MCP URL. Set **URL configuration** to **Universal URL** (everyone connects to the same URL). This is correct in almost every case. Only use **Multiple URLs** (a fixed list) or **URL pattern** (a regex, for per-tenant or versioned subdomains) if you are specifically instructed to.
### Tools
Tools, prompts, and resources sync automatically from your server URL. If your annotations are set correctly (see [What to prepare](#tool-annotations)), this step passes on its own.
The portal reads tools from your cached custom-connector schema. If you change the MCP app, reconnect or refresh the custom connector on claude.ai: open it in another tab and refresh the connector you are submitting for.
### Listing
Paste the [listing copy](#listing-copy) you prepared: name, slug, one-liner, description, categories, icon URL, and support URL. Remember the slug is **permanent after submission**, and preview the icon with the **Check icon** button.
### Use cases
The Use cases step asks for:
* **Primary use cases:** the main tasks users accomplish, with a few example prompts.
* **Connection requirements:** what accounts, permissions, or setup a user needs before connecting.
* **Read / write capabilities:** a radio (Read only / Write only / Read and write).
The read/write radio is validated against your tool annotations. Choosing "Read only" while any tool is annotated as a write or destructive triggers an error. Match it to your tools.
### Company
Enter your organization's details and primary contact (see [Company and contact details](#company-and-contact-details)).
### Authentication
In almost every case a Waniwani MCP app needs **no authentication**, so pick *No authentication* unless told otherwise.
If your connector accesses **private user data**, Claude requires **OAuth 2.0**. The Authentication step offers several modes: OAuth with Dynamic Client Registration (DCR), OAuth with a Client ID Metadata Document (CIMD), OAuth with Anthropic-held client credentials, custom URL/credentials at connection time, and no authentication, plus a "Partial auth" toggle.
* With **DCR** (clients self-register, as Waniwani-hosted servers do) there is nothing to register; the form shows it is supported out of the box.
* For **CIMD, Anthropic-held, or static-client** modes, register the redirect URI `https://claude.ai/api/mcp/auth_callback`.
### Data handling
* [ ] **API ownership:** confirm your server calls first-party APIs (or legitimately proxied services). The MCP server domain should match your service domain.
* [ ] **Sensitive data:** flag any sensitive data your tools touch.
* [ ] **External links:** declare allowed link URIs (HTTPS origins or custom URI schemes you own). Third-party or unowned schemes are removed during review.
### Test and launch
Reviewers must be able to exercise every tool end to end. In most cases no special instructions are needed.
If a test account is required, we recommend stating that one will be provided on request, so you do not need it ready at submission, only when a reviewer asks.
* Provide **fully populated** credentials when asked (empty accounts are rejected).
* Provide step-by-step access instructions detailed enough to reach and run every tool.
* Confirm every tool runs end to end via MCP Inspector or as a custom connector in Claude.
### Compliance
Review and acknowledge all of Anthropic's policies (the Software Directory Terms and Policy, and the connector directory guidelines).
Anthropic does not support connectors that execute financial asset transfers (money or crypto) or perform AI image, video, or audio generation. Design tools that produce diagrams or charts are fine.
### Review and submit
* [ ] Resolve any quality warnings shown on the Review step.
* [ ] Submit.
Submitting does not guarantee a listing.
## Common rejection reasons
* Missing tool titles or annotations.
* Behavioral instructions embedded in tool descriptions.
* A missing or incomplete privacy policy.
## References
* [Submitting to the Connectors Directory](https://claude.com/docs/connectors/building/submission)
* [Pre-submission checklist and review criteria](https://claude.com/docs/connectors/building/review-criteria)
* [Connectors Directory FAQ](https://support.claude.com/en/articles/11596036-anthropic-connectors-directory-faq)
* [Submission portal](https://claude.ai/admin-settings/directory/submissions/new)
Lay out every tool's annotations before you submit.
Listing the same app in the ChatGPT Plugin Store.
Drive traffic to your app once it is live.
Add the Waniwani MCP server to Claude, Cursor, or ChatGPT.
# Submit your app to ChatGPT and Claude
Source: https://docs.waniwani.ai/platform/guides/overview
How to list an MCP app built on Waniwani in the ChatGPT Plugin Store and Claude's Connectors Directory: prerequisites, who submits each one, and what each review needs.
Once your app is built and deployed on Waniwani, the next step is getting it listed in the assistant directories so people can find and install it. There are two directories today: the **ChatGPT Plugin Store** and **Claude's Connectors Directory**. This section walks through what each one needs and gives you copy-pasteable templates for the parts reviewers scrutinize most.
A listing is never guaranteed. Both OpenAI and Anthropic review every app for quality and policy, and they may follow up with questions. These guides get you through review with the fewest surprises, but completing a form is not the same as being approved.
## Who submits
Both directories publish the listing under your company name, from an account your organization owns. What differs is who is allowed to sit in the form:
| | ChatGPT Plugin Store | Claude Connectors Directory |
| -------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Who submits | You, or Waniwani from a seat we are invited to in your OpenAI organization | You, from your own Claude organization (required by Anthropic) |
| Account you need | An OpenAI organization with a verified developer or business identity | A Claude **Team or Enterprise** workspace, with you as an Owner |
| Where the listing's "Developer" points | Your company | Your company |
For ChatGPT, the choice is yours: [submit yourself](/platform/guides/chatgpt/you-submit) or [invite us in and have Waniwani handle the mechanics](/platform/guides/chatgpt/we-submit). For Claude, Anthropic requires the listing to come from the app owner's own workspace, so the final submit always happens on your side. In both cases Waniwani prepares everything you paste in.
## What both directories require
Prepare these before you start either form. They are common to both reviews:
The form needs a stable HTTPS MCP URL. Find yours at [app.waniwani.ai/mcp](https://app.waniwani.ai/mcp): your apps are listed there, and the dropdown next to each one copies its production URL. It follows a versioned pattern, for example `https://v1..mcp.waniwani.run/mcp`.
Every tool needs accurate hints (read-only, destructive, and for ChatGPT also open-world) plus a written justification. This is the single most common reason apps get rejected. See the [Tool justification template](/platform/guides/templates/tool-justification). For managed Waniwani apps these are set in the server code for you before submission.
Both reviews require a publicly accessible privacy policy URL and a monitored support URL or email. Open each link in a private browser window to confirm it loads without a login. A missing or gated privacy policy is an immediate rejection.
Claude requires a live documentation URL at review time, and it helps your ChatGPT listing too. These submission guides themselves can serve as your documentation URL if you do not have a dedicated page yet.
A name, a short tagline, a description, and a square logo (a contrast-safe icon that reads on dark backgrounds). The exact limits differ per directory and are covered in each guide.
## Two hard eligibility limits
Both assistants reject the same two categories of app, so confirm yours does neither inside the chat:
* **No moving money or crypto inside the conversation.** Handing the user off to your own site to finish a purchase is fine. Showing a quote or an estimate is fine.
* **No AI generation of images, video, or audio.** Tools that produce diagrams or charts are fine.
## Pick your path
Pick who runs the Plugin Store submission, then follow that path: what you prepare, or the full tab-by-tab form checklist.
The Connectors Directory flow: Team/Enterprise prerequisite, the in-product portal, annotations, and listing details.
Submitting is the start. How to drive traffic to your app and capture it: agent on your site, GEO, and PR.
Copy-pasteable tool justification and test-case templates for the parts reviewers check hardest.
# After you submit: drive traffic to your app
Source: https://docs.waniwani.ai/platform/guides/post-submission
Submitting your app is the start, not the finish. The three levers that get traffic to your app and capture it: putting the agent on your website, GEO, and PR, plus the KPIs to track.
Getting listed is the start, not the finish. Today, ChatGPT and Claude do **not** suggest apps proactively: your app shows up when the assistant searches the web and finds content saying it exists and how to use it. So once you are submitted, the real work is to **drive traffic to the app** and **capture that value on your own site**. This page explains what to do, in what order, and how to measure it.
## The two milestones
1. **Traffic on the app** inside ChatGPT and Claude: installs, conversations, and the quotes or leads it generates.
2. **Traffic on the agent on your website:** sessions, leads captured, and conversion compared to your classic funnel.
## The three levers
| Lever | What it is for |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **Put the agent on your website** | Capture value where you control the experience, without depending on the assistant's recommendation. |
| **Do GEO** | Make the assistants aware the app exists so they suggest it when a user asks. |
| **Do PR and comms** | Create third-party corroboration (assistants weigh information that appears across several sources) and drive direct traffic. |
### Lever 1: put the agent on your website
The fastest way to capture leads is to run the same agent on your own site, where you control the experience and do not depend on an assistant surfacing your app. The Waniwani [chat widget](/sdk/chat/embed) embeds the agent on any page. This is the highest-leverage step, so do it first.
### Lever 2: GEO (generative engine optimization)
GEO is the practice of publishing content that teaches assistants your app exists and how to install it, so they describe it correctly and suggest it when relevant.
It works. We ran a controlled experiment on an app with zero starting authority (NA Drink Finder, an independent ChatGPT app) on a brand-new domain, with no other marketing input, just content: 74 GEO-optimized articles over 33 days, with 1,881 prompts tracked across ChatGPT, Perplexity, and Google AI Overview.
| Metric | Before | After |
| ------------------------------------------------------------------- | ------ | --------------------------------------- |
| ChatGPT describes the app correctly as a ChatGPT app | 4.5% | 45.7% (10x) |
| Hardest case: prompts where the app name is **not** in the question | 0% | about 30% |
| ChatGPT wrongly describes it as a mobile app | 40.9% | 17.1% |
| ChatGPT denies the ChatGPT app exists | 4.5% | 0% |
| Install syntax `@AppName` present in the answer | 0% | up to 50% of answers citing our content |
The takeaway: before GEO, ChatGPT did not suggest the app, described it as a mobile app, or said it did not exist. After publishing content, it described the app correctly **and** explained how to install it. Answers citing our content described the app correctly 2.3x more often.
Read the full write-up at [waniwani.ai/research/chatgpt-app-geo-experiment](https://waniwani.ai/research/chatgpt-app-geo-experiment).
### Lever 3: PR and comms
Comms has two goals: create third-party corroboration and generate direct traffic. A reusable plan for a launch:
1. **Press release** (under 500 words, with a "first" or "new" angle, your site URL, and your app URL).
2. **Pitch one reference industry publication** (one trade outlet beats ten generic tech blogs).
3. **LinkedIn:** one post from the company page and one personal post from the founder (personal posts reach further).
4. **Email** your prospect and partner base announcing the launch.
5. **A results post at day 30 or 60** ("we generated X quotes through ChatGPT in one month"). This is highly citable and creates a second wave of content.
A strong angle compounds: the first live insurance app on ChatGPT got picked up by industry press precisely because "first" is newsworthy. Find the equivalent "first" or "new" angle for your category.
## KPIs to track
**The app (ChatGPT and Claude)**
| KPI | Source |
| ------------------------------------------------------------- | ---------------------------- |
| App installs | OpenAI / Anthropic dashboard |
| Sessions and conversations on the app | Waniwani |
| Quotes or leads generated via the app | Waniwani |
| Redirects to the website (UTM) | Your analytics |
| Share of tracked prompts where the assistant mentions the app | Waniwani (GEO) |
| Answer quality (app described correctly, install flow given) | Waniwani (GEO) |
| Citation rate of your content | Waniwani (GEO) |
**The agent on your website**
| KPI | Source |
| ------------------------------------------------------------------------------- | ---------------------- |
| Sessions on the agent | Waniwani |
| Funnel: visitors, engaged (more than 1 prompt), email captured, qualified leads | Waniwani |
| Email capture rate | Waniwani |
| Conversion vs the classic form funnel | Waniwani and your data |
## What to expect
Set expectations early: the assistants will not surface your app on day one. Treat the weeks after launch as an ongoing program rather than a single event. Review the KPIs above on a regular cadence (a bi-weekly check-in works well), and let the data point you at which lever to push next.
Put the same agent on your website with the chat widget.
The sessions, events, and funnel metrics behind these KPIs.
# Test cases template
Source: https://docs.waniwani.ai/platform/guides/templates/test-cases
A copy-pasteable template for the ChatGPT Plugin Store test-case step: the 5 positive cases where your app should trigger and the 3 negative cases where it should not.
OpenAI reviewers run your test cases to validate your app, so this section is reviewed carefully. You need at least **5 positive** cases (where the app should trigger and work) and at least **3 negative** cases (where it should not). Lay them out here before you open the form, then paste each value in.
For managed Waniwani apps you receive these pre-filled based on your app's tools and behavior. Review them to confirm they match how your app actually works before submitting.
## Positive test cases (minimum 5)
Scenarios where your app **should** trigger and work correctly. For each, the form asks for the scenario, the user prompt, which tools fire, and the expected output.
| # | Scenario | User prompt (paste into form) | Tools triggered | Expected output |
| - | ----------------------------- | ---------------------------------- | --------------- | -------------------------- |
| 1 | What the user is trying to do | The exact prompt a user would type | `tool_name` | What the app should return |
| 2 | | | | |
| 3 | | | | |
| 4 | | | | |
| 5 | | | | |
Add rows for any additional use cases worth covering.
## Negative test cases (minimum 3)
Prompts where your app should **not** trigger. These confirm your app does not activate inappropriately. For each, the form asks for the scenario and a prompt.
| # | Scenario | User prompt (paste into form) | Why the app should NOT trigger |
| - | -------------------- | ---------------------------------- | ------------------------------ |
| 1 | An unrelated request | A prompt outside your app's domain | Why this is out of scope |
| 2 | | | |
| 3 | | | |
## Writing good test cases
* **Positive cases should be realistic.** Use prompts a real user would actually type, phrased naturally, not keyword-stuffed prompts engineered to trigger the app.
* **Cover distinct paths.** Spread the five across different tools and use cases rather than five variations of the same request.
* **Negative cases should be plausibly nearby.** The strongest negatives are requests in an adjacent area where the app should stay quiet, not random unrelated prompts. They show the app has well-defined boundaries.
* **Match the expected output to reality.** Reviewers run these against your live app, so describe what it genuinely returns.
Where this template fits in the full checklist.
The other section reviewers check hardest.
# Tool justification template
Source: https://docs.waniwani.ai/platform/guides/templates/tool-justification
A copy-pasteable template for the ChatGPT Plugin Store tool-annotation step: list every tool, its read-only / open-world / destructive values, and the written justification reviewers require.
The tool-annotation step is the most common reason a ChatGPT submission is rejected. For **every tool** your MCP server exposes, OpenAI requires three annotation values and a written justification for each. This page gives you a template to lay them all out before you open the form, and a reference for what each value means.
For managed Waniwani apps, the annotations are already set in the server code and you receive these justifications pre-filled to review and paste. This template is for laying them out yourself or for checking that the prepared values match what your app actually does.
## How to use this
1. Add one row per tool to the table below.
2. Fill the Yes/No values to match how each tool behaves (use the reference at the bottom if unsure).
3. Write a one or two sentence justification for each value, stating plainly why it is accurate.
4. When the form scans your server, confirm the values it shows match this table, then paste your justifications.
## Tool annotations
Copy this table and fill one row per tool:
| Tool name | Read Only | Read Only justification | Open World | Open World justification | Destructive | Destructive justification |
| ------------- | --------- | -------------------------- | ---------- | -------------------------- | ----------- | -------------------------- |
| `tool_name_1` | Yes / No | Why this value is accurate | Yes / No | Why this value is accurate | Yes / No | Why this value is accurate |
| `tool_name_2` | Yes / No | Why this value is accurate | Yes / No | Why this value is accurate | Yes / No | Why this value is accurate |
| `tool_name_3` | Yes / No | Why this value is accurate | Yes / No | Why this value is accurate | Yes / No | Why this value is accurate |
Add or remove rows so the count matches your server exactly.
## CSP metadata
If any tool interacts with external services, list its domains:
| Tool name | Connect domains | Resource domains |
| ------------- | --------------------- | --------------------- |
| `tool_name_1` | `api.yourcompany.com` | `cdn.yourcompany.com` |
## What each annotation means
| Annotation | Question | Set to **Yes** when | Set to **No** when |
| --------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| **Read Only** | Does this tool only read data without modifying anything? | The tool only fetches or displays data; nothing is created, updated, or deleted. | The tool creates, updates, or deletes any data. |
| **Open World** | Does the tool interact with an open world of external entities, or is its domain closed? | The tool calls an external API or service the user defines (a web search, an arbitrary backend). | The tool only uses local data, renders UI with no external calls, or uses pre-approved internal APIs fully under your control. |
| **Destructive** | Can this tool delete, overwrite, or irreversibly modify data? | The tool can delete records, overwrite data, or cause irreversible changes. | The tool only reads or creates new data without modifying or deleting existing records. |
If the annotations are not set, OpenAI assumes the worst defaults: `readOnlyHint = false`, `openWorldHint = true`, `destructiveHint = true`. That is why setting and justifying them accurately matters. For more background, see the [MCP tool annotations post](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/).
Where this template fits in the full checklist.
The other section reviewers check hardest.
# What is Waniwani?
Source: https://docs.waniwani.ai/platform/introduction
Waniwani turns AI assistants like ChatGPT and Claude into a channel you can show up in, convert from, and measure.
People used to start with a search engine. Now they ask ChatGPT, Claude, and other AI assistants — and they act on what those assistants tell them. That shift moves the first, most important moment of your funnel out of your website and into a conversation you don't control.
**Waniwani is how you show up in that conversation, turn it into a customer, and measure what happened.**
## What Waniwani does
Instead of a static page a person reads, Waniwani lets you publish a guided, multi-step experience that runs *inside* the assistant. The assistant asks the right questions, captures the answers, branches on what it learns, and walks the person all the way to a booking, a quote, a signup, or a qualified lead — without ever leaving the chat.
You get the three things a channel needs to matter:
* **Presence** — be discoverable and citable when an assistant answers questions in your space.
* **Conversion** — run real funnels in the conversation, not just a link out to your site.
* **Measurement** — see which assistants drive sessions, where people drop off, and what converts.
## How it fits together
Waniwani is built on the [Model Context Protocol (MCP)](https://modelcontextprotocol.io), the open standard AI assistants use to connect to outside tools and data. Connecting your funnel to an assistant is the same kind of integration as connecting any other MCP tool — so the same experience works across every MCP-capable assistant at once.
There's an open-source SDK for building the experience, a hosted platform for state, analytics, and content, and a managed MCP server that exposes it to assistants. You'll find each of those in its own section.
## Where to go next
This page is the high-level picture. The rest of the docs go deep:
* **SDK** — build and ship your funnels.
* **MCP Server** — connect Waniwani to ChatGPT, Claude, and other assistants.
* **[Regions & data residency](/platform/regions)** — pick EU or US when you create an organization.
# Regions & data residency
Source: https://docs.waniwani.ai/platform/regions
Waniwani runs in two regions — EU and US. You pick where an organization's data lives when you create it, and everything for that org stays on that stack.
Waniwani runs in **two regions**, EU and US. Each is an independent stack with its own database: an organization's data lives entirely in the region you choose and never crosses to the other.
| Region | Dashboard | MCP server |
| ------ | ---------------------------- | -------------------------------- |
| **US** | `https://app.waniwani.ai` | `https://mcp.waniwani.ai/mcp` |
| **EU** | `https://eu.app.waniwani.ai` | `https://eu.mcp.waniwani.ai/mcp` |
## Choosing a region
You choose a region **when you create an organization**. Before the org-creation wizard runs, Waniwani prompts you to pick your **data residency**:
* **EU** — for teams that need European storage and processing (EU residency requirements).
* **US** — for US-based teams and global teams without EU residency needs.
Your choice applies to **that organization**. Everything downstream — the workspace, members, environments, API keys, conversations, analytics, and stored content — is created on the region's stack and stays there.
Data residency is set per organization and can't be changed after creation. If you need both regions, create a separate organization on each stack.
## Signing in
Sign in on the dashboard for the region your organization lives in:
* **US:** [app.waniwani.ai](https://app.waniwani.ai)
* **EU:** [eu.app.waniwani.ai](https://eu.app.waniwani.ai)
The US dashboard won't show organizations that live in the EU, and vice versa. If you pick a region during onboarding that differs from the stack you signed in on, Waniwani moves you to the right stack and you continue there — your account (email, name, linked sign-in providers) is portable, but sessions and org data always stay local to each region.
## Connecting AI clients
The [Waniwani MCP server](/mcp-server/overview) — the one that lets an AI client operate the platform for you — is also regional. Use the MCP URL for the region your data lives in, and sign in on the matching stack:
* **US:** `https://mcp.waniwani.ai/mcp`
* **EU:** `https://eu.mcp.waniwani.ai/mcp`
See [Connect your AI client](/mcp-server/connect) for the per-region setup in Claude, Cursor, and ChatGPT.
# Service accounts
Source: https://docs.waniwani.ai/platform/scopes
Issue a workspace-wide service-account key, choose the scopes it can hold, and control destructive operations.
When you connect WaniWani to a workspace-wide tool — for example the [Claude-in-Slack connector](/mcp-server/connect) — an organization admin issues a **service-account key** (`wws_…`) from **Settings → Service accounts**. The key acts on its own labelled identity, and the admin chooses up front exactly how much of the organization it may touch:
* a set of **scopes** — the resources the key can read and write, and
* a **destructive-operations** toggle — whether it may deploy, connect a repo, or delete whole resources.
The key is shown once, at creation. You can revoke it at any time, which takes effect immediately.
## Permissions & scopes
A key carries a fixed set of scopes, chosen when you issue it. Each area has a `read` and a `write` scope, and the key holds exactly the ones you select.
A new key is **read-only** — `mcp:read` and `kb:read` — until you add more scopes.
| Scope | Description |
| --------------------- | ------------------------------------------------------------------------------------------ |
| `mcp:read` | View agents and their environments, channels, and deployments |
| `mcp:write` | Create and edit agents, environments, and channels |
| `kb:read` | View knowledge base sources and their content |
| `kb:write` | Add, edit, and remove knowledge base content |
| `analytics:read` | View traffic, sessions, funnels, and insights |
| `analytics:write` | Create and edit funnels and analytics views |
| `evals:read` | View evaluations, evaluators, and their results |
| `evals:write` | Create, edit, and run evaluations |
| `conversations:read` | View personas, their conversations, and shared submissions |
| `conversations:write` | Manage personas and conversation submissions |
| `competition:read` | View tracked competitor companies |
| `competition:write` | Add and edit tracked competitor companies |
| `geo:read` | View GEO visibility: brand presence in AI answers, monitored prompts, and competitor stats |
| `geo:write` | Manage GEO tracking: monitored prompts, sources, and tags |
## Destructive operations
Some actions can't be undone or take a running agent offline. A service-account key may perform them **only when the “Allow destructive operations” toggle is enabled** for that key. The toggle is **off by default** and is independent of the scopes above: a key with `mcp:write` can edit content, but it still cannot run a destructive action unless the toggle is on.
The toggle only affects service-account keys. It does not change what a signed-in teammate can do, and it does not widen the key's scopes — it only unlocks the operations below for scopes the key already holds.
With the toggle enabled, the key may:
**Deploy & connect**
* Clone managed project repo to GitHub
* Connect project to a user-owned GitHub repo
* Deploy the latest commit on an environment's branch
* Migrate a managed project to a user-owned repo
* Promote a channel
* Redeploy a past deployment's commit
* Roll back production to a past deployment
**Delete a whole resource**
* Delete a project
* Delete environment
* Delete environment channel
Everyday content changes — including deleting individual items such as a knowledge-base source, an evaluator, or an email template — need only the matching `write` scope and are **not** gated by this toggle.
# Changelog
Source: https://docs.waniwani.ai/sdk/changelog
Release notes for the Waniwani SDK, including breaking changes and deprecated APIs.
This page tracks API changes that require code updates, organized by the version that introduced them. Deprecations are safe to ignore short-term — the old shape keeps working until the removal version listed in the deprecation notice.
## Deprecations at a glance
| API | Status | Since | Removed in |
| ------------------------------------------------------------------------------------------- | ----------- | ------ | ---------- |
| `track.lead()` (alias of `track.leadQualified()`) | **Removed** | 0.15.1 | 0.16.0 |
| `.addNode(id, run, options?)` (positional) | Deprecated | 0.12.0 | 0.13.0 |
| `createTool`, `createResource`, `registerTools` from `@waniwani/sdk/mcp` | **Removed** | 0.12.0 | 0.20.0 |
| MCP-widget React host (`WidgetProvider`, `useToolOutput`, …) from `@waniwani/sdk/mcp/react` | **Removed** | 0.12.0 | 0.20.0 |
| `toNextJsHandler` from `@waniwani/sdk/next-js` | **Removed** | 0.12.0 | 0.20.0 |
| `toExpressJsHandler` from `@waniwani/sdk/express-js` | **Removed** | 0.12.0 | 0.20.0 |
| Chat-server types from `@waniwani/sdk/chat/server` | **Removed** | 0.12.0 | 0.20.0 |
| `ChatCard` from `@waniwani/sdk/chat` | **Removed** | 0.12.0 | 0.20.0 |
| `evals/*` (entire subtree) | **Removed** | 0.12.0 | 0.12.0 |
TypeScript will strike through deprecated signatures in your IDE. Hover for the replacement.
## Breaking changes at a glance
| Change | Kind | Version | Auto-fixable |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | ------- | ------------------------------------------------ |
| Entire legacy tier deleted: `@waniwani/sdk/legacy*`, `@waniwani/sdk/next-js`, `@waniwani/sdk/express-js`, `@waniwani/sdk/chat/server`, and `ChatCard` from `@waniwani/sdk/chat` | Removal | 0.20.0 | Partly (codemod below; widget hooks need a port) |
| Generic funnel events `quote.requested` / `quote.succeeded` / `quote.failed` / `purchase.completed` removed from the `track()` taxonomy (with their property types and legacy input fields) | Removal | 0.18.0 | Yes (codemod below) |
| `useWaniwani()` no longer auto-discovers config (removed `WidgetProvider` context read + self-connecting host bridge); resolves from explicit `{ endpoint, source }` or a passed `toolResponseMetadata` only | Behavior change | 0.17.0 | Yes (codemod below) |
| `useWaniwani()` returns the typed server `track` surface; string `track(name, props)`, `step()`, `conversion()`, and the `capture` option are removed | API change | 0.16.0 | Yes (codemod below) |
| Widget auto-capture event names (`widget_click`, `widget_scroll`, `widget_form_*`, `widget_link_click`, `widget_error`) removed from `EventType` | Removal | 0.16.0 | Yes (codemod below) |
| Widget config `_meta` key: `waniwani` becomes `waniwani/widget` | Rename | 0.16.0 | Yes (codemod below) |
| `track.lead()` alias removed (deprecated since 0.15.1) | Removal | 0.16.0 | Yes (codemod below) |
| `createTrackingRoute` accepts the V2 batch envelope instead of the snake\_case widget payload | Contract change | 0.16.0 | Yes (codemod below) |
| `track.lead()` → `track.leadQualified()`, event `"lead"` → `"lead_qualified"` | Rename | 0.15.0 | Yes (codemod below) |
| `addConditionalEdge(from, condition)` → `addConditionalEdge(from, to, condition)` | Signature change | 0.14.0 | Yes (codemod below) |
When you bump `@waniwani/sdk` across a minor version (`0.x` minors can break), scan this page for every breaking change between your old and new version and apply the documented migration. Each one below is a mechanical rewrite an agent or codemod can apply in a single pass, followed by `bun run typecheck && bun test`.
**Migrate automatically.** Every version hop with breaking changes ships a self-contained migration skill named `migrate-waniwani-sdk--to-`. Add the one for your hop and an agent applies the whole section for you:
```bash theme={null}
npx skills add Waniwani-AI/sdk -s migrate-waniwani-sdk-0.19-to-0.20
```
***
## 0.20.0: the legacy tier is deleted
Apply this entire section automatically: `npx skills add Waniwani-AI/sdk -s migrate-waniwani-sdk-0.19-to-0.20`, then invoke it from your project on 0.19.x.
0.12.0 moved the MCP-widget-in-host stack and the chat-server BFF adapters to `@waniwani/sdk/legacy*` and kept the old paths re-exporting every symbol. 0.20.0 deletes all of it. There is no shim: these are module-resolution errors at build time, not deprecation warnings.
### Entry points removed
| Entry point | Held |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `@waniwani/sdk/legacy` | `createTool`, `createResource`, `registerTools` and their types |
| `@waniwani/sdk/legacy/react` | `WidgetProvider`, `useWidgetClient`, every widget host hook, `LoadingWidget`, `DevModeProvider`, mocks, `detectPlatform` |
| `@waniwani/sdk/legacy/next-js` | `toNextJsHandler` |
| `@waniwani/sdk/legacy/express-js` | `toExpressJsHandler` |
| `@waniwani/sdk/next-js` | `toNextJsHandler` (0.12 back-compat alias) |
| `@waniwani/sdk/express-js` | `toExpressJsHandler` (0.12 back-compat alias) |
| `@waniwani/sdk/chat/server` | `createApiHandler` types, `extractGeoFromHeaders`, `GeoLocation` |
`@waniwani/sdk`, `@waniwani/sdk/mcp`, `@waniwani/sdk/mcp/react`, `@waniwani/sdk/mcp/react/skybridge`, `@waniwani/sdk/chat`, `@waniwani/sdk/kb` and the two chat assets are untouched.
`ChatCard` and `ChatCardProps` are gone from `@waniwani/sdk/chat`.
### `RegisteredTool` moved rather than disappeared
The OSS flow API's `showWidget` accepts `RegisteredTool | string`, so the type outlived the tier that defined it:
```ts theme={null}
// Before
import type { RegisteredTool } from "@waniwani/sdk/legacy";
// After
import type { RegisteredTool } from "@waniwani/sdk/mcp";
```
The shape is unchanged and structural (`{ id, title, description, register }`), so any object you already pass to `showWidget` still satisfies it.
### `@modelcontextprotocol/ext-apps` is no longer a peer dependency
Only the legacy widget clients imported it. If your app talks to a widget host directly, depend on it yourself; nothing in the SDK asks for it any more.
### Rewrite 1 — `createTool` becomes `registerTool`
`createTool` returned an object with a `register(server)` method. Register on the server instead, and return MCP's content shape from the handler rather than `{ text, data }`.
```ts theme={null}
// Before
import { createTool, registerTools } from "@waniwani/sdk/mcp";
export const searchTool = createTool(
{
id: "search",
title: "Search",
description: "Search the knowledge base",
inputSchema: { query: z.string() },
annotations: { readOnlyHint: true },
},
async ({ query }, { waniwani }) => {
await waniwani?.track.priceShown({ amount: 49, currency: "EUR" });
return { text: `Results for "${query}"`, data: { query } };
},
);
await registerTools(server, [searchTool]);
```
```ts theme={null}
// After
import { extractScopedClient } from "@waniwani/sdk/mcp";
server.registerTool(
"search",
{
title: "Search",
description: "Search the knowledge base",
inputSchema: { query: z.string() },
annotations: { title: "Search", readOnlyHint: true },
},
async ({ query }, extra) => {
const waniwani = extractScopedClient(extra);
await waniwani?.track.priceShown({ amount: 49, currency: "EUR" });
return {
content: [{ type: "text" as const, text: `Results for "${query}"` }],
structuredContent: { query },
};
},
);
```
Mechanical parts of the rewrite:
* `config.id` becomes `registerTool`'s first argument; the rest of `config` becomes the second, minus `id`, `resource`, `invoking`, `invoked` and `autoInjectResultText`.
* `{ text }` becomes `{ content: [{ type: "text" as const, text }] }`.
* `{ text, data }` becomes the same plus `structuredContent: data`.
* The handler's second parameter changes from `{ extra, waniwani }` to MCP's `extra`. Read `extra._meta` directly and get the scoped client with `extractScopedClient(extra)`, still exported from `@waniwani/sdk/mcp`.
* Add `annotations.title`. `createTool` accepted a top-level `title`, and Claude's Connectors Directory requires one inside `annotations`.
For a tool that pauses, branches or holds state across turns, port it to `createFlow` instead of `registerTool`. That is the surface the whole tier was deprecated in favour of.
### Rewrite 2 — widget tools carry their own `_meta`
`createTool` derived widget metadata from `config.resource`. Write it out:
```ts theme={null}
// After — the _meta createTool used to build for a resource-backed tool
_meta: {
"openai/outputTemplate": openaiUri,
"openai/toolInvocation/invoking": "Loading...",
"openai/toolInvocation/invoked": "Loaded",
"openai/widgetAccessible": true,
"openai/resultCanProduceWidget": true,
ui: { resourceUri: mcpUri },
"ui/resourceUri": mcpUri,
}
```
`invoking` and `invoked` default to `"Loading..."` and `"Loaded"`. Add `ui.autoHeight: true` when the resource set `autoHeight`. `createResource` built `mcpUri` as `ui://widgets/ext-apps/{id}.html`; keep whatever URI you already serve.
### Rewrite 3 — drop the chat BFF
`toNextJsHandler`, `toExpressJsHandler` and `createApiHandler` existed so the chat widget could proxy through your backend. `WaniwaniChat` talks to `app.waniwani.ai` directly, so the route goes away entirely.
```tsx theme={null}
// Before — app/api/waniwani/[[...path]]/route.ts
import { toNextJsHandler } from "@waniwani/sdk/next-js";
export const { GET, POST } = toNextJsHandler(client);
// and, on the page
```
```tsx theme={null}
// After — delete the route file
import { WaniwaniChat } from "@waniwani/sdk/chat";
```
Keep a backend of your own only if you were using `beforeRequest` to inject per-visitor context or a self-hosted model. In that case stay on `ChatEmbed` and point `api` at a route you write yourself; the SDK no longer ships the router.
### Rewrite 4 — widget React hooks
`WidgetProvider` and the host bridge hooks (`useToolOutput`, `useCallTool`, `useDisplayMode`, `useSendFollowUp`, and the rest) have no drop-in replacement in the SDK. Widgets belong to the app framework now:
* Tracking from inside a widget: `useWaniwani` from `@waniwani/sdk/mcp/react`, or `@waniwani/sdk/mcp/react/skybridge` when skybridge hosts the widget. Both survive 0.20.
* Everything else (host handshake, display mode, tool calls, follow-ups): use the kit's widget runtime.
***
## 0.18.0: quote and purchase events removed from the taxonomy
Apply this entire section automatically: `npx skills add Waniwani-AI/sdk -s migrate-waniwani-sdk-0.17-to-0.18`, then invoke it from your project on 0.17.x.
The generic funnel events `quote.requested`, `quote.succeeded`, `quote.failed`, and `purchase.completed` are removed from `EVENT_TYPES` and the `TrackEvent` union. They predate the typed revenue taxonomy (`price_shown`, `prices_compared`, `option_selected`, `lead_qualified`, `converted`), which models the same funnel stages with typed properties and flat `track.*` helpers. `link.clicked` is **not** affected and remains a first-class event.
Removed with them:
* The type exports `QuoteSucceededProperties` and `PurchaseCompletedProperties`
* The legacy input fields `quoteAmount`, `quoteCurrency`, `purchaseAmount`, `purchaseCurrency` on `LegacyTrackEvent`
### Breaking: `quote.*` and `purchase.completed` no longer typecheck
#### Before
```ts theme={null}
await client.track({ event: "quote.requested" });
await client.track({ event: "quote.succeeded", properties: { amount: 120, currency: "EUR" } });
await client.track({ event: "purchase.completed", properties: { amount: 490, currency: "EUR" } });
```
#### After
```ts theme={null}
await client.track.priceShown({ amount: 120, currency: "EUR" });
await client.track.converted({ amount: 490, currency: "EUR" });
```
#### Migration
1. `track({ event: "quote.succeeded", properties: { amount, currency } })` (or legacy `eventType: "quote.succeeded"` with `quoteAmount` / `quoteCurrency`) becomes `track.priceShown({ amount, currency })`. Keep any identity fields (`sessionId`, `externalUserId`, `meta`) on the call.
2. `track({ event: "purchase.completed", properties: { amount, currency } })` (or legacy `purchaseAmount` / `purchaseCurrency`) becomes `track.converted({ amount, currency })`.
3. `track({ event: "quote.requested" })` and `track({ event: "quote.failed" })`: delete the call. The funnel start is already covered by the auto-captured `tool.called` and `session.started`.
4. Replace type imports: `QuoteSucceededProperties` with `PriceShownProperties`, `PurchaseCompletedProperties` with `ConvertedProperties`.
No `@deprecated` shim: the names are removed outright, so `tsc` surfaces every call site as an error. After applying, run `bun run typecheck && bun test`.
***
## 0.17.0: `useWaniwani()` is host-agnostic
Apply this entire section automatically: `npx skills add Waniwani-AI/sdk -s migrate-waniwani-sdk-0.16-to-0.17`, then invoke it from your project on 0.16.x.
`useWaniwani()` from `@waniwani/sdk/mcp/react` takes the tool-response `_meta` as data and never opens a host connection of its own. It resolves config (endpoint, source, widget token, session id) from **explicit options** or a **`toolResponseMetadata` object you pass**.
New in this release:
* `@waniwani/sdk/mcp/react/skybridge` — a skybridge-host adapter for `useWaniwani`. It reads skybridge's `useToolInfo().responseMetadata` and feeds it to the core hook, so skybridge widgets call `useWaniwani()` bare. `skybridge` is an optional peer dependency.
* `toolResponseMetadata` option on `useWaniwani` (from `@waniwani/sdk/mcp/react`) — pass the host's tool-response `_meta` on any host you already have it from.
### Breaking: `useWaniwani()` no longer auto-discovers its config
Config resolution is now: explicit `{ endpoint, source }`, or a `toolResponseMetadata` object you pass, and nothing else. The hook does not read a `WidgetProvider` context and does not connect to the widget host to find the config. A bare `useWaniwani()` that relied on that auto-discovery returns a no-op widget (no `sessionId`; `track.*` does nothing).
This break does not surface in `tsc`. A bare `useWaniwani()` still typechecks and fails only at runtime, by tracking nothing. Find call sites by auditing imports of `useWaniwani`, not by chasing type errors.
Why: on an MCP-Apps host the tool-response `_meta` is delivered once, to whichever host bridge is connected and listening at that moment. In a skybridge widget that bridge is skybridge. A hook opening a second connection raced it and missed the one-shot on Claude. The core hook now takes the metadata as data; the skybridge adapter supplies it.
#### Before
```tsx theme={null}
import { useWaniwani } from "@waniwani/sdk/mcp/react";
// relied on auto-discovery via WidgetProvider / a self-opened host connection
const wani = useWaniwani();
```
#### After — skybridge-hosted widget (the common case)
Change the import; the call stays bare.
```tsx theme={null}
import { useWaniwani } from "@waniwani/sdk/mcp/react/skybridge";
const wani = useWaniwani(); // reads skybridge's useToolInfo().responseMetadata
```
#### After — any other host
Pass the metadata you already hold, or an explicit endpoint.
```tsx theme={null}
import { useWaniwani } from "@waniwani/sdk/mcp/react";
const wani = useWaniwani({ toolResponseMetadata }); // the host's tool-response _meta
// bring-your-own backend, unchanged:
const wani2 = useWaniwani({ endpoint: "https://…/v2/track", source: "chatgpt" });
```
#### Migration
1. Grep for imports of `useWaniwani` from `@waniwani/sdk/mcp/react`.
2. If the project uses skybridge (`skybridge` dependency / imports of `skybridge/web`), change the import to `@waniwani/sdk/mcp/react/skybridge` and leave the call bare. Any options passed (`source`, `token`, `sessionId`, `metadata`) are forwarded, so keep them.
3. Otherwise, pass `toolResponseMetadata` (the host's tool-response `_meta`) to the call, or leave an existing explicit `{ endpoint, source }` call as-is.
No `@deprecated` shim: the self-connect path was unreliable (a no-op on Claude) and the `WidgetProvider`-context read belonged to the retired legacy widget host, so both are removed outright. After applying, run `bun run typecheck && bun test`; because `tsc` cannot see this break, the completion check is that every bare `useWaniwani()` call site has moved to one of the forms above.
***
## 0.16.0: one tracking client on every surface
Tracking is a single client that exists on four surfaces: the server (`waniwani()`), tool handlers and flow nodes (the scoped client), MCP-app widgets (`useWaniwani()`), and chat host pages (`chat.track`). The browser surfaces send the same typed events through the same batching transport as the server, with session identity stamped automatically. See [Events](/sdk/tracking/events) and [Widgets & chat](/sdk/tracking/widgets).
Apply this entire section automatically: `npx skills add Waniwani-AI/sdk -s migrate-waniwani-sdk-0.15-to-0.16`, then invoke it from your project on 0.15.x.
New in this release:
* `useWaniwani().track` is the typed `TrackFn`: `track({ event, properties })` plus the flat revenue helpers (`track.priceShown()`, `track.converted()`, ...). One `widget_render` event is emitted automatically on init. The hook works without the legacy `WidgetProvider`.
* `WaniWani.chat.track` / `WaniWani.chat.identify` on the `
```
If you want React-component-level control over the inline mount, see [Chat in React](/sdk/chat/react).
## Sizing
Inline mode only — in `floating` mode the panel sizes itself.
The embed fills its container. Give the `
` a definite size via CSS, or set `data-height` on the script tag:
```html theme={null}
```
A container you leave unstyled is `500px` tall. That default is written with `:where()`, so it carries no specificity and any rule you write beats it — including `height: auto` to let the chat grow with its content. A `ResizeObserver` mirrors the container's `max-height` onto the embed so padding and borders are respected.
## Overriding attributes
You can edit `data-*` attributes on the `
```
`init()` returns an instance with `destroy()` and `sendMessage(text)`. You can also call `window.WaniWani.chat.destroy()` and `window.WaniWani.chat.sendMessage("...")` directly on the global.
`init()` also accepts an `onEvent` callback that mirrors chat lifecycle events (opens, messages, errors, link clicks) into your own analytics — Amplitude, Segment, gtag — with your page's identity attached automatically. See [Widget events (onEvent)](/sdk/chat/widget-events).
## Link visitors to your analytics
By default the embed mints its own anonymous **visitor id**, a stable opaque value persisted in the browser's `localStorage`. It rides on every chat request and tracking event, so a visit is attributable before the first message mints a session (see [Sessions](/sdk/tracking/sessions)).
If your site already tracks visitors with PostHog, Amplitude, Segment, or a first-party cookie, override that id with your own. Waniwani then correlates its sessions and events to the same visitor you see in your analytics, and your server-side MCP tools and flows read the id back as `context.waniwani.visitorId`, so they can send events straight to the same analytics tool the id came from.
There are three ways to set it. Pick the one that matches when your id is available.
### If you know the id up front
Set it declaratively on the script tag:
```html theme={null}
```
Or pass it to `init()` when you initialize programmatically. `init()` accepts a string or a resolver (sync or async), so you can read the id inline:
```js theme={null}
window.WaniWani.chat.init({
token: "wwp_...",
visitorId: "the-id-from-your-system",
// or a resolver: visitorId: () => posthog.get_distinct_id(),
});
```
### If the id resolves asynchronously (the common case)
Analytics SDKs usually assign a distinct id only after they bootstrap, so read it when it's ready and hand it to the widget. `setVisitorId()` is safe to call at any time, before or after `init()`, and the new id applies to the next chat request and tracking event:
```js theme={null}
// PostHog
posthog.onFeatureFlags(() => {
window.WaniWani.chat.setVisitorId(posthog.get_distinct_id());
});
// Amplitude
const amplitudeId = amplitude.getDeviceId();
if (amplitudeId) {
window.WaniWani.chat.setVisitorId(amplitudeId);
}
```
A blank value is ignored, so an id that isn't ready yet never wipes a good one. Read the id the widget will send with `window.WaniWani.chat.getVisitorId()`.
The visitor id identifies a **device / browser**, not a signed-in account. When a visitor logs in, keep the visitor id and additionally call [`identify()`](/sdk/tracking/identify) with your account id, so anonymous and known activity stitch together.
## Self-hosting the JS bundle
If you'd rather not load from a CDN, pin a specific SDK version and serve the bundled file yourself. The `data-*` attributes still come from the dashboard snippet — you're only swapping where `embed.js` is served from:
```bash theme={null}
bun add @waniwani/sdk
cp node_modules/@waniwani/sdk/dist/chat/embed.js public/waniwani-embed.js
```
Then point the dashboard-issued snippet at your local copy:
```html theme={null}
```
## How it works
1. The script reads its own `data-*` attributes (token, channel ID, mode, theming, content overrides).
2. On `DOMContentLoaded` (or immediately, if the DOM is already ready), it mounts the chat via Shadow DOM — inside `[data-waniwani-embed]` in `inline` mode, or in its own anchored container in `floating` mode.
3. The chat calls `app.waniwani.ai/api/mcp/chat` with the project token and streams responses back through Server-Sent Events.
4. Conversation history lives only in memory by default; set `data-enable-thread-history` to persist threads in IndexedDB on the user's device.
No server-side integration on your side.
# Chat in React
Source: https://docs.waniwani.ai/sdk/chat/react
Embed the Waniwani chat as a React component in your app.
Two React components ship from `@waniwani/sdk/chat`. Pick based on who runs the chat backend:
| Component | Use when | Backend |
| ------------------ | ----------------------------------------- | -------------- |
| **`WaniwaniChat`** | You want the hosted chat. **Start here.** | Ours, no setup |
| `ChatEmbed` | You self-host the chat backend | Yours |
The React components mount inline: they render wherever you place them in your layout, and you control the container. There is no launcher or floating bubble in the React path — for that, use the [embed script](/sdk/chat/embed) in `floating` mode.
**Platform feature.** Requires a Waniwani project token (`wwp_...`). [Get one at app.waniwani.ai](https://app.waniwani.ai) or read more about the [Platform](/sdk/platform/overview).
## Install
```bash theme={null}
bun add @waniwani/sdk
```
Add the stylesheet to your app once (e.g. in `_app.tsx` or your root layout):
```ts theme={null}
import "@waniwani/sdk/chat/styles.css";
```
## WaniwaniChat
The hosted chat. Pass your project token and channel ID and it talks to `app.waniwani.ai` directly — no API route, no proxy, no backend work on your side.
```tsx theme={null}
import { WaniwaniChat } from "@waniwani/sdk/chat";
;
```
The system prompt, welcome message, placeholder, suggestions, thread history and tool-call display are configured per channel in the dashboard, so business users can change them without a deploy. Theming has no dashboard equivalent — set it in code via `overrides.appearance`.
### Sizing and placement
`WaniwaniChat` fills its parent. Put it wherever you want it and size the container:
```tsx theme={null}
```
To use it as a full-width page hero, give the wrapper the height you want and let the chat fill it. `className` is applied to the root element if you'd rather style it directly.
### Per-page overrides
The dashboard is the source of truth. Use `overrides` only for a local tweak that doesn't justify a separate channel:
```tsx theme={null}
;
```
| Override | Notes |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title` | Sticky header title |
| `hideHeader` | Force-hide the header when your page already provides chrome |
| `welcomeMessage` | Greeting before the first user message |
| `welcome` | Rich welcome screen (icon, title, suggestion cards). Takes precedence over `welcomeMessage` |
| `placeholder` | Input placeholder |
| `suggestions` | Initial suggestion chips |
| `suggestionOrigins` | Which providers may fill the per-turn pill row: any of `"channel"`, `"page"`, `"flow"`, `"followup"`. Defaults to `["channel", "page", "followup"]` — include `"flow"` to render pills from a flow's `interrupt({ suggestions })` |
| `enableThreadHistory` | Persist threads in IndexedDB |
| `showToolCalls` | `true` (default), `"titles-only"`, or `false` |
| `allowAttachments` | Enable file attachments in the input |
| `appearance` | Theme preset plus per-property overrides. Not dashboard-backed — this is where theming is set — see [Theming](/sdk/chat/theming) |
| `classNames` | Per-slot class overrides — see [Deep customization](#deep-customization) |
| `disclaimer` | AI transparency notice under the input. A string overrides the wording, `false` hides it |
| `locale` | UI language for built-in labels: `"en"`, `"fr"`, `"es"`. Detected from `` / `navigator.language` when unset |
| `messages` | Per-key overrides on the resolved locale catalog, to tweak individual built-in strings |
| `disablePageView` | Opt out of the `page.viewed` event fired once on mount. Set on surfaces where a page view would pollute the funnel |
| `api` | Chat API URL. Rarely needed — defaults to `https://app.waniwani.ai/api/mcp/chat` |
| `mcpServerUrl` | Override the MCP server URL. Rarely needed |
### Tracking from the host page
The `ref` exposes a `ChatHandle` with `track` and `identify` alongside the chat controls. `identify` takes the user id as its first argument, with optional traits second — it is not an object:
```tsx theme={null}
import { WaniwaniChat, type ChatHandle } from "@waniwani/sdk/chat";
const chat = useRef(null);
;
chat.current?.identify(user.id, { plan: "pro" });
chat.current?.track.leadQualified({ /* … */ });
```
Both are present on `WaniwaniChat` only — the bare `ChatEmbed` primitive has no Waniwani credential to send events with. See [Identify](/sdk/tracking/identify).
## ChatEmbed
A bare-bones primitive for when you run the chat backend yourself. `api` is required and there is no default; pass any endpoint implementing the [Vercel AI SDK chat protocol](https://ai-sdk.dev) and the component handles the rest.
Reach for `ChatEmbed` only when self-hosting. If you're using the Waniwani Platform, `WaniwaniChat` gives you the same UI plus dashboard configuration, visitor correlation, and tracking.
```tsx theme={null}
import { ChatEmbed } from "@waniwani/sdk/chat";
;
```
## Link visitors to your analytics
Both components accept a `visitorId` prop to override the anonymous id the widget generates with one you already track (a PostHog / Amplitude / Segment distinct id, your own cookie). Waniwani correlates its sessions and events to the same visitor your analytics sees, and your server-side MCP tools and flows read it back as `context.waniwani.visitorId`. Leave it unset to keep the auto-generated, `localStorage`-persisted id.
`visitorId` accepts a string, or a resolver that returns one. The resolver may be sync or async, which is handy when your analytics SDK only exposes its id after it bootstraps:
```tsx theme={null}
// A string you already hold
;
// A sync resolver
posthog.get_distinct_id()} />;
// An async resolver — the widget uses the auto id until this settles
(await analytics.ready()).anonymousId}
/>;
```
A blank or failed result is ignored, so a not-ready id never wipes a good one. The value is read live on every request, so updating it applies to the next message. See [Sessions](/sdk/tracking/sessions) for how the three correlation ids fit together.
For an expensive async resolver, pass a stable reference (`useCallback`) so it isn't re-invoked on every render.
### Attachments
`ChatEmbed` takes the documents module's state as a prop, since it fetches no remote config of its own:
```tsx theme={null}
;
```
The composer offers a paperclip, a drop target and paste only while `enabled` is true, and the upload endpoint enforces the same flag. `WaniwaniChat` and the script embed read all of this from the dashboard, so they need none of it. See [Documents](/sdk/modules/documents) for what an agent does with the files.
### Authentication
`headers` goes out with every chat request, and document uploads reuse it, so one bearer usually covers both.
```tsx theme={null}
;
```
Set `uploadHeaders` when your upload endpoint takes a different credential than your chat endpoint. It is merged over `headers` on the document upload and discard calls, and leaves the chat request alone.
```tsx theme={null}
;
```
Uploads there go out with the upload token and still carry `X-Tenant-Id`. Only the names you repeat are replaced, and the match ignores case, so `authorization` overrides `Authorization`.
Both land in the browser, so put only public credentials in them.
## Theming
Both components take an appearance preset (`light`, `dark`, or `auto`) with optional per-property overrides layered on top:
```tsx theme={null}
;
```
`auto` follows the host's `prefers-color-scheme`. `variables` accepts every `ChatTheme` token — colors, message-bubble radius/padding/max-width, base font size and line height. The full token and CSS-variable list lives on [Theming & customization](/sdk/chat/theming).
## Deep customization
Two escape hatches when tokens aren't enough:
* **`appearance.assistantBubble`** — opt-in filled bubble for assistant replies (plain text by default), styled by `assistantBubbleColor` / `assistantBubbleTextColor`.
* **`classNames`** — per-slot class overrides merged onto the widget's own classes: `root`, `header`, `message`, `userBubble`, `assistantBubble`, `input`. Pass it directly on `ChatEmbed`, or as `overrides.classNames` on `WaniwaniChat`.
```tsx theme={null}
;
```
Recipes and the list of what remains non-themeable: [Theming & customization](/sdk/chat/theming).
## Mirroring events into your analytics
The hosted `WaniwaniChat` component accepts an `onEvent` prop that fires on chat lifecycle events (messages, session start, errors, link clicks) so you can mirror widget activity into your own analytics — Segment, Amplitude, gtag — with the page's identity attached automatically. `ChatEmbed`, the bring-your-own-backend primitive, does not expose it. See [Widget events (onEvent)](/sdk/chat/widget-events).
# Theming & customization
Source: https://docs.waniwani.ai/sdk/chat/theming
Rebrand the chat widget — colors, typography, message bubbles, and deep CSS customization — on both the script embed and the React component.
Every surface of the chat widget is themeable through the same token system, whether you use the [script embed](/sdk/chat/embed) or the [React component](/sdk/chat/react). Defaults are pinned so an untouched widget looks the same across releases — you only override what you care about.
## Presets
Pick `light` (default), `dark`, or `auto` (follows the visitor's `prefers-color-scheme`):
```html theme={null}
```
```tsx theme={null}
// React
```
## Theme tokens
Layer per-property overrides on top of a preset. The same keys work everywhere:
```tsx theme={null}
// React — ChatEmbed or WaniwaniChat (via overrides.appearance)
```
```js theme={null}
// Script embed — programmatic init
window.WaniWani.chat.init({
token: "wwp_...",
appearance: {
theme: "light",
variables: { primaryColor: "#0a6c74", messageBorderRadius: 20 },
},
});
```
## CSS variables
The widget exposes a `--ww-*` namespace that pierces the Shadow DOM boundary, so the script embed can be themed from your page's own CSS:
```html theme={null}
```
In `floating` mode there is no container of your own, so declare the variables on any ancestor — `:root` is the simplest. They inherit down into the widget either way.
Unset variables fall back to the preset's defaults, and your overrides win in both light and dark modes.
`borderWidth` and `boxShadow` are the panel's opt-in chrome — both are no-ops until you set them, so a widget slotted into your own bordered container doesn't draw a second border. In the script embed they apply to the `[data-waniwani-embed]` container rather than the chat inside it, since the container clips anything drawn within.
| CSS Variable | Theme token | Property | Default (light) | Default (dark) |
| ---------------------------- | -------------------------- | -------------------------------- | --------------- | -------------- |
| `--ww-primary` | `primaryColor` | Primary brand colour | `#6366f1` | `#6366f1` |
| `--ww-primary-fg` | `primaryForeground` | Text on primary | `#1f2937` | `#ffffff` |
| `--ww-bg` | `backgroundColor` | Panel background | `#ffffff` | `#212121` |
| `--ww-text` | `textColor` | Default text colour | `#1f2937` | `#ececec` |
| `--ww-muted` | `mutedColor` | Secondary text | `#6b7280` | `#8e8ea0` |
| `--ww-border` | `borderColor` | Border colour | `#e5e7eb` | `#444444` |
| `--ww-border-width` | `borderWidth` | Panel border width (px) | `0` | `0` |
| `--ww-shadow` | `boxShadow` | Panel box-shadow shorthand | `none` | `none` |
| `--ww-assistant-bubble` | `assistantBubbleColor` | Assistant bubble bg | `#f3f4f6` | `#2f2f2f` |
| `--ww-assistant-bubble-text` | `assistantBubbleTextColor` | Assistant bubble text | `#1f2937` | `#ececec` |
| `--ww-user-bubble` | `userBubbleColor` | User bubble bg | `#f4f4f4` | `#303030` |
| `--ww-user-bubble-text` | `userBubbleTextColor` | User bubble text | `#1f2937` | `#ffffff` |
| `--ww-input-bg` | `inputBackgroundColor` | Input field bg | `#f9fafb` | `#2f2f2f` |
| `--ww-header-bg` | `headerBackgroundColor` | Header background | `#ffffff` | `#1e1e1e` |
| `--ww-header-text` | `headerTextColor` | Header text | `#1f2937` | `#ececec` |
| `--ww-status` | `statusColor` | Status dot | `#22c55e` | `#22c55e` |
| `--ww-tool-card` | `toolCardColor` | Tool call card bg | `#f4f4f5` | `#262626` |
| `--ww-glass-tint` | `glassTint` | Floating bar glass tint | `unset` | `unset` |
| `--ww-glass-tint-opacity` | `glassTintStrength` | Floating bar glass tint strength | `unset` | `unset` |
| `--ww-radius` | `borderRadius` | Panel border-radius | `16px` | `16px` |
| `--ww-msg-radius` | `messageBorderRadius` | Message bubble radius | `8px` | `8px` |
| `--ww-msg-pad-x` | `messagePaddingX` | Message bubble padding X | `16px` | `16px` |
| `--ww-msg-pad-y` | `messagePaddingY` | Message bubble padding Y | `12px` | `12px` |
| `--ww-msg-max-width` | `messageMaxWidth` | Message bubble max width | `80%` | `80%` |
| `--ww-font` | `fontFamily` | Font family | system stack | system stack |
| `--ww-font-size` | `fontSize` | Base message font size | `1rem` | `1rem` |
| `--ww-line-height` | `lineHeight` | Base message line height | `1.5` | `1.5` |
`glassTint` and `glassTintStrength` tint the floating bar's frosted-glass surface — the expanded suggestion card and open panel in `floating` mode — layering a colour over the existing backdrop blur. `glassTint` takes any CSS colour string; `glassTintStrength` is a unitless string from `"0"` to `"1"` setting how strongly the tint is applied. Both are set programmatically through `appearance.variables`:
```tsx theme={null}
// React — tint the floating bar's frosted glass with a brand colour
```
Like `borderWidth` and `boxShadow`, they're additive: leave them unset and the floating bar renders exactly as before.
## Assistant message bubble (opt-in)
Assistant replies render as plain text by default. Turn them into filled bubbles — styled by `assistantBubbleColor` / `assistantBubbleTextColor`, sharing the user bubble's radius and padding — with `assistantBubble`:
```html theme={null}
```
```tsx theme={null}
// React
```
Leave it unset and assistant messages stay bubble-less — existing widgets are unchanged.
## Deep customization
Tokens cover colors, typography, and bubble shape. To restyle an element beyond what a token exposes, target it directly.
**React** — pass `classNames` (available on `ChatEmbed` directly, or `overrides.classNames` on `WaniwaniChat`). Each string is merged onto that slot:
```tsx theme={null}
```
Slots: `root`, `header`, `message`, `userBubble`, `assistantBubble`, `input`.
**Script embed** — the widget renders in a Shadow DOM, so your page's own selectors cannot reach inside. Inject a stylesheet with `data-css` and target the stable semantic classes:
```html theme={null}
```
```css theme={null}
/* widget.css — loaded into the widget's Shadow DOM */
.ww-header { letter-spacing: 0.02em; }
.ww-input { box-shadow: 0 0 0 2px rgba(10, 108, 116, 0.25); }
.ww-message-user .ww-bubble { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); }
.ww-message-assistant .ww-bubble { border: 1px solid #0a6c74; }
```
Stable hooks: `.ww-message`, `.ww-message-user`, `.ww-message-assistant`, `.ww-bubble`, `.ww-header`, `.ww-input`. These are guaranteed selectors — the internal `ww:`-prefixed utility classes are not.
## What is not themeable
To set expectations for a full rebrand, these are intentionally fixed today:
* **Typographic scale beyond the base** — `fontSize` / `lineHeight` set the message base; per-heading sizes inside markdown and built-in label sizes/weights are not individually exposed.
* **Chrome corner radii** — `messageBorderRadius` shapes message bubbles only; tool-call cards, the chain-of-thought accordion, menus, and welcome-screen buttons keep their fixed radii.
* **Floating launcher accent** — the dock's animated border-glow uses a fixed accent gradient.
* **"Powered by" mark** — the footer logo is not removable via theming.
For anything in this list, [reach out](https://waniwani.ai) — some are candidates for future tokens.
Full type definitions: [`ChatTheme` and `ChatClassNames`](https://github.com/WaniWani-AI/sdk/blob/main/src/chat/web/%40types.ts).
# Widget events (onEvent)
Source: https://docs.waniwani.ai/sdk/chat/widget-events
Mirror chat widget activity into your own analytics with the onEvent callback.
The chat widget can mirror its lifecycle events into the host page through a single `onEvent` callback. Both hosted surfaces accept it: `WaniWani.chat.init({ onEvent })` on the script embed, and the `onEvent` prop on the React component.
The embed mounts in your page's DOM (the shadow root isolates styles only), so the callback runs in your page's own JS context. Forward events straight into `window.amplitude`, `window.analytics`, or `gtag` and your page-side analytics SDK attaches its own user identity automatically — no server-side identity plumbing, and no Amplitude/Segment SDK is ever loaded inside the widget.
**When to use which channel.** `onEvent` mirrors widget activity into **your** analytics on the host page — it never leaves the browser unless your callback sends it somewhere. It is separate from platform ingest tracking (`WaniWani.chat.track`, funnel events, the typed event catalog), which sends events **to the Waniwani platform** for funnel analytics. Use `onEvent` to see chat activity next to your existing product analytics; use [tracking events](/sdk/tracking/events) to power Waniwani funnels. The two channels are independent and can be used together.
## The events
| Event | Fired when | Extra `properties` |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chat.ready` | Widget mounted and config resolved, or the readiness timeout elapses (all modes; fires even on pages where visibility rules hide the floating bar) | — |
| `chat.opened` | Full chat panel opened (floating mode only; the collapsed dock does not count) | — |
| `chat.closed` | Full chat panel closed (floating mode only; a page-gate hiding an open panel also emits this) | — |
| `message.sent` | The visitor (or the imperative API, e.g. `sendMessage`) submits a message (never includes the message text) | — |
| `message.received` | Assistant reply finished (never includes the message text) | — |
| `session.started` | Server assigned the session id on the first exchange (restored threads don't re-fire) | `{ sessionId }` |
| `thread.changed` | Thread created or switched (requires thread history) | `{ threadId }` |
| `chat.error` | Chat request failed | `{ message }` (truncated to 200 chars) |
| `suggestion.clicked` | Suggestion pill or welcome card clicked (dock pills, in-chat pills, welcome cards) | `{ text, index, origin }` (`index` is `-1` when the position is unknown; `origin` is `"channel"`, `"page"`, `"flow"`, or `"followup"` — which provider supplied the pill) |
| `link.clicked` | Anchor clicked inside the conversation | `{ url }` (absolute URL) |
Do not assume `chat.opened` precedes the first `message.sent`: a send from the docked bar opens the panel and sends in one action, and the open transition is reported after the send.
**Separate from the `track()` taxonomy.** Some `onEvent` names (`session.started`, `link.clicked`) also exist as event names in the hosted `track()` funnel taxonomy — same concepts, but a separate client-side channel with different payload shapes. `onEvent` events are not `client.track()` events, do not feed the platform funnel dashboard, and should not be forwarded into `client.track()` as-is even where names coincide.
## The payload
Every callback receives a `WidgetEvent` — exported from `@waniwani/sdk/chat` alongside `WidgetEventName` and `WidgetMode`:
```ts theme={null}
import type { WidgetEvent, WidgetEventName, WidgetMode } from "@waniwani/sdk/chat";
// WidgetEvent is a discriminated union on `name`:
// {
// name: WidgetEventName; // one of the 10 events above
// mode: WidgetMode; // "inline" | "floating"
// sessionId?: string; // undefined before the first exchange
// timestamp: number; // epoch milliseconds at emit time
// properties?: { ... }; // per-event extras from the table above
// }
```
`mode` is the embed surface: `"floating"` for the floating bar, `"inline"` for an in-page mount. `` reports `"inline"` — it is an in-page mount, and this is the same `mode` tag its server-side events (chat requests, `page.viewed`) carry, so both streams correlate.
Narrowing on `name` types `properties` automatically:
```ts theme={null}
function handle(event: WidgetEvent) {
if (event.name === "suggestion.clicked") {
// event.properties is { text: string; index: number }
}
}
```
Event names are neutral and fixed — the widget never adopts your analytics naming. Map them to your own schema inside the callback.
## Script embed → Amplitude
`onEvent` is programmatic-only on the script embed: a function cannot be expressed as a `data-*` attribute, so pass it to `WaniWani.chat.init()`.
```html theme={null}
```
Because the Amplitude snippet on your page already identified the user, every mirrored event lands in Amplitude under that same identity.
## React → Segment
The `WaniwaniChat` component takes the same callback as an `onEvent` prop (`chat.opened`/`chat.closed` are floating-embed-only and never fire here):
```tsx theme={null}
import { WaniwaniChat, type WidgetEvent } from "@waniwani/sdk/chat";
function Support() {
const handleWidgetEvent = (event: WidgetEvent) => {
window.analytics.track(event.name, {
mode: event.mode,
sessionId: event.sessionId,
...(event.name === "chat.error"
? { errorMessage: event.properties.message }
: {}),
});
};
return (
);
}
```
## Privacy
Message content never passes through this channel. `message.sent` and `message.received` tell the host *that* a message was exchanged, never what was said. The one text field that does flow through is `suggestion.clicked`'s `text` — suggestion pills are operator-authored config, not user input.
## Error isolation
Exceptions thrown by your callback are swallowed and logged as a console warning; they never break the widget or block other subscribers. Still, keep the callback fast — it runs synchronously on the widget's event path.
`ChatEmbed`, the bring-your-own-backend React primitive, does **not** expose `onEvent`. Like `track`/`identify`, it is a hosted-surface feature of `WaniwaniChat` and the script embed.
# CLI overview
Source: https://docs.waniwani.ai/sdk/cli/overview
The @waniwani/cli connects a local repo to a Waniwani agent and runs your MCP server against the hosted playground in one command.
`@waniwani/cli` is the official command-line tool for [Waniwani](https://app.waniwani.ai). It wires a local repo to a Waniwani agent, runs your MCP server locally against the hosted playground, and removes the manual steps of provisioning GitHub, Vercel, and API keys.
The CLI is **optional**. [`@waniwani/sdk`](/sdk/configuration/installation) works on its own — the CLI is the fastest way to skip the dashboard for project setup and local development.
`waniwani init`, `check`, `build`, `start` and `eject` belong to [`@waniwani/kit`](/kit/commands), the framework that builds an MCP app from a folder, and so does the `dev` that regenerates the app on every change. This page covers the platform CLI: `login`, `logout` and `connect`, plus a `dev` that bridges a local server to the hosted playground.
## Install
```bash bun theme={null}
bun add -g @waniwani/cli
```
```bash pnpm theme={null}
pnpm add -g @waniwani/cli
```
```bash npm theme={null}
npm install -g @waniwani/cli
```
```bash yarn theme={null}
yarn global add @waniwani/cli
```
Requires Node.js 20 or later.
## Quickstart
```bash theme={null}
waniwani login # browser-based OAuth2 PKCE
waniwani connect # pick an org + agent, writes waniwani.json
waniwani dev # run local MCP, open playground bridged to localhost
```
Three commands. Your local MCP server is now reachable from ChatGPT, Claude, and Cursor through the Waniwani playground, with no tunnel setup.
## Commands
| Command | What it does |
| ------------------------------ | --------------------------------------------------------------------------- |
| [`waniwani login`](#login) | Browser-based OAuth2 PKCE. Stores tokens in `.waniwani/settings.json`. |
| [`waniwani logout`](#logout) | Clear local credentials. |
| [`waniwani connect`](#connect) | Pick an org, pick or create an agent, write the binding to `waniwani.json`. |
| [`waniwani dev`](#dev) | Run your MCP locally and open the Waniwani playground against it. |
### login
```bash theme={null}
waniwani login [--no-browser] [--json]
```
Runs the OAuth2 PKCE flow: spins up a local callback server on port `54321`, opens your browser to `app.waniwani.ai`, exchanges the code for an access token, and persists access + refresh tokens to `.waniwani/settings.json` in the current directory.
If you're already logged in with an expired token, it auto-refreshes and exits. Pass `--no-browser` to print the URL instead of launching a browser (useful on headless machines).
### logout
```bash theme={null}
waniwani logout [--json]
```
Clears the stored auth tokens. Keeps non-auth config (like `apiUrl`) intact. No-op if you're already logged out.
### connect
```bash theme={null}
waniwani connect
```
Interactive only. Walks you through:
1. **Pick an organization.** If you're in one org, it's auto-selected.
2. **Pick or create an agent.** Either select an existing project, create a new **managed** agent (Waniwani provisions a GitHub repo + Vercel project from the [MCP distribution template](https://github.com/WaniWani-AI/mcp-distribution-template) with API keys pre-wired), or create a new **external** agent (you host the MCP server; the CLI gives you a production API key to set as `WANIWANI_API_KEY`).
3. **Write the binding.** Writes `waniwani.json` at the repo root with `$schema`, `orgId`, and `projectId`. If the file already exists, the CLI merges the keys in; if the shape isn't recognized, it prints the snippet for you to paste. If a legacy `waniwani.config.ts` is present, it's removed after the JSON file is written.
Re-run anytime to switch the repo to a different agent.
#### Managed vs external agents
| | Managed | External |
| --------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| Where the server runs | Waniwani-hosted Vercel project | Your infra (Docker, Cloudflare Workers, fly.io, etc.) |
| Source code | Auto-provisioned from [mcp-distribution-template](https://github.com/WaniWani-AI/mcp-distribution-template) | Yours |
| API key | Pre-configured in the Vercel env | Printed once by the CLI — save it as `WANIWANI_API_KEY` |
| Deploys on | Push to `main` | Whatever you already use |
See [Deployment](/sdk/deployment/overview) for the full picture.
### dev
```bash theme={null}
waniwani dev [-p ]
```
Interactive only. The end-to-end local dev loop:
1. Ensures you're logged in (runs `login` if not) and that `waniwani.json` has a `projectId` (runs `connect` if not).
2. Detects your package manager (`bun` / `pnpm` / `yarn` / `npm`) and spawns the dev script with `PORT` set.
3. Waits for the local server to bind to the port.
4. Creates a dev session against the Waniwani API and starts a heartbeat.
5. Opens the playground at `app.waniwani.ai/agents//playground?localMode=1` so chat traffic routes to your local server.
6. Ctrl-C tears the dev session down cleanly.
**Options:**
* `-p, --port ` — Port the local MCP listens on. Defaults to `devPort` in `waniwani.json`, or `3000`.
`dev` runs your existing dev script — it does not start the MCP for you. The script must bind to `process.env.PORT`.
## Global flags
| Flag | Description |
| ----------- | -------------------------------------------------------------------------------------------------------------------- |
| `--json` | Output as JSON. Supported by `login` and `logout`. Interactive commands (`connect`, `dev`) error out under `--json`. |
| `--verbose` | Enable verbose logging. |
| `--version` | Print the CLI version. |
| `--help` | Print help. |
## Configuration
### Auth & local state — `.waniwani/settings.json`
Per-repo, gitignored. Holds OAuth tokens and the resolved API URL:
```json theme={null}
{
"apiUrl": "https://app.waniwani.ai",
"accessToken": "…",
"refreshToken": "…",
"expiresAt": "2026-05-20T12:00:00Z",
"clientId": "…"
}
```
Access tokens auto-refresh on 401. Add `.waniwani/` to your `.gitignore`.
### Project config — `waniwani.json`
Shared with [`@waniwani/sdk`](/sdk/configuration/installation). `waniwani connect` writes `$schema`, `orgId`, and `projectId`; other fields are optional and consumed by the SDK and the `dev` command:
```json waniwani.json theme={null}
{
"$schema": "https://docs.waniwani.ai/waniwani.json",
"orgId": "org_…",
"projectId": "proj_…",
"devPort": 3000
}
```
The `$schema` field unlocks autocomplete and validation in editors that support JSON Schema (VS Code, JetBrains, Neovim with LSP). It's ignored at runtime.
Legacy `waniwani.config.ts` files are still read for now (with a deprecation warning), but new projects should use `waniwani.json`. Running `waniwani connect` against a repo that has a `.ts` file removes it and writes a `.json` in its place.
### Environment variables
| Variable | Purpose |
| ------------------ | ----------------------------------------------------------------------------------------------------- |
| `WANIWANI_API_URL` | Override the API base URL. Defaults to `https://app.waniwani.ai`. |
| `WANIWANI_API_KEY` | Use a long-lived API key instead of OAuth (useful in CI). See [API keys](/sdk/configuration/api-key). |
Resolution order for the API URL: env var → `waniwani.json` → `.waniwani/settings.json` → default. For auth: API key → OAuth tokens (with auto-refresh).
## Typical workflow
```bash theme={null}
# One-time
bun add -g @waniwani/cli
waniwani login
# Per-project
cd my-mcp-server
waniwani connect # creates / picks the agent, writes waniwani.json
waniwani dev # runs the dev loop bridged to the playground
```
To start a fresh project, clone the template first:
```bash theme={null}
git clone https://github.com/WaniWani-AI/mcp-distribution-template.git my-mcp-server
cd my-mcp-server
bun install
waniwani connect
waniwani dev
```
## For AI coding agents
If you're [Claude Code](https://claude.com/claude-code), Cursor, or another AI agent setting up Waniwani in a user's repo:
1. Confirm the user is in an MCP project (`@waniwani/sdk` in `package.json`, or it's the [distribution template](https://github.com/WaniWani-AI/mcp-distribution-template)). If not, scaffold one with the [SDK quickstart](/sdk/quickstart) first.
2. Run `waniwani login` — opens the browser; the user authenticates once.
3. Run `waniwani connect` — interactive; ask the user which org / agent they want, or recommend "managed" if they don't already host.
4. Run `waniwani dev` to verify the bridge works.
The CLI is interactive — `connect` and `dev` cannot run under `--json`. Pair it with the [agent skill](/sdk/guides/skills) for code generation:
```bash theme={null}
bunx skills add Waniwani-AI/sdk -s waniwani-sdk
```
The skill teaches your agent the SDK APIs (flows, tracking, widgets, knowledge base). The CLI handles platform wiring. They're independent — install whichever you need.
## Source
* **npm:** [`@waniwani/cli`](https://www.npmjs.com/package/@waniwani/cli)
* **GitHub:** [Waniwani-AI/cli](https://github.com/WaniWani-AI/cli)
* **Issues:** [github.com/WaniWani-AI/cli/issues](https://github.com/WaniWani-AI/cli/issues)
MIT licensed, like the rest of the Waniwani stack.
# Waniwani vs LangGraph
Source: https://docs.waniwani.ai/sdk/compare/vs-langgraph
When to use createFlow vs LangChain's LangGraph for MCP funnels and multi-step conversational tools.
LangGraph is LangChain's graph framework for agent workflows. `@waniwani/sdk` is a graph framework specifically for MCP funnels. The two overlap. They are not interchangeable.
This page is for developers who already know LangGraph and are evaluating whether to use it for a multi-step MCP tool.
## TL;DR
| | LangGraph | `@waniwani/sdk` (createFlow) |
| ------------------------------ | ------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| **Scope** | General-purpose agent graphs | MCP funnels specifically |
| **Primary output** | A runnable graph | An MCP tool registered on a server |
| **State persistence** | Pluggable checkpointer, Python-first | Pluggable KvStore, MCP-session-keyed |
| **Built-in funnel ergonomics** | Build them yourself | Built in: interrupts, re-ask on validation, multi-field interrupts, auto-skip filled fields, widget signals |
| **MCP integration** | None, you wire it | One call: `flow.register(server)` |
| **Typed state** | Pydantic / TypedDict | Zod, end-to-end type inference |
| **Language** | Python first, JS port secondary | TypeScript only |
| **License** | MIT | MIT |
If you are building a general-purpose agent (multiple LLM calls, retrievers, tool selection, planning), pick LangGraph. If you are building a funnel that runs as one MCP tool inside ChatGPT or Claude, pick `createFlow`.
## What each one is
**LangGraph** is a graph runtime. You declare nodes (functions), edges (direct or conditional), and a shared state object. The runtime drives the graph. It is unopinionated about what the nodes do, what the state contains, or how the graph is exposed.
**`createFlow`** is also a graph runtime. The difference is opinion. A flow compiles to a single MCP tool. Nodes return state updates, interrupt signals, or widget signals. Interrupts pause the graph to ask a typed question, validate the answer, and re-ask on failure. State is keyed by the MCP session id and persisted to a `KvStore`. The graph is funnel-shaped on purpose.
## When LangGraph is the better choice
* You need an agent that plans, picks tools, and loops over its own output.
* You want multiple LLM calls inside the graph itself, not just a single tool surface.
* You need retrievers, parallel branches, or supervisor patterns.
* Your team is Python-first and the rest of your stack lives in LangChain.
* You are not building for MCP, or MCP is one surface among many.
## When `createFlow` is the better choice
* You are building a multi-step MCP tool: sales funnel, lead generation, booking, quote, intake.
* You want deterministic step order with typed validation, not agent improvisation.
* You want to skip the work of wiring an MCP tool around your graph.
* You want state persistence keyed to the MCP session, not a homegrown checkpointer.
* You want widget cards for confirmation steps without inventing a protocol.
## Side by side: a two-question funnel
The simplest case where the difference shows up.
**LangGraph (conceptual):**
```python theme={null}
from langgraph.graph import StateGraph, START, END
from typing import TypedDict
class State(TypedDict, total=False):
name: str
email: str
def ask_name(state):
# You implement: pause the graph, expose a question to whatever UI is
# driving, parse the response, validate, re-ask on failure, write to state.
...
def ask_email(state):
# Same again. Validation, re-ask, persistence: all on you.
...
graph = StateGraph(State)
graph.add_node("ask_name", ask_name)
graph.add_node("ask_email", ask_email)
graph.add_edge(START, "ask_name")
graph.add_edge("ask_name", "ask_email")
graph.add_edge("ask_email", END)
app = graph.compile()
# You still have to: expose this as an MCP tool, route the MCP session id to
# the checkpointer, decide how interrupts are rendered to the model, etc.
```
**`createFlow`:**
```ts theme={null}
import { createFlow, END, MemoryKvStore, START } from "@waniwani/sdk/mcp";
import { z } from "zod";
const flow = createFlow({
id: "intake",
title: "Intake",
description: "Collect name and email.",
state: {
name: z.string().describe("Full name"),
email: z.string().email().describe("Email address"),
},
})
.addNode({
id: "ask_name",
run: ({ interrupt }) => interrupt({ name: { question: "What's your name?" } }),
})
.addNode({
id: "ask_email",
run: ({ interrupt }) => interrupt({ email: { question: "What's your email?" } }),
})
.addEdge(START, "ask_name")
.addEdge("ask_name", "ask_email")
.addEdge("ask_email", END)
.compile({ store: new MemoryKvStore() });
await flow.register(server);
```
Both are graph definitions. The `createFlow` version compiles to a working MCP tool. The LangGraph version is a graph that still needs to be wrapped, exposed, and given an interaction protocol.
## What you would re-implement on top of LangGraph
If you choose LangGraph for an MCP funnel, you take on:
1. **MCP tool surface.** Declare the tool, marshal arguments, route results.
2. **Session-keyed state.** Map the MCP session id to a checkpointer thread id.
3. **Interrupt semantics.** Pause the graph, emit a structured question, resume on the next tool call.
4. **Validation loop.** Re-ask on schema failure without advancing the graph.
5. **Auto-skip filled fields.** If state already has `email`, skip `ask_email`.
6. **Widget signals.** If you want non-text rendering (cards, pickers), invent a protocol.
7. **Multi-field interrupts.** Ask several questions in one turn when the model can fill them at once.
These are the funnel ergonomics `createFlow` ships built in. None are hard individually. Together they are a project.
## What `createFlow` does not do
To be clear about scope:
* **No agent loop.** A flow is a deterministic graph driven by tool calls, not an LLM-controlled agent.
* **No retrieval.** Use [`@waniwani/sdk/kb`](/sdk/knowledge-base/overview) or your own retriever inside a node.
* **No Python.** TypeScript only.
* **No graph composition primitives** (subgraphs, supervisors). A flow is a single graph compiled to a single tool.
If you need any of those, LangGraph or a hybrid is the right call. You can also call into a LangGraph (or any other) agent from inside a flow node.
## Migration sketch
If you have a LangGraph workflow that is funnel-shaped:
1. Replace `TypedDict` state with a Zod schema map.
2. Replace each node body with either `interrupt({...})`, a state-returning function, or a widget signal.
3. Replace `add_edge` and `add_conditional_edges` with `.addEdge` and `.addConditionalEdge`.
4. Replace the checkpointer with a [KV store adapter](/sdk/flows/kv-store).
5. Call `.register(server)` instead of wiring an MCP tool by hand.
The graph shape stays. The wrapping disappears.
## Next
The distribution shift behind the SDK.
A complete flow in under 30 lines.
Nodes, edges, interrupts, branching.
Redis, Upstash, Cloudflare KV, DynamoDB.
# API Keys
Source: https://docs.waniwani.ai/sdk/configuration/api-key
Create, configure, and rotate Waniwani API keys.
Every Waniwani client authenticates with a single API key scoped to one **MCP environment**. When you create a project, two environments are provisioned automatically: **Staging** and **Production**, each with its own unique API key.
The key you use determines which environment your data (events, sessions, knowledge base) is stored in. Use the Staging key during development and the Production key for live traffic. This keeps your data cleanly separated.
## Managed projects
Both API keys (Staging and Production) are pre-configured in your deployed instance. You can view and rotate them from your MCP app page under **Settings > API Keys**.
No manual setup needed for managed projects. API keys are pre-configured in your environment.
## Self-hosted projects
When you create a self-hosted project, you get your **Production** API key right away. Set it in the environment where your MCP server runs:
```bash .env theme={null}
WANIWANI_API_KEY=wwk_...
```
```ts theme={null}
import { waniwani } from "@waniwani/sdk";
const wani = waniwani(); // reads WANIWANI_API_KEY from env
```
Manage the variable wherever you normally keep secrets (`.env` file, your platform's secret manager, etc.).
To set up a **Staging** environment, create one from your project's dashboard and use its key for local dev and preview deploys.
API keys grant rights to ingest events and mint widget tokens for their environment. Treat them as server-side secrets. Never commit them to git, never ship them in client-side bundles.
## Per-environment keys
Use the **Staging** key during development and the **Production** key for your live deployment. Mixing environments pollutes your metrics and makes rotation harder.
| Environment | When to use | Where the env var lives |
| ----------- | ----------------------------- | ------------------------------------- |
| Production | Live traffic | Platform secret manager |
| Staging | Local dev and preview deploys | `.env` (gitignored) / preview secrets |
## Rotate a key
Rotate from the environment settings in the dashboard. Clients using the old key start receiving `401 Unauthorized`; the SDK's transport detects auth failures and stops retrying, so you get a clean shutdown instead of a retry storm.
## Troubleshooting
If events do not appear in the dashboard:
1. Confirm `WANIWANI_API_KEY` is set in the process running the MCP server (setting it only in your shell is not enough).
2. Check for typos. The key is a single string, no quotes, no trailing whitespace.
3. Look for `[waniwani]` lines in your server logs.
4. Trigger a tool call. The dashboard only shows events from real traffic.
## Also supported
Pass the key explicitly when you need multiple clients in one process (multi-tenant setups):
```ts theme={null}
import { waniwani } from "@waniwani/sdk";
const wani = waniwani({ apiKey: process.env.TENANT_A_KEY });
```
Next: [wrap your MCP server](/sdk/configuration/wrap-server).
# Environment Variables
Source: https://docs.waniwani.ai/sdk/configuration/environment-variables
All environment variables recognized by the Waniwani SDK.
The SDK reads the following environment variables at runtime. None are required at import time; they are checked when the relevant feature is used.
## Authentication
| Variable | Required | Description |
| ------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `WANIWANI_API_KEY` | Yes | API key for your MCP environment. See [API Keys](/sdk/configuration/api-key). |
| `WANIWANI_API_URL` | No | Override the API base URL. Defaults to `https://app.waniwani.ai`. Use `https://eu.app.waniwani.ai` for the EU region. |
## Logging
By default the SDK is silent. Set one of these to enable internal logs (prefixed `[waniwani:*]`):
| Variable | Values | Description |
| -------------------- | -------------------------------------- | ----------------------------------------------------------- |
| `WANIWANI_LOG_LEVEL` | `debug` \| `warn` \| `error` \| `none` | Controls SDK log verbosity. Defaults to `none`. |
| `WANIWANI_DEBUG` | Any truthy value | Legacy shorthand. Equivalent to `WANIWANI_LOG_LEVEL=debug`. |
If both are set, `WANIWANI_LOG_LEVEL` takes precedence.
```bash .env theme={null}
# Enable debug logs
WANIWANI_LOG_LEVEL=debug
```
## Encryption
| Variable | Required | Description |
| ------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `WANIWANI_ENCRYPTION_KEY` | No | Base64-encoded 32-byte key for AES-256-GCM encryption of KV store values. When set, all values written through the KV store are encrypted at rest. |
# Installation
Source: https://docs.waniwani.ai/sdk/configuration/installation
Install @waniwani/sdk in a new or existing project.
## Choose your approach
Clone a ready-to-go MCP app (server + widgets + UI) with the SDK pre-configured. Best for **new projects**.
Install the SDK package into your existing MCP server or app. Best for **existing projects**.
The SDK is open source and works without an API key. Set `WANIWANI_API_KEY` to connect the [Waniwani Platform](/sdk/platform/overview) and unlock hosted state, tracking, knowledge base, and the chat widget on top.
***
## Option A: Start from template
The [MCP Distribution Template](https://github.com/WaniWani-AI/mcp-distribution-template) gives you a production-ready **MCP app** (server + widgets + UI) with the SDK, event tracking, flows, and widgets already wired up.
The template is an MCP app. If you only need a bare MCP server (no widgets or embedded UI), skip to [Option B](#option-b-add-to-existing-project) or follow the [Quickstart](/sdk/quickstart). The SDK works for both.
Click **"Use this template"** on [GitHub](https://github.com/WaniWani-AI/mcp-distribution-template) to create a new repository from the template, or clone it directly:
```bash theme={null}
git clone https://github.com/WaniWani-AI/mcp-distribution-template.git my-mcp-server
cd my-mcp-server
```
```bash theme={null}
bun install
cp .env.example .env
bun dev
```
The template runs without an API key. Set `WANIWANI_API_KEY` in `.env` to connect the [Platform](/sdk/platform/overview) and enable hosted state, tracking, and the dashboard.
The template includes a reference flow (Alpine ski lessons) you can replace with your own. If you have the [agent skill](/sdk/guides/skills) installed, run `/waniwani-sdk initialize` to interactively scaffold a flow tailored to your business.
***
## Option B: Add to existing project
### Requirements
* Node.js 18.17 or newer (Node 20+ recommended)
* An MCP server runtime. The SDK is tested against:
* [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk)
* [Skybridge](https://github.com/alpic-ai/skybridge) (Alpic)
* [`@vercel/mcp-handler`](https://www.npmjs.com/package/@vercel/mcp-handler)
* A Waniwani account at [app.waniwani.ai](https://app.waniwani.ai) — optional, only needed for [Platform](/sdk/platform/overview) features.
`@modelcontextprotocol/sdk` and `zod` are peer dependencies. Install them in your project if you do not already have them.
### Install
```bash bun theme={null}
bun add @waniwani/sdk
```
```bash pnpm theme={null}
pnpm add @waniwani/sdk
```
```bash npm theme={null}
npm install @waniwani/sdk
```
```bash yarn theme={null}
yarn add @waniwani/sdk
```
The SDK has zero runtime dependencies and targets a sub-5KB core bundle. It runs in long-lived Node processes, serverless functions, and edge runtimes.
### Pick what you need
Three things you can add independently to an existing MCP server. Any subset works.
| Add | What it gives you | API key |
| ---------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `createFlow` | Multi-step typed flows registered as a single MCP tool. The primary entry point. | Not required. Required only if you want hosted state instead of bringing a `KvStore`. |
| `withWaniwani(server)` | Wraps existing `registerTool` handlers to emit tracking events and bridge session IDs. No handler changes. | Required for events. Bridging works without. |
| `waniwani()` client | Direct calls to Platform APIs (custom events, `identify()`, KB search). | Required. |
### Add a flow to an existing server
Assume you already have an `McpServer` instance with some tools registered on it. Define a flow and `.register()` it alongside them:
```ts theme={null}
import { createFlow, END, MemoryKvStore, START } from "@waniwani/sdk/mcp";
import { z } from "zod";
import { server } from "./mcp-server"; // your existing McpServer
const onboardingFlow = createFlow({
id: "onboarding",
title: "User Onboarding",
description: "Use when a new user wants to get started.",
state: { name: z.string().describe("Their name") },
})
.addNode({
id: "ask_name",
run: ({ interrupt }) =>
interrupt({ name: { question: "What is your name?" } }),
})
.addEdge(START, "ask_name")
.addEdge("ask_name", END)
.compile({ store: new MemoryKvStore() });
await onboardingFlow.register(server);
```
`MemoryKvStore` is fine for development and tests. For production, plug in a real backend (Redis, Upstash, Cloudflare KV, DynamoDB) or set `WANIWANI_API_KEY` to use hosted state. See [KV store adapters](/sdk/flows/kv-store).
### Wrap an existing server for Platform tracking
If your server already has plain `registerTool` handlers and you just want them tracked, wrap it once. No changes to the handlers themselves:
```ts theme={null}
import { withWaniwani } from "@waniwani/sdk/mcp";
import { server } from "./mcp-server"; // your existing McpServer
withWaniwani(server);
```
```bash .env theme={null}
WANIWANI_API_KEY=wwk_...
```
Every wrapped tool call now emits a `tool.called` event to your [Waniwani dashboard](https://app.waniwani.ai). Without an API key, the wrapper still bridges session IDs and forwards widget metadata but emits no events. Safe to wrap unconditionally. See [Wrap your server](/sdk/configuration/wrap-server) for all options.
## Create a Platform client
The `waniwani()` client is needed only when you call Platform APIs directly (custom event tracking via `track()`, `identify()`, KB search). It is **not** required for `createFlow` or `withWaniwani`.
```ts theme={null}
import { waniwani } from "@waniwani/sdk";
const wani = waniwani();
```
With no arguments, the client reads `WANIWANI_API_KEY` from the environment and uses `https://app.waniwani.ai` as the base URL.
See [API keys](/sdk/configuration/api-key) for how to obtain and configure the key, and [Client & KV store reference](/sdk/reference/kv-store-api) for the full client config.
## Also supported
* **Project config file.** Drop a `waniwani.json` at the project root with `$schema` set to `https://docs.waniwani.ai/waniwani.json`. The SDK auto-loads it on the first `waniwani()` call; the CLI uses the same file for `connect`/`dev`/evals. No explicit import required.
Next: [get your API key](/sdk/configuration/api-key) (if you want Platform features), then [wrap your MCP server](/sdk/configuration/wrap-server).
# Security
Source: https://docs.waniwani.ai/sdk/configuration/security
Encryption at rest, key management, and security best practices.
## Encryption at rest
Flow state is stored server-side via the Waniwani KV store. The SDK supports **AES-256-GCM encryption** -- values are encrypted before they leave your MCP server process and decrypted on read. The Waniwani server never sees plaintext flow state.
Encryption is automatic. Waniwani generates and manages the key for you -- nothing to configure.
You generate a key and add it to your MCP server's environment.
***
### Managed projects
Encryption is enabled automatically. Waniwani generates and stores the encryption key as an environment variable in your deployed instance. No setup is needed -- flow state is encrypted out of the box and the key is never exposed.
### Self-hosted projects
You need to generate a key and add it to your MCP server's environment:
```bash theme={null}
openssl rand -base64 32
```
```bash .env theme={null}
WANIWANI_ENCRYPTION_KEY=
```
The SDK picks up the key automatically -- no code changes required. When the key is not set, values are stored as plain JSON.
### How it works
When `WANIWANI_ENCRYPTION_KEY` is set:
1. **On write** -- the SDK serializes the value to JSON, encrypts it with AES-256-GCM using a random 12-byte IV, and stores an encrypted envelope in the KV store.
2. **On read** -- the SDK detects the encrypted envelope, decrypts the ciphertext, and returns the original value.
### Key rotation
The SDK does not support automatic key rotation. To rotate:
1. Set the new key in your environment.
2. Trigger each active flow so its state is re-read with the old key (which will fail) and re-written with the new key.
In practice, the simplest approach is to let existing sessions expire naturally and apply the new key to new sessions only.
If you lose the encryption key, encrypted flow state cannot be recovered. Back up the key in your secret manager.
## API key handling
API keys (`WANIWANI_API_KEY`) authenticate your MCP server to the Waniwani platform. Treat them as server-side secrets:
* Never commit them to version control.
* Never include them in client-side bundles.
* Store them in your platform's secret manager or a gitignored `.env` file.
* Use separate keys for Staging and Production environments.
See [API Keys](/sdk/configuration/api-key) for setup and rotation details.
## Transport security
All communication between the SDK and the Waniwani API uses HTTPS (TLS 1.2+). No additional configuration is needed.
## Tenant isolation
Each API key is scoped to a single MCP environment. The Waniwani API enforces tenant isolation at the key level -- one key cannot access another environment's data (events, sessions, KV store, knowledge base).
# Wrap your MCP server
Source: https://docs.waniwani.ai/sdk/configuration/wrap-server
Optional convenience wrapper that adds tool tracking, session bridging, and widget metadata forwarding.
`withWaniwani(server)` is an optional convenience that instruments an MCP server. It activates Platform tracking when an API key is configured, and stays a no-op-but-still-useful wrapper when it isn't.
**Not required for OSS flows.** `createFlow` works without `withWaniwani`. Use this wrapper when you want auto-tracking, session-id bridging, or widget metadata forwarding on top.
## What it does, with `WANIWANI_API_KEY`
Every tool invocation produces a `tool.called` event containing name, type, duration, status, input, output, client info, and session correlation. Flow graphs are synced to the dashboard so the funnel view lights up automatically.
```ts theme={null}
import { withWaniwani } from "@waniwani/sdk/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const server = new McpServer({ name: "my-mcp-app", version: "0.0.1" });
server.registerTool(/* ... */);
withWaniwani(server);
```
## What it does, without an API key
The wrapper still adds two genuinely useful things, even with no key configured:
1. **Transport session-ID bridging.** When the MCP transport carries a session ID via the `Mcp-Session-Id` header but the request `_meta` doesn't include one, the wrapper copies it into `_meta.waniwani/sessionId` so flow nodes and downstream tools can see the correlation.
2. **Widget definition metadata forwarding.** Widget keys declared on a tool's definition `_meta` (`openai/outputTemplate`, `ui/resourceUri`, etc.) are forwarded into each tool result's `_meta`, so chat UIs that only see results can still render widgets.
The wrapper's own auto-capture (`tool.called`) silently no-ops without a key, so wrapping unconditionally is safe. Your own `client.track.*` and `identify()` calls are different: they throw `WANIWANI_API_KEY is not set` when no key is configured, so guard them (`waniwani?.track...` on the scoped client) on code paths that must also run keyless.
## When to use it
| You want | Use `withWaniwani`? |
| --------------------------------------------------- | -------------------------------------------- |
| Only run OSS flows, no telemetry | Optional (still useful for session bridging) |
| OSS flows + Platform tracking | Yes |
| Hosted everything (flow state + tracking + funnel) | Yes |
| Just custom event tracking via `waniwani().track()` | No, call `track()` directly |
## Options
```ts theme={null}
import { withWaniwani } from "@waniwani/sdk/mcp";
import { waniwani } from "@waniwani/sdk";
withWaniwani(server, {
client: waniwani(),
toolType: (name) => (name.startsWith("get_price") ? "pricing" : "other"),
metadata: { deployment: "production" },
flushAfterToolCall: false,
injectWidgetToken: true,
onError: (err) => console.error("[waniwani]", err),
});
```
The client used for tracking. Defaults to `waniwani()`, which picks up `WANIWANI_API_KEY` from the environment.
Classify tools into dashboard buckets: `"pricing"`, `"product_info"`, `"availability"`, `"support"`, or `"other"`. Accepts a literal or a function that maps a tool name to a type.
Extra fields merged into every `tool.called` event. Use this to tag deployments, tenants, or releases.
Force a flush after every tool call. Turn this on in serverless runtimes where the process may be frozen between invocations. Leave it off for long-running Node processes.
Inject the widget tracking config (endpoint, a short-lived widget token, session id, source) into tool responses under `_meta["waniwani/widget"]`, so browser widgets can post events directly to Waniwani without a proxy route. `useWaniwani()` reads this automatically. Without an API key, the token is omitted and only the endpoint metadata is injected.
Callback for non-fatal tracking errors. Tracking failures never block tool execution; use this callback when you want visibility into them.
Call order does not matter. `withWaniwani` walks existing `_registeredTools` at call time and intercepts `registerTool` for any future registrations. It is idempotent: a second call on the same server is a no-op.
## Tracked event shape
| Field | Description |
| --------------- | ---------------------------------------------------------------- |
| `name` | Registered tool name |
| `type` | Category derived from the `toolType` option |
| `durationMs` | Handler execution time |
| `status` | `"ok"` or `"error"` |
| `errorMessage` | Present when the handler threw or returned `isError` |
| `input` | Tool arguments |
| `output` | Handler response |
| `clientName` | MCP client name (for example `chatgpt`, `claude`) |
| `clientVersion` | MCP client version |
| `sessionId` | Correlated from `_meta` (see [Sessions](/sdk/tracking/sessions)) |
Errors (thrown or `{ isError: true }`) are tracked and then re-thrown or returned unchanged, so your tool's error contract is preserved.
## Custom events inside tools
For anything beyond `tool.called`, use the request-scoped client the wrapper attaches to `extra["waniwani/client"]`. Session correlation is already merged in; you pass nothing.
```ts theme={null}
import { extractScopedClient } from "@waniwani/sdk/mcp";
server.registerTool(
"get_quote",
{ /* tool config */ },
async ({ amount, currency }, extra) => {
const waniwani = extractScopedClient(extra);
await waniwani?.track.priceShown({ amount, currency });
return { content: [{ type: "text", text: `Quoted ${amount} ${currency}` }] };
},
);
```
## Session-scoped client in flows
Inside a [flow](/sdk/flows/overview) node, `ctx.waniwani` is the same scoped client, so `ctx.waniwani.track({ event, properties })` needs no session plumbing either. The resolved session id is readable as `ctx.waniwani.sessionId`; store it with your own records to attribute later off-platform events back to the conversation (see [Sessions](/sdk/tracking/sessions)).
# Deployment
Source: https://docs.waniwani.ai/sdk/deployment/overview
Run your Waniwani-powered MCP app on Managed Hosting or self-host it yourself (Docker, Alpic, Vercel).
Where your MCP server runs is independent of whether you connect the [Waniwani Platform](/sdk/platform/overview). This page covers the two server-hosting options. The Platform layer (tracking, KB, chat, hosted state) works the same in both.
Waniwani hosts your app. Provisioned, pre-configured, push to main and it deploys. Fastest path.
You host the app yourself. Docker, Alpic, or Vercel. Connect by giving us the MCP URL.
Both paths use the same [MCP Distribution Template](https://github.com/WaniWani-AI/mcp-distribution-template). Pick based on where you want the server to live.
## Managed Hosting
When you create a managed project in the [Waniwani dashboard](https://app.waniwani.ai), we provision a copy of the distribution template fully wired up: API keys set, hosted URL assigned, everything ready. You don't configure anything.
Typical workflow:
From [app.waniwani.ai](https://app.waniwani.ai), create a managed project. We spin up a repo from the template and deploy it on our managed hosting. Your tools, flows, widgets, and chat surface are live immediately.
Move the provisioned repo into your own GitHub organization when you want full control over the codebase. You keep the managed hosting; you just own the source now.
Every push to `main` auto-deploys to our managed hosting environment. API keys, URLs, and tracking continue to flow without any setup on your side.
Best for: getting started fast, teams that don't want to run infrastructure, and projects that want one less moving part to maintain.
## Self-hosted server
You run the server. By default we recommend connecting the [Platform](/sdk/platform/overview): set `WANIWANI_API_KEY` and you get hosted state + dashboards on top of your self-hosted server, with no extra infra. If you want to push that further and self-host the state layer too, see [Advanced: bring your own session cache](#advanced-bring-your-own-session-cache) below. If you want zero Platform dependency at all (no API key, no outbound calls), see the [open-source self-hosting walkthrough](/sdk/deployment/self-hosting).
The [MCP Distribution Template](https://github.com/WaniWani-AI/mcp-distribution-template) ships three deployment targets. Pick whichever fits your stack.
Run anywhere a container runs.
MCP-native runtime.
One command from the template root.
### Docker
The template ships a `Dockerfile`. Build and run anywhere a container runs (Fly.io, Render, Railway, ECS, GCP Cloud Run, your own VPS, Kubernetes).
```bash theme={null}
docker build -t my-mcp-app .
docker run --env-file .env -p 3000:3000 my-mcp-app
```
Set `WANIWANI_API_KEY` and any backend secrets in the runtime's environment.
### Alpic
Deploy to [Alpic](https://github.com/alpic-ai/skybridge), an MCP-native hosting runtime. Follow Alpic's deployment instructions for the template repo. The SDK is tested against the Skybridge runtime, so flows, tracking, and widgets work out of the box.
### Vercel
```bash theme={null}
vercel deploy
```
From the template root. Set `WANIWANI_API_KEY` and any backend secrets in your Vercel project's environment variables.
### Connect to Waniwani
Once your app is deployed, copy its public MCP URL (the endpoint where MCP clients send `initialize` / `tools/call` requests). In the Waniwani dashboard, create a self-hosted project and paste that URL. Waniwani uses it as the entry point for chat requests, widget rendering, and analytics ingestion.
### Advanced: bring your own session cache
By default a self-hosted server uses `WaniwaniKvStore` for flow state when `WANIWANI_API_KEY` is set (encrypted at rest, hosted by Waniwani, no infra). That's the recommended setup.
If you want zero dependency on `app.waniwani.ai` for state too, plug in your own `KvStore` adapter at `.compile()` time:
```ts theme={null}
import { createFlow } from "@waniwani/sdk/mcp";
import { flowStore } from "./kv-store"; // your Redis / Upstash / DynamoDB / SQLite adapter
const flow = createFlow({ /* ... */ }).compile({ store: flowStore });
```
See [KV store adapters](/sdk/flows/kv-store) for recipes (Redis, Upstash, Cloudflare KV, DynamoDB, SQLite) and the full [open-source self-hosting](/sdk/deployment/self-hosting) walkthrough.
Your server, your state. You can still send tracking events to the Platform by calling `withWaniwani(server)`, or skip that entirely for a fully open-source setup.
## Platform features are orthogonal
Tracking, KB, chat, funnel analytics, and hosted state are Platform features. They are independent of where your server lives and where your state lives. Both Managed Hosting and self-hosted servers emit events through `withWaniwani(server)` and `client.track()`, and both surface in the same Waniwani dashboard. See the [Platform overview](/sdk/platform/overview) for what's included and [Tracking](/sdk/tracking/overview) for the event pipeline.
# Self-hosting
Source: https://docs.waniwani.ai/sdk/deployment/self-hosting
Two pieces you can self-host independently: the MCP app and the KV store.
A Waniwani deployment has **two pieces you can self-host independently**:
1. **The MCP app** — the server that hosts your `createFlow` tools and serves MCP clients.
2. **The KV store** — where flow state lives between tool calls.
These are orthogonal. The most common setup is "self-host the MCP app, let Waniwani host the KV store" — your infra for the server, the [Waniwani Platform](/sdk/platform/overview) for state and dashboards. The next step is self-hosting the KV too, eliminating any outbound call to `app.waniwani.ai`.
| | KV on `WaniwaniKvStore` | KV self-hosted |
| ------------------------------ | ---------------------------------------------------------------------------- | ------------------------------------- |
| **MCP app self-hosted** | Hybrid: your infra for the server, hosted state. Recommended starting point. | Fully open source. No outbound calls. |
| **MCP app on Managed Hosting** | Fully managed. | Not supported. |
## 1. Self-host the MCP app
Deploy the server you wrote on infrastructure you control. By itself this gets you self-hosted MCP + hosted state (the Hybrid row above).
### Scaffold
Start from the [MCP Distribution Template](https://github.com/WaniWani-AI/mcp-distribution-template) or any MCP server of your own:
```bash theme={null}
bun add @waniwani/sdk
```
### Wire up the server
```ts server.ts theme={null}
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { onboardingFlow } from "./flow";
const server = new McpServer({ name: "my-mcp-app", version: "0.0.1" });
await onboardingFlow.register(server);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
const app = express();
app.use(express.json());
app.post("/mcp", (req, res) => transport.handleRequest(req, res, req.body));
app.listen(3000);
```
When `.compile()` is called with no `store` and `WANIWANI_API_KEY` is set, the SDK uses `WaniwaniKvStore` automatically. That's the Hybrid setup.
### Deploy
Any platform that can run Node 18.17+ works: Vercel, Render, Railway, Fly.io, Cloudflare Workers, AWS Lambda, Google Cloud Run. See [Deployment overview](/sdk/deployment/overview) for the choice between this and Managed Hosting.
### Register with Waniwani
In the [Waniwani dashboard](https://app.waniwani.ai), create a self-hosted project and paste your deployed MCP URL. Waniwani routes chat requests, widget rendering, and analytics ingestion through that URL.
## 2. Self-host the KV store
Pass your own `KvStore` to `.compile()` and the engine never touches `app.waniwani.ai` for state. Useful when you have data-residency constraints, run in fully open-source mode, or want all state inside your existing infrastructure.
### Pick a backend
See [KV store adapters](/sdk/flows/kv-store) for recipes. Quick decision tree:
* **Vercel / Netlify / Cloudflare Workers** → Upstash Redis or Cloudflare KV
* **AWS Lambda** → DynamoDB
* **Long-running Node** → ioredis against a managed Redis (Render, Railway, Fly)
* **Local box** → SQLite via `better-sqlite3`
### Implement the interface
```ts kv-store.ts theme={null}
import type { KvStore } from "@waniwani/sdk/mcp";
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
export const flowStore: KvStore = {
async get(key) { return (await redis.get(key)) as never; },
async set(key, value) { await redis.set(key, value); },
async delete(key) { await redis.del(key); },
};
```
### Pass it to `.compile()`
```ts flow.ts theme={null}
import { createFlow, END, START } from "@waniwani/sdk/mcp";
import { flowStore } from "./kv-store";
export const onboardingFlow = createFlow({ /* ... */ })
.addNode({ /* ... */ })
.addEdge(START, "...")
.addEdge("...", END)
.compile({ store: flowStore });
```
State now reads and writes against your backend. Nothing about flow state ever leaves your infrastructure.
## Fully open-source mode
Combine the two: self-host the MCP app, pass a custom `KvStore`, omit `WANIWANI_API_KEY`. Result: no outbound calls to `app.waniwani.ai`, no telemetry, no hosted dashboards. The engine is fully usable in this mode.
### Verifying it's truly offline
```bash theme={null}
# 1. No WANIWANI_API_KEY in env
echo $WANIWANI_API_KEY
# (empty)
# 2. Run with network observability
DEBUG=* bun run start 2>&1 | grep waniwani
# (no requests to app.waniwani.ai)
```
The only outbound calls the SDK makes are when the `waniwani()` client is instantiated with an API key, or when `WaniwaniKvStore` is used. Neither happens in fully open-source mode.
## Switching modes later
The same code moves between modes by editing one or two lines:
* **OSS → Hybrid:** drop the `store` argument from `.compile()`, set `WANIWANI_API_KEY`.
* **Hybrid → OSS:** pass a custom `store` to `.compile()`.
* **Self-hosted → Managed Hosting:** see [Deployment overview](/sdk/deployment/overview#managed-hosting).
Existing sessions stay where they started; there is no automatic state migration between stores.
# Architecture
Source: https://docs.waniwani.ai/sdk/flows/architecture
What happens between an MCP tool call and the flow engine's response, and why a KV store is required.
A flow is a single MCP tool. The model calls that tool, the engine drives the graph one step at a time, and the response tells the model whether to ask the user something, render a widget, or stop. Between calls, everything the engine knows about the conversation lives in the KV store, keyed by the MCP session id.
This page walks through one full round-trip. If you understand the picture below, debugging "why doesn't the flow remember what I said?" becomes trivial.
## The pieces
```mermaid theme={null}
flowchart LR
Client["MCP client (ChatGPT, Claude, ...)"]
subgraph Server["Your MCP server"]
Tool["Flow tool"]
KV[("KV store")]
Tool <-->|loads / saves| KV
end
Client -->|tools/call| Tool
Tool -->|response| Client
```
The KV store is the only place the flow engine looks for state between two tool calls. Your MCP server itself can be either stateful (a long-running process, e.g. stdio or single-instance HTTP) or stateless (a serverless function where every call is a fresh process). The engine doesn't assume either — it always requires a `KvStore`. In a stateful runtime, `MemoryKvStore` is enough, because the process holds the state across calls. In a stateless runtime, you need a real backing store (Redis, Upstash, Cloudflare KV, DynamoDB, or `WaniwaniKvStore`) so state survives between cold starts and instances.
## First call: `action: "start"`
```mermaid theme={null}
sequenceDiagram
participant Client as MCP client
participant Tool as Flow tool handler
participant Engine as Flow engine
participant KV as KV store
Client->>Tool: tools/call ({ action: "start", intent, stateUpdates })
Tool->>Engine: invoke
Engine->>KV: get(sessionId)
KV-->>Engine: null (first call)
Engine->>Engine: init state from stateUpdates
Engine->>Engine: run START → action nodes...
Engine->>Engine: hit ask_email (interrupt)
Engine->>KV: set(sessionId, { step: "ask_email", state: {} })
Engine-->>Tool: { status: "interrupt", questions: {...} }
Tool-->>Client: response
```
After this call, the KV holds:
```json theme={null}
{
"step": "ask_email",
"state": {}
}
```
The flow has paused. The next tool call (with the user's answer) will resume from here.
## Next call: `action: "continue"`
```mermaid theme={null}
sequenceDiagram
participant Client as MCP client
participant Tool as Flow tool handler
participant Engine as Flow engine
participant KV as KV store
Client->>Tool: tools/call ({ action: "continue", stateUpdates: { email: "..." } })
Tool->>Engine: invoke
Engine->>KV: get(sessionId)
KV-->>Engine: { step: "ask_email", state: {} }
Engine->>Engine: apply stateUpdates, run validators
Engine->>Engine: advance along edge to next node
Engine->>Engine: run action nodes... hit ask_use_case
Engine->>KV: set(sessionId, { step: "ask_use_case", state: { email: "..." } })
Engine-->>Tool: { status: "interrupt", questions: {...} }
Tool-->>Client: response
```
Every `continue` is the same shape: load → apply updates → advance → save → respond.
When the engine reaches `END`, the response status is `"complete"` and the KV key is deleted, so a stale `continue` call can't accidentally resume a finished session.
## What's in the stored value
The value at a given session key is small and deterministic:
```ts theme={null}
type StoredValue = {
step: string; // current node name (or "END" briefly)
state: Partial; // merged state so far, typed against schema
pendingWidget?: { // present only when paused at a showWidget node
tool: string;
data?: Record;
};
};
```
Each session maps to one key. The key is derived from the MCP session identifier (`_meta.waniwani/sessionId`, or `Mcp-Session-Id` from the transport). One session, one key, one ongoing flow run.
## What's *not* in the KV store
The KV powers the flow engine only. The following live in a separate pipeline:
* **Tracking events.** `tool.called`, `price_shown`, etc. are emitted by `withWaniwani(server)` and `client.track()`, batched, and POSTed to the events endpoint. Independent of the KV store.
* **Funnel analytics and dashboards.** Built from tracking events on the server side. Reading them never touches your KV.
* **Knowledge base content.** Lives in Waniwani's hosted KB service, unrelated to flow state.
You can self-host the KV and still get hosted dashboards by also calling `withWaniwani(server)` with `WANIWANI_API_KEY` set. The two systems compose.
## Common gotchas
The KV reset between calls. Two common causes:
1. **`MemoryKvStore` in a serverless runtime.** Each invocation is a fresh process, so the in-memory `Map` is empty every time. Use a real backend (Redis, Upstash, Cloudflare KV) or set `WANIWANI_API_KEY` to use `WaniwaniKvStore`.
2. **Missing session id in `_meta`.** If the MCP client doesn't propagate a session id, the engine derives a different key on every call. `withWaniwani(server)` bridges the transport-level session id into `_meta` for you. Install it.
Same root cause as above (missing or changing session id), but with a different symptom: the engine never finds a stored value, so it always behaves like `action: "start"`. Inspect what your transport puts in `Mcp-Session-Id` and what arrives in `_meta` on the server. They should be stable across a conversation.
You changed the flow's state schema, then deployed. Sessions that were mid-flow have stored values that no longer match the new schema. The safest fix is to invalidate old sessions: delete the prefix in your KV, or version your flow id (`"onboarding_v2"`).
MCP clients serialize calls within a conversation, so concurrent `continue`s on one session are rare. If you see racy reads/writes in your KV, the cause is usually two clients sharing one session id by accident (often a misconfigured proxy). Make sure each conversation has a unique session id.
A `showWidget` node stores `pendingWidget` in the KV and returns `status: "widget"`. The engine resumes only when the next `continue` carries `stateUpdates` that fill the `field` declared in `showWidget(...)`. If your display tool never writes that field, the flow stays paused forever. Confirm the display tool's handler is producing the expected key in `stateUpdates`.
## Next
The three kinds of nodes and the context they receive.
Pick a backend or write your own in 10 lines.
Wire-level reference for `FlowToolInput` and response statuses.
What runs without an API key, what unlocks when you connect the Platform.
# Edges
Source: https://docs.waniwani.ai/sdk/flows/edges
Wire nodes together: direct edges, conditional branching, loops.
Edges define the order in which nodes run. Every node needs an outgoing edge. Every flow needs an edge out of `START` and must be able to reach `END`.
## Sentinels
Two reserved node names control flow boundaries:
* `START`: the entry point. Exactly one edge must originate from it.
* `END`: the terminal marker. Any node pointing to `END` finishes the flow when reached.
```ts theme={null}
import { createFlow, END, START } from "@waniwani/sdk/mcp";
```
## Direct edges
Use `.addEdge(from, to)` for unconditional transitions. Each node can have at most one outgoing edge.
```ts theme={null}
flow
.addEdge(START, "ask_email")
.addEdge("ask_email", "ask_use_case")
.addEdge("ask_use_case", "record")
.addEdge("record", END);
```
Calling `addEdge` twice from the same node throws at build time. If you need a second outgoing path, replace the first call with `addConditionalEdge`.
## Conditional edges
Use `.addConditionalEdge(from, to, condition)` when the next node depends on state. Declare every node the branch can reach in the `to` array, then `condition` receives `Partial` and returns which of those nodes to go to next. It can be async.
The condition's return type is **constrained to `to`** — returning a node you did not list is a compile error. Because `to` is the declared set of targets, graph introspection (funnel analytics, Mermaid diagrams) reads it directly, so the graph stays correct even when the branch target is computed dynamically.
```ts theme={null}
flow
.addNode({
id: "ask_email",
run: ({ interrupt }) =>
interrupt({ email: { question: "What's your email?" } }),
})
.addNode({
id: "analyze_email",
run: ({ state }) => {
const domain = state.email?.split("@")[1] ?? "";
const generic = new Set(["gmail.com", "yahoo.com", "outlook.com"]);
return { isCompanyEmail: !generic.has(domain) };
},
})
.addNode({
id: "ask_company",
run: ({ interrupt }) =>
interrupt({ companyName: { question: "What company are you with?" } }),
})
.addNode({ id: "done", run: () => ({ ready: true }) })
.addEdge(START, "ask_email")
.addEdge("ask_email", "analyze_email")
.addConditionalEdge("analyze_email", ["done", "ask_company"], (state) =>
state.isCompanyEmail ? "done" : "ask_company",
)
.addEdge("ask_company", "done")
.addEdge("done", END);
```
Async conditions work for branches that depend on external lookups:
```ts theme={null}
.addConditionalEdge(
"check_inventory",
["confirm_order", "offer_alternative"],
async (state) => {
const inStock = await inventoryService.check(state.sku!);
return inStock ? "confirm_order" : "offer_alternative";
},
)
```
The return type is checked against the `to` list, so a typo in a branch target — or a target you forgot to declare — fails type-checking.
## Reaching END
When execution reaches `END`, the engine returns `status: "complete"` to the model and deletes the session's flow state so a stale `continue` call cannot resume it. Any node can point to `END`, directly or conditionally.
```ts theme={null}
.addConditionalEdge("review", [END, "ask_corrections"], (state) =>
state.approved ? END : "ask_corrections",
)
```
## Loops
A conditional edge that points back to an earlier node creates a loop. Useful for retries or collection gathering.
```ts theme={null}
.addNode({
id: "ask_item",
run: ({ interrupt }) =>
interrupt({ item: { question: "Add an item (or say 'done')" } }),
})
.addNode({
id: "append",
run: ({ state }) => ({
items: [...(state.items ?? []), state.item].filter(Boolean) as string[],
}),
})
.addEdge("ask_item", "append")
.addConditionalEdge("append", ["finalize", "ask_item"], (state) =>
state.item === "done" ? "finalize" : "ask_item",
)
```
The engine enforces a hard cap of 50 iterations per tool call to prevent runaway loops between action nodes. You will still need `END` reachable from every branch or the flow can stall.
## Compile-time validation
`.compile()` validates the graph before returning. It throws if:
* No edge exists from `START`.
* A `START` edge or direct edge points at an undeclared node.
* A conditional edge declares a `to` target that is not a registered node.
* A conditional edge declares an empty `to` list.
* A node has no outgoing edge.
* An edge is declared `from` a non-existent node.
Errors surface immediately at startup, not at the first user interaction.
## Visualizing the graph
Both the builder and the compiled flow expose `graph()`, which returns a Mermaid `flowchart TD` string. Conditional edges render one dashed arrow per declared `to` target.
```ts theme={null}
console.log(flow.graph());
```
```mermaid theme={null}
flowchart TD
__start__((Start))
ask_email[ask_email]
analyze_email[analyze_email]
ask_company[ask_company]
done[done]
__end__((End))
__start__ --> ask_email
ask_email --> analyze_email
analyze_email -.-> done
analyze_email -.-> ask_company
ask_company --> done
done --> __end__
```
# Interrupts
Source: https://docs.waniwani.ai/sdk/flows/interrupts
Pause the flow to ask the user one or more questions.
An interrupt is the mechanism by which a flow collects information from the user. When a node returns `interrupt(...)`, the engine returns `status: "interrupt"` to the model, persists the current step, and waits. On the next `action: "continue"` call, the user's answers arrive in `stateUpdates`, validators run, and the engine advances along the node's outgoing edge.
## What gets persisted
Between an interrupt and the matching continue call, the flow store holds:
* The current step name (which node is paused).
* The full state snapshot at the time of pause.
* For single-question interrupts, the field being asked.
Everything else (handler closures, validator functions) lives in the compiled flow in memory. Validators are registered when a handler first returns an interrupt and are keyed by `"nodeName:fieldName"`. They are not serialized.
This has one operational consequence: if the server process restarts between the interrupt and the continue call, persisted state is reloaded but validator functions are rebuilt from the handler's return value as the interrupt re-executes. The interrupt handler must therefore be deterministic enough to produce the same validator for a given state.
## Single question
The smallest interrupt asks one question that writes its answer to one field.
```ts theme={null}
.addNode({
id: "ask_email",
run: ({ interrupt }) =>
interrupt({
email: { question: "What's your work email?" },
}),
})
```
The key (`email`) must be a field in the flow's state schema. TypeScript enforces this.
## Suggestions
Pass `suggestions` to give the model a concrete list to offer. Pairs well with `z.enum()` state fields so the model validates the user's choice against a fixed set.
```ts theme={null}
.addNode({
id: "ask_level",
run: ({ interrupt }) =>
interrupt({
level: {
question: "What's your ski level?",
suggestions: ["beginner", "intermediate", "advanced"],
},
}),
})
```
Suggestions serve two audiences at once. The assistant receives them as candidate answers on every host, regardless of any configuration. In the Waniwani chat widget they can also render as clickable pills above the input — clicking one sends it as the visitor's answer — but only when the host chat surface opts the `"flow"` suggestion origin in:
* ``: `overrides={{ suggestionOrigins: ["channel", "page", "flow", "followup"] }}`
* `
```
With the React component, the same surface lives on the ref handle:
```tsx theme={null}
import { useRef } from "react";
import { WaniwaniChat, type ChatHandle } from "@waniwani/sdk/chat";
function Page() {
const chat = useRef(null);
return (
<>
>
);
}
```
`track` and `identify` are absent on the bare `ChatEmbed` primitive, which is bring-your-own-backend and holds no Waniwani credential.
To mirror chat lifecycle events (opened, closed, message sent/received) into your own analytics instead, use the embed's `onEvent` callback; it runs in the page's JS context and carries the `sessionId`.
## Custom surfaces: `createFrontendClient`
Both surfaces above are wrappers around one primitive, exported for anything they do not cover:
```ts theme={null}
import { createFrontendClient } from "@waniwani/sdk";
const client = createFrontendClient({
endpoint: "https://app.waniwani.ai/api/mcp/events/v2/batch",
token: "wwp_...", // public token or widget JWT
source: "web",
identity: () => ({ sessionId: currentSessionId, visitorId }),
});
await client.track.priceShown({ amount: 49, currency: "EUR" });
```
It shares the server client's mapper and batching transport, adds keepalive flushing on page hide/unload, and reads `identity()` at emit time so late-arriving ids (a session assigned mid-page) are picked up without recreating the client.
## Delivery behavior
* Events batch in memory and flush on a short timer, on batch size, and with `keepalive` requests when the page is hidden or unloading.
* Tracking never throws into the host page; failures are logged and dropped.
* On an auth failure (expired widget token, revoked public token) the transport stops and logs the reason once, instead of retrying forever.
# Why MCP funnels
Source: https://docs.waniwani.ai/sdk/why-mcp-funnels
Why ChatGPT, Claude, and Cursor change distribution, and why conversational funnels are the new web forms.
A distribution shift is underway. ChatGPT, Claude, and Cursor are becoming the new browsers. MCP is the store. The way users find products, get qualified, book, and buy is moving from web forms inside web pages to conversational funnels inside AI clients.
This page explains why we believe that, and why `@waniwani/sdk` exists.
## The distribution shift
For twenty years the web ran on the same loop: a user lands on a page, fills a form, gets routed to the next step. Sales funnels, lead generation, booking, quoting. Money moved through forms.
That loop is moving. The interface a user starts a buying journey in is increasingly an AI client, not a browser tab. ChatGPT, Claude, and Cursor are where attention concentrates. MCP is the protocol those clients use to call third-party tools. From the user's perspective: they describe what they want, the AI client calls your MCP server, your server runs the funnel.
The implications are not subtle:
* **One server, every surface.** One MCP server reaches ChatGPT, Claude, Cursor, and any other MCP-capable client. One edit to your messaging deploys everywhere.
* **No website, no form, no chatbot widget.** The funnel runs inside the AI client the user already uses.
* **The funnel is a tool call.** The same tool call drives the conversation, validates input, branches, persists state.
## Conversational funnels are the new web forms
The shape of the work has not changed. A funnel still has steps. A booking funnel still asks for a service, a slot, and a confirmation. An insurance funnel still collects details, validates, and returns a quote. A lead funnel still captures email, role, and use case.
What changes is the rendering surface. The funnel runs inside an AI client. The model is the front-end. The funnel logic still lives on your server.
This is good news: the business outcomes you care about (qualified leads, booked appointments, returned quotes, completed purchases) translate directly. It is bad news only if you assume the AI client will run the funnel for you. It will not.
## Why LLMs cannot run funnels on their own
A funnel needs five things:
1. **Deterministic order.** Steps run in a known sequence.
2. **Typed fields.** Email is an email, date is a date.
3. **Validation.** Bad input is rejected and re-asked.
4. **Branching.** The next step depends on the current answer.
5. **Resumable state.** Every step builds on the last, across many tool calls.
LLMs left to themselves do none of these reliably. They paraphrase questions, skip fields, accept malformed input, drop state between turns. This is not a bug in the model. It is a mismatch: a stateless system cannot run a stateful process.
The fix is to make the funnel deterministic on the server, and let the model do what it is good at: rendering the next question, interpreting the answer, handing back to the server.
## Why generic agent frameworks are not the right fit
Frameworks like LangChain and LangGraph are general-purpose agent builders. They expose every primitive: graphs, tools, memory, retrievers, planners. You can build a funnel on top of them. You can also build a database on top of `malloc`.
The cost is that every funnel ergonomic (interrupt on a missing field, re-ask on validation failure, skip a step whose state is already known, hand off to a widget for confirmation, persist state under the MCP session id) is something you re-invent on every project. The frameworks give you the road. They do not give you the car.
`createFlow` is funnel-shaped by design. A node is a step. An interrupt is a form field. An edge is a transition. A conditional edge is a branching question. Typed state via Zod is your lead data. The graph compiles to one MCP tool. The engine runs it.
See [Waniwani vs LangGraph](/sdk/compare/vs-langgraph) for a direct comparison.
## Production-validated
We did not start with this abstraction. We arrived at it.
`@waniwani/sdk` is forked from internal distribution MCPs we shipped for paying customers: insurance quoting, pet care, lead capture, booking. We hit the same pattern enough times to extract it, and open-sourced once the shape stabilized.
The hosted tier (analytics, KV store, embeddable widgets, knowledge base) is opt-in via a single env var. The engine works standalone with any get/set/delete backend.
## What this means for you
If you are building distribution into AI clients, you have three options:
1. **Hand-roll on the raw MCP SDK.** You serialize state through the model on every turn. The model forgets, paraphrases, skips. You re-invent re-ask, validation, and branching on every project.
2. **Build on a generic agent framework.** You get graph primitives but write all the funnel ergonomics yourself.
3. **Use `createFlow`.** You declare state, nodes, and edges. The engine handles interrupts, validation, branching, and resumption, and compiles to one MCP tool.
This SDK exists because we believe option three should be the default. The funnel is the unit of distribution in the AI era, and the abstraction should be funnel-shaped.
## Next
Boot a flow-driven MCP server in 60 seconds.
Sales funnels, lead gen, booking, quote flows.
Direct comparison with LangChain's graph framework.
Nodes, edges, interrupts, conditional branching.