Skip to content

Bundled Todo App

A walkthrough of the TypeScript todo app in packages/poe-todo-list-app/. This example shows the standard file structure for a production app using Poe.setupStore() with a bundled client config.

File Structure

poe-todo-list-app/
├── app-schema-version.ts   # Shared version constant
├── schema.ts               # Schema with Zod validation
├── mutators.ts             # Shared mutation handlers
├── client-config.ts        # Client-safe configuration
├── backend-config.ts       # Server-only configuration
├── client.ts               # Client re-exports
├── server.ts               # Server re-exports
├── app/                    # React frontend
│   ├── index.html
│   └── src/
│       ├── App.tsx         # React app using Poe.setupStore
│       └── backend.ts      # Backend config re-export
└── tests/                  # Tests

Schema (schema.ts)

The schema defines one table (todos) with three mutators. The description and input fields on each mutator and action are also used to produce LLM tools and MCP tools, so AI models can interact with the store directly. The schema version comes from a separate file to avoid bundling Zod on the client.

View on GitHub

typescript
import { defineSchema, table } from "@synced-store/backend";
import { z } from "zod";
import { TODO_LIST_APP_SCHEMA_VERSION } from "./app-schema-version";

export const todoListSchema = defineSchema({
  schemaVersion: TODO_LIST_APP_SCHEMA_VERSION,
  tables: {
    todos: {
      schema: table(
        z.object({
          text: z.string(),
          done: z.boolean(),
        }),
      ),
    },
  },
  mutators: {
    addTodo: {
      description: "Add a todo item",
      input: z.object({
        id: z.string(),
        text: z.string(),
      }),
    },
    toggleTodo: {
      description: "Toggle a todo item's done state",
      input: z.object({
        id: z.string(),
      }),
    },
    deleteTodo: {
      description: "Delete a todo item",
      input: z.object({
        id: z.string(),
      }),
    },
  },
  actions: {},
});

export type TodoListSchema = typeof todoListSchema;

Mutators (mutators.ts)

Types are inferred from the schema — no manual type definitions needed.

View on GitHub

typescript
import type {
  InferMutatorHandlers,
  InferSchemaTableTypes,
} from "@synced-store/backend";
import type { TodoListSchema } from "./schema";

export type TodoListTableTypes = InferSchemaTableTypes<TodoListSchema>;
export type Todo = TodoListTableTypes["todos"];

type TodoListMutatorHandlers = InferMutatorHandlers<TodoListSchema>;

export const todoListMutatorHandlers: TodoListMutatorHandlers = {
  async addTodo(ctx, input): Promise<void> {
    await ctx
      .table("todos")
      .set({ itemKey: input.id, value: { text: input.text, done: false } });
  },

  async toggleTodo(ctx, input): Promise<void> {
    const existing = await ctx.table("todos").get(input.id);
    if (existing) {
      await ctx.table("todos").set({
        itemKey: input.id,
        value: { ...existing, done: !existing.done },
      });
    }
  },

  async deleteTodo(ctx, input): Promise<void> {
    await ctx.table("todos").delete(input.id);
  },
};

Client Config (client-config.ts)

Uses a type-only import of the schema to avoid bundling Zod (~280KB) on the client.

View on GitHub

typescript
import { defineClientConfig } from "@synced-store/client";
import type { todoListSchema } from "./schema";
import { todoListMutatorHandlers } from "./mutators";
import { TODO_LIST_APP_SCHEMA_VERSION } from "./app-schema-version";

export const todoListClientConfig = defineClientConfig<
  typeof todoListSchema
>({
  mutators: todoListMutatorHandlers,
  schemaVersion: TODO_LIST_APP_SCHEMA_VERSION,
});

React App (app/src/App.tsx)

The React frontend uses Poe.setupStore() with the client config, then accesses the store via Poe.store:

View on GitHub

