Skip to main content

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

GoalPick
Local dev, tests, ephemeralMemoryKvStore
Zero infra, encrypted at restWaniwaniKvStore (Platform)
Vercel / Netlify / serverless edgeUpstash Redis adapter
Cloudflare WorkersWorkers KV adapter
AWS LambdaDynamoDB adapter
Single-node Node.js with persistenceSQLite 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.