Documentation Index
Fetch the complete documentation index at: https://docs.waniwani.ai/llms.txt
Use this file to discover all available pages before exploring further.
Open source. The KV store interface works without an API key. The hosted WaniwaniKvStore requires the WaniWani Platform.
A flow needs a place to persist its state between MCP calls. MCP tools are stateless, so without a store the engine has nothing to resume from on the next continue call. Every compiled flow is therefore required to have a store. .compile() throws at compile time if you pass neither a store argument nor configure WANIWANI_API_KEY.
createFlow persists this state through a tiny KvStore interface. Implement it against any key-value backend: Redis, Upstash, Cloudflare KV, DynamoDB, even a SQLite table. The SDK ships two implementations out of the box, and you can write your own in ~10 lines.
The KV store powers the flow engine only. It is not what drives dashboards, funnel analytics, or event tracking. Those are a separate pipeline fed by withWaniwani(server) and client.track(). You can run a fully self-hosted KV (no API key, nothing leaves your infra) and still get hosted dashboards by also calling withWaniwani(server) with WANIWANI_API_KEY set. The two paths are independent.
The interface
export interface KvStore<T = Record<string, unknown>> {
get(key: string): Promise<T | null>;
set(key: string, value: T): Promise<void>;
delete(key: string): Promise<void>;
}
That’s it. The engine handles serialization, session-key derivation, expiry semantics, and concurrency.
Built-in implementations
The SDK ships two implementations. Pick one or write your own (recipes below).
import { MemoryKvStore, WaniwaniKvStore } from "@waniwani/sdk/mcp";
// In-memory Map, resets on restart. For development and tests.
const flow = createFlow({ /* ... */ }).compile({ store: new MemoryKvStore() });
// Hosted on app.waniwani.ai. Auto-selected when WANIWANI_API_KEY is set
// and no explicit store is passed.
const flow = createFlow({ /* ... */ }).compile(); // picks WaniwaniKvStore
// Or instantiate it manually.
const flow = createFlow({ /* ... */ }).compile({ store: new WaniwaniKvStore() });
See the Platform overview for when the hosted store makes sense versus a self-hosted adapter.
Adapter recipes
Drop-in KvStore implementations for common backends.
Upstash Redis (serverless, HTTP)
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 upstashStore: 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);
},
};
Node Redis (ioredis or redis)
import type { KvStore } from "@waniwani/sdk/mcp";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
export const redisStore: KvStore = {
async get(key) {
const raw = await redis.get(key);
return raw ? JSON.parse(raw) : null;
},
async set(key, value) {
await redis.set(key, JSON.stringify(value));
},
async delete(key) {
await redis.del(key);
},
};
Cloudflare Workers KV
import type { KvStore } from "@waniwani/sdk/mcp";
export function cloudflareKvStore(ns: KVNamespace): KvStore {
return {
async get(key) {
return ns.get<Record<string, unknown>>(key, "json");
},
async set(key, value) {
await ns.put(key, JSON.stringify(value));
},
async delete(key) {
await ns.delete(key);
},
};
}
// In your worker:
const flow = createFlow({ /* ... */ }).compile({
store: cloudflareKvStore(env.FLOW_STATE_KV),
});
DynamoDB
import type { KvStore } from "@waniwani/sdk/mcp";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
DeleteCommand,
GetCommand,
PutCommand,
} from "@aws-sdk/lib-dynamodb";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = "flow_state";
export const dynamoStore: KvStore = {
async get(key) {
const res = await ddb.send(new GetCommand({ TableName: TABLE, Key: { pk: key } }));
return (res.Item?.value as never) ?? null;
},
async set(key, value) {
await ddb.send(new PutCommand({ TableName: TABLE, Item: { pk: key, value } }));
},
async delete(key) {
await ddb.send(new DeleteCommand({ TableName: TABLE, Key: { pk: key } }));
},
};
SQLite (better-sqlite3)
Quick local persistence without standing up a separate service.
import type { KvStore } from "@waniwani/sdk/mcp";
import Database from "better-sqlite3";
const db = new Database("flow-state.db");
db.exec("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT)");
const getStmt = db.prepare("SELECT value FROM kv WHERE key = ?");
const setStmt = db.prepare("INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)");
const delStmt = db.prepare("DELETE FROM kv WHERE key = ?");
export const sqliteStore: KvStore = {
async get(key) {
const row = getStmt.get(key) as { value: string } | undefined;
return row ? JSON.parse(row.value) : null;
},
async set(key, value) {
setStmt.run(key, JSON.stringify(value));
},
async delete(key) {
delStmt.run(key);
},
};
Encryption at rest
WaniwaniKvStore encrypts values at rest with AES-256-GCM before they leave the SDK. You don’t have to do anything; it’s handled inside the store.
For self-hosted stores, encryption is your responsibility. If your backend doesn’t provide encryption at rest, wrap your adapter:
import type { KvStore } from "@waniwani/sdk/mcp";
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
function encrypted(inner: KvStore, key: Buffer): KvStore {
return {
async get(k) {
const raw = await inner.get(k);
if (!raw) return null;
// ...decrypt with key, return parsed value
},
async set(k, value) {
// ...encrypt JSON.stringify(value) with key
await inner.set(k, /* encrypted envelope */);
},
async delete(k) {
await inner.delete(k);
},
};
}
The KvStore interface composes trivially. Encrypt at the wrapper boundary; the engine stays unaware.
What gets stored
Each session maps to one key. The engine stores:
- The current node (or
END when complete)
- The merged state object so far
- A pending widget reference, if the current node is a
showWidget step
Keys are derived from the MCP session identifier (_meta.waniwani/sessionId or Mcp-Session-Id), so isolating sessions is automatic. Values are JSON-serializable plain objects.
Choosing a backend
| Goal | Pick |
|---|
| Local dev, tests, ephemeral | MemoryKvStore |
| Zero infra, encrypted at rest | WaniwaniKvStore (Platform) |
| Vercel / Netlify / serverless edge | Upstash Redis adapter |
| Cloudflare Workers | Workers KV adapter |
| AWS Lambda | DynamoDB adapter |
| Single-node Node.js with persistence | SQLite or Node Redis |
Picking WaniwaniKvStore does not by itself enable dashboards or funnel analytics. Those come from tracking events emitted by withWaniwani(server). The two systems are independent.