tsx
import { Poe } from "@poe/client-runtime-v1";
import { useState, useEffect, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { todoListClientConfig } from "../../client-config";

// Initialize the store with the typed client config
Poe.setupStore(todoListClientConfig);

let nextId = 0;

function App(): ReactNode {
  const [todos, setTodos] = useState<
    Array<{ id: string; text: string; done: boolean }>
  >([]);
  const [status, setStatus] = useState("loading");

  useEffect(() => {
    // Subscribe to all todos — re-fires on any change
    Poe.store.subscribe(
      (tx) => tx.table("todos").entries().toArray(),
      (entries) => {
        setTodos(
          entries.map(([k, v]) => ({
            id: k.itemKey,
            ...(v as { text: string; done: boolean }),
          })),
        );
        setStatus("ready");
      },
    );
  }, []);

  const handleAdd = (): void => {
    const input = document.getElementById("todo-input") as HTMLInputElement;
    const text = input.value.trim();
    if (text) {
      Poe.store.mutate["addTodo"]!({ id: String(nextId++), text });
      input.value = "";
    }
  };

  const handleToggle = (id: string): void => {
    Poe.store.mutate["toggleTodo"]!({ id });
  };

  const handleDelete = (id: string): void => {
    Poe.store.mutate["deleteTodo"]!({ id });
  };

  return (
    <div>
      <div id="status">{status}</div>
      <input id="todo-input" type="text" placeholder="Enter todo" />
      <button id="add-btn" type="button" onClick={handleAdd}>
        Add
      </button>
      <ul id="todo-list">
        {todos.map((todo) => (
          <li key={todo.id} data-todo-id={todo.id}>
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => handleToggle(todo.id)}
            />
            <span className={todo.done ? "done" : ""}>{todo.text}</span>
            <button
              type="button"
              className="delete-btn"
              onClick={() => handleDelete(todo.id)}
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
      <div id="todo-count">{todos.length}</div>
    </div>
  );
}

const root = document.getElementById("root");
if (root) {
  createRoot(root).render(<App />);
}

Key Differences from No-Build

No-BuildBundled
Import@poe/embed-api/v1.js@poe/client-runtime-v1
SchemaInline mutatorsdefineSchema() + defineClientConfig()
Config{ mutators, schemaVersion }Pre-built todoListClientConfig
TypesNone (plain JS)Full type inference from Zod schema

The @poe/client-runtime-v1 Import

Bundled apps import from @poe/client-runtime-v1 instead of @poe/embed-api/v1.js. The bundler resolves this to the platform's ESM runtime, which provides the same Poe API with setupStore(), store, stream(), and call().

Poe Employee Note

For no-build apps, the platform currently injects an import map into the app's index.html at serve time to make @poe/embed-api/v1.js resolvable. For bundled apps, the bundler resolves @poe/client-runtime-v1 at build time instead. In the future we'll probably want creators to include a script tag (e.g. <script src="https://poe.com/v1/embed-api.js"></script>) so the mechanism is more explicit and doesn't require server-side HTML rewriting or special bundler config.

Building

The app is built using the platform bundler, which produces:

  • app-frontend.js — ESM bundle for the browser
  • synced-store-backend-config.js — Backend config for the sync server
typescript
import { buildApp } from "@ai-app/canvas-frame-lib/bundler";

await buildApp({
  frontend: "app/src/App.tsx",
  backend: "app/src/backend.ts",
  outDir: "dist",
});

The built output is zipped and uploaded to blob storage for static hosting.

Poe Employee Note

We'll likely want to instruct creators to mark @synced-store/* as external in their production builds (e.g. external: ["@synced-store/client", "@synced-store/react"] in esbuild/Vite/Rollup) and let the platform resolve these at runtime. This commits us to a stable external API but gives us flexibility to change the underlying implementation. Since synced-store client and server are tightly coupled, forcing everyone onto the latest version will reduce version-mismatch bugs and simplify upgrades. Later, once the API is more settled, we'd probably want to let creators pin and bundle a specific version of synced-store instead.

React Hooks (Advanced)

For apps that manage multiple store instances or need lifecycle control beyond Poe.setupStore(), the @synced-store/react package provides React hooks:

HookPurpose
useStore(config, instanceId)Acquire a managed store instance with automatic lifecycle
useLiveQuery(store, queryFn)Subscribe to a live query that re-renders on changes
StoreManagerProviderContext provider with reference counting and auto-disposal
tsx
import { StoreManagerProvider, useStore, useLiveQuery } from "@synced-store/react";

function TodoList({ roomId }: { roomId: string }) {
  const { store, isLoading } = useStore(todoListClientConfig, roomId);

  const { data: todos } = useLiveQuery(store, async (ctx) => {
    const entries = await ctx.table("todos").entries().toArray();
    return entries.map(([key, todo]) => ({ id: key.itemKey, ...todo }));
  });

  if (isLoading || !todos) return <div>Loading...</div>;

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

See the @synced-store/react package for the full API.