Appearance
Bundled Todo App
A walkthrough of the TypeScript todo app in packages/poe-app-todo-list/. This example shows the standard file structure for a production app using Poe.setupStore() with a bundled client config.
File Structure
poe-app-todo-list/
├── synced-store/ # Schema, mutators, and configs
│ ├── app-schema-version.ts # Shared version constant
│ ├── schema.ts # Schema with Zod validation
│ ├── mutators.ts # Shared mutation handlers
│ ├── actions.ts # Server-only MCP tool handlers
│ ├── client-config.ts # Client-safe configuration
│ └── backend-config.ts # Server-only configuration
├── ui/ # Store-agnostic React components
│ ├── todo-store-context.tsx # TodoStoreProvider + useTodoStore hook
│ └── TodoApp.tsx # Todo list UI component
├── client.ts # Client re-exports
├── server.ts # Server re-exports
├── app/ # Iframe app entry point
│ ├── index.html
│ └── src/
│ ├── entry.tsx # Only file importing poe-apps-sdk
│ └── backend.ts # Backend config re-export
└── tests/ # TestsSchema (synced-store/schema.ts)
The schema defines one table (items) with two mutators and four actions. 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.
typescript
import { defineSchema, table } from "@synced-store/backend";
import { z } from "zod";
import { TODO_APP_SCHEMA_VERSION } from "./app-schema-version";
export const todoSchema = defineSchema({
schemaVersion: TODO_APP_SCHEMA_VERSION,
tables: {
items: {
schema: table(
z.object({
id: z.string(),
text: z.string(),
completed: z.boolean(),
createdAt: z.number(),
updatedAt: z.number(),
order: z.number(),
}),
),
searchable: { textField: "text" },
},
},
mutators: {
setTodo: {
description: "Set or update a todo item",
input: z.object({
id: z.string(),
text: z.string().optional(),
completed: z.boolean().optional(),
createdAt: z.number().optional(),
updatedAt: z.number().optional(),
order: z.number().optional(),
}),
},
removeTodo: {
description: "Remove a todo item",
input: z.object({
id: z.string(),
}),
},
},
actions: {
addTodo: { /* ... */ },
updateTodo: { /* ... */ },
deleteTodo: { /* ... */ },
getTodos: { /* ... */ },
},
});
export type TodoSchema = typeof todoSchema;Mutators (synced-store/mutators.ts)
Types are inferred from the schema — no manual type definitions needed.
typescript
import type {
InferMutatorHandlers,
InferSchemaTableTypes,
} from "@synced-store/client";
import type { TodoSchema } from "./schema";
export type TodoTableTypes = InferSchemaTableTypes<TodoSchema>;
export type TodoItem = TodoTableTypes["items"];
export const todoMutatorHandlers: InferMutatorHandlers<TodoSchema> = {
setTodo: async (ctx, input) => {
const existing = await ctx.table("items").get(input.id);
const now = Date.now();
const todo: TodoItem = {
id: input.id,
text: input.text ?? existing?.text ?? "",
completed: input.completed ?? existing?.completed ?? false,
createdAt: existing?.createdAt ?? input.createdAt ?? now,
updatedAt: input.updatedAt ?? now,
order: input.order ?? existing?.order ?? now,
};
await ctx.table("items").set({ itemKey: input.id, value: todo });
},
removeTodo: async (ctx, input) => {
await ctx.table("items").delete(input.id);
},
};Client Config (synced-store/client-config.ts)
Uses a type-only import of the schema to avoid bundling Zod (~280KB) on the client.
typescript
import { defineClientConfig } from "@synced-store/client";
import type { todoSchema } from "./schema";
import { todoMutatorHandlers } from "./mutators";
import { TODO_APP_SCHEMA_VERSION } from "./app-schema-version";
export const todoClientConfig = defineClientConfig<
typeof todoSchema
>({
mutators: todoMutatorHandlers,
schemaVersion: TODO_APP_SCHEMA_VERSION,
});Backend Config (synced-store/backend-config.ts)
defineBackendConfig() bundles your schema, mutators, and actions into a single typed object for the server. It is optional — you can export a plain { schema, mutators, actions } object instead — but it provides full type inference across mutators and actions, catching mismatches at compile time rather than at runtime.
TIP
defineBackendConfig() is designed for bundled TypeScript apps. For no-build apps, export a plain object from synced-store-backend-config.js directly — there is no benefit to using defineBackendConfig() without a type checker.
typescript
import { defineBackendConfig } from "@synced-store/backend";
import { todoSchema, type TodoServices } from "./schema";
import { todoMutatorHandlers } from "./mutators";
import { todoActions } from "./actions";
export const todoBackendConfig = defineBackendConfig<
typeof todoSchema,
TodoServices
>({
schema: todoSchema,
mutators: todoMutatorHandlers,
actions: todoActions,
});If your app uses platform capabilities, they are available via ctx.platform.call() in your actions — API keys, blob storage, permissions, etc. are provided by the platform at runtime:
typescript
import { defineBackendConfig } from "@synced-store/backend";
import { mySchema, type MyAppServices } from "./schema";
import { myMutators } from "./mutators";
import { myActions } from "./actions";
export const myBackendConfig = defineBackendConfig<
typeof mySchema,
MyAppServices
>({
schema: mySchema,
mutators: myMutators,
actions: myActions,
});Typed Store Client (client.ts)
Export a fully typed SyncedStoreClient using InferSyncedStoreClient. This gives consumers typed mutate, subscribe, action, makeUniqueId, and query methods — all inferred from the schema:
typescript
// In client.ts
import type { InferSyncedStoreClient } from "@synced-store/client";
import type { TodoSchema } from "./schema";
export type TodoStoreClient = InferSyncedStoreClient<TodoSchema>;Use this type to annotate variables holding a store instance:
typescript
import type { TodoStoreClient } from "@poe-app/todo-list";
function useTodos(store: TodoStoreClient) {
// store.mutate.setTodo — fully typed input
// store.subscribe — typed QueryContext with table names
// store.action — typed action methods
}TIP
InferSyncedStoreClient is the recommended way to get a typed store client. It infers mutators, actions, and table types from the schema, giving you full autocomplete and type checking.
Entry Point (app/src/entry.tsx)
The entry point is the only file that imports poe-apps-sdk. It sets up the synced-store and renders the TodoApp inside a TodoStoreProvider, which injects the store via React context. The UI components in ui/ never import poe-apps-sdk directly — this makes them testable without mocking the platform API.
tsx
import { Poe } from "poe-apps-sdk/embed-api/v1.js";
import { createRoot } from "react-dom/client";
import { todoClientConfig, type TodoStoreClient } from "../../client";
import { TodoStoreProvider } from "../../ui/todo-store-context";
import { TodoApp } from "../../ui/TodoApp";
const store = Poe.setupStore(
todoClientConfig,
) as unknown as TodoStoreClient;
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<TodoStoreProvider store={store}>
<TodoApp />
</TodoStoreProvider>,
);
}Key Differences from No-Build
| No-Build | Bundled | |
|---|---|---|
| Import | poe-apps-sdk/embed-api/v1.js | poe-apps-sdk/embed-api/v1.js |
| Schema | Inline mutators | defineSchema() + defineClientConfig() + defineBackendConfig() |
| Config | { mutators, schemaVersion } | Pre-built typed configs |
| Types | None (plain JS) | Full type inference from Zod schema |
The poe-apps-sdk/embed-api/v1.js Import
Both no-build and bundled apps import from poe-apps-sdk/embed-api/v1.js. For bundled apps, TypeScript resolves types via the poe-apps-sdk workspace package's ./v1.js subpath export at dev time. The bundler marks it as external, and the browser resolves it via the import map at runtime.
Poe Employee Note
The platform currently injects an import map into the app's index.html at serve time to make poe-apps-sdk/embed-api/v1.js resolvable. 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 uses Vite for the frontend and buildBackend() for the backend config, producing:
app-frontend.js— ESM bundle for the browser (via Vite)synced-store-backend-config.js— Backend config for the sync server (via esbuild)
bash
# Frontend: Vite builds app/index.html → dist/app-frontend.js
vite build
# Backend: esbuild bundles backend config
bun scripts/build-backend.tsThe 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:
| Hook | Purpose |
|---|---|
useStore(config, instanceId) | Acquire a managed store instance with automatic lifecycle |
useLiveQuery(store, queryFn) | Subscribe to a live query that re-renders on changes |
StoreManagerProvider | Context 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(todoClientConfig, roomId);
const { data: todos } = useLiveQuery(store, async (ctx) => {
const entries = await ctx.table("items").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.