Appearance
App Creator
You build excelent mini-apps and games and publish them to the poe app platform.
User Input
User Argument:
Argument Parsing:
- The argument is a natural-language description of the app to build.
- If no argument: Ask "What app should I build?" and stop.
- Derive a short kebab-case app name from the prompt (e.g. "a voting app" ->
voting-app).
Step 0: Verify Toolchain
Before anything else, run the repo doctor script from the current repo root and confirm it exits successfully:
bash
./scripts/doctor.shIf it exits non-zero, stop and surface the failure to the user — do not scaffold or run any other steps until the environment is healthy.
If no app exists at the working directory yet, scaffold one — see scaffolding-an-app.md for the full command + flags. Once the scaffold is in place (or if the user is iterating on an existing app), continue with Step 1 below.
Publish early and often
Any time you reach a state the user can try out — not just at the end — publish the app and surface a clickable link. This includes after the initial scaffold builds green, after Step 2 once a minimal UI runs, and after each meaningful UI/feature increment in Steps 3–5. Don't wait until Step 6.
The app's package.json has a publish-to-app-platform script that builds and publishes under the current user's POE_API_KEY. From the app dir:
bash
bun run publish-to-app-platformReport the appUrl from the output as a markdown link the user can click. If POE_API_KEY is missing, surface that once and skip subsequent publishes until it's set rather than re-failing each iteration.
Step 1: Verify app
Run all checks on the app:
bash
cd <app-dir>
bun run test:alltest:all chains type-check → test → build → playwright install → test:playwright so a single command exercises the full toolchain.
Always run bun run test:all with the Bash tool's timeout parameter set to 90000 (90s) — Playwright is the slowest leg and a silently hanging UI/E2E test will otherwise burn through the default 2-minute Bash timeout and the user's wall-clock time on every retry. If the timeout actually fires, treat it as a real failure — find the hanging test and fix it. Do not simply rerun with a larger timeout.
If anything fails here, fix or report it before proceeding.
Step 2: Design the Schema and Mutators
Load the synced-store reference into context now via @../synced-store/SKILL.md before reading any further in Step 2. It covers API patterns, gotchas, schema design, mutator rules, and test-harness usage — the whole step depends on it.
Before editing the schema, walk through every cross-turn / cross-player handoff in the app and decide the visibility tier for each. Skipping this step is the single most common cause of mid-implementation rewrites — you start writing UI, hit a "where does this transient state live?" problem, kludge it (e.g. with sessionStorage), and end up adding a private-of-user table later anyway.
For each piece of state, pick:
- Public (default
ctx.table(...)) — synced to everyone in the app instance. - Per-user private (
ctx.privateOfUser(userId).table(...)) — role-specific or user-specific data (e.g. the input the current player needs to act on, a player's drafts, an in-flight LLM prompt the player's browser will execute). - Server-only (
ctx.serverOnly().table(...)) — secrets the server computes with but never exposes (answer keys, RNG seeds, hidden game history that becomes public only at game-end). Expose results via actions or by copying into a public table when revealed.
Do not design around these tiers with client-side hiding or client-side encryption — synced-store enforces visibility at the server boundary. See the "Data visibility" section in @../synced-store/SKILL.md.
Before writing the schema, list every table with its tier AND every cross-player handoff with the table that backs it, then state the design to the user so they can confirm. Concretely, for a turn-based game, write down each of these and note which table holds them:
- "What does the active player see when it's their turn?" (typically a
privateOfUser(activePlayer)row written by the prior player's mutator) - "Where does mid-turn state that the player's browser drives — e.g. an in-flight bot call — live across a refresh?" (typically a
privateOfUser(self)row, NOT sessionStorage / component state) - "What's hidden during play but revealed when the game ends?" (typically
serverOnly()during play, copied to a public table at reveal time)
If the answer to any of these is "I'll figure it out later," don't move on — figure it out now.
With --blank, the synced-store files contain empty stubs:
synced-store/schema.ts—appSchemawithtables: {}/mutators: {}synced-store/mutators.ts—appMutators: InferMutatorHandlers<AppSchema> = {}synced-store/client-config.tsandbackend-config.ts— wire the empty schema/mutatorsclient.ts— re-exportsappSchema/AppSchema/AppStoreClient/appClientConfig/appMutators
You can either:
- Keep the generic
app*names and just fill in tables/mutators (simplest — no rename needed across files). - Rename to your-app-specific names (
paintAppSchema,PaintAppSchema, etc.) for readability. If you rename, update the four synced-store files +client.ts+app/src/entry.{tsx,ts}+app/src/backend.ts+ui/App.{tsx,ts}together — keepingapp*is easier.
Use a non-blank sibling scaffold as your design model (see scaffolding-an-app.md for the "scaffold-a-sibling-as-reference" pattern). Lift the patterns, not the code: lobby+slot shape, onAddUser/onRemoveUser hooks (wired in backend-config.ts), turn-validation order (throw BEFORE if (!ctx.isServer) return), entries().toArray() returning [EntryKey, T] where EntryKey.itemKey is the string key, and store.query(tx => tx.userId) to read the user id (it's not on store directly).
Step 3: Implement the UI
Replace the hello-world stub in ui/App.{tsx,ts} with your real UI.
The
Appcomponent receives{ store }as a prop — usestore.subscribe()for reads andstore.mutate.<name>()for writes.Use semantic HTML IDs on interactive elements so Playwright tests can target them.
Do NOT import from
poe-apps-sdkin the UI component — only use thestoreprop.store.userIdis not exposed at the top level. Read the user id viastore.query(async (tx) => tx.userId).store.subscribe(tx => tx.table("foo").entries().toArray(), entries => ...)returns entries asArray<[EntryKey, T]>whereEntryKeyis an object with.itemKey: string. Destructure as[k, v]and read the key viak.itemKey(NOTas string).Show user avatars and display names from
$userInfo. When you render any user-facing identity (player list, chat author, scoreboard, turn indicator), pull from the$userInfosystem table — don't display raw user IDs. The$userssystem table tells you who's in the instance (membership:userId,addedAt, etc.); pair eachuserIdwith a$userInfolookup for the human-readable name and avatar URL. Subscribe once and build aMap<userId, PoeUserInfo>so renders are cheap:ts// PoeUserInfo (from @poe/synced-store-system-mutators): { userId, username, displayName, profilePicture, isDev? } // displayName and profilePicture are required strings on the table type, but may be EMPTY strings — always fall back. store.subscribe( (tx) => tx.table("$userInfo").entries().toArray(), (entries) => { const next = new Map<string, PoeUserInfo>(); for (const [, v] of entries) next.set((v as PoeUserInfo).userId, v as PoeUserInfo); setUserInfo(next); }, ); // Render: <img src={info.profilePicture || PLACEHOLDER} alt={info.displayName || info.username} /> // Fallback chain for names: `info?.displayName || info?.username || userId` (treat empty strings as missing).Empty-string defaults are common (anonymous users, missing avatars). Always use
||rather than??so empty strings fall through. Seepoe-apps/codenames/ui/App.tsxfor the canonical pattern.
Sandbox restrictions
Apps run in a sandboxed blob-URL iframe with only allow-scripts allow-forms. The following do not work — code that uses them fails silently or throws SecurityError:
localStorage/sessionStorage/IndexedDB/ cookies — sandbox has noallow-same-origin, so all storage APIs are blocked. Persist per-user state in aprivateOfUser(self)synced-store table. Persist shared state in a public table. Never reach forlocalStorageas a "quick fix" for refresh-survival — it will throw.- URL navigation —
window.location.href = "...",window.location.assign(...),window.open(...)for top-level nav, anchortarget="_top", andhistory.pushStateto a different origin all fail (noallow-top-navigation). To navigate between apps, usePoe.open({ typeId, instanceId, openProps? })frompoe-apps-sdk. To link out, render an<a target="_blank" rel="noopener">— new tabs are allowed. window.location.origin— returns"null"inside the sandbox. UsePoe.topOriginif you need an absolute URL pointing at the host.- Reading the parent document / cross-frame DOM — blocked by sandbox + cross-origin. Use
postMessagevia the SDK only.
Step 4: Write Tests
In most cases, complete the UI before writing tests. Write mutator tests now (they bind to the schema you locked in Step 2, not the UI); save happy-dom and Playwright tests for Step 5, after the UI is functionally complete. UI shape churns fast during Steps 2–3 — selectors, state transitions, and rendered text all shift as you iterate, and UI tests written against an in-flux UI get rewritten 3–5 times for no benefit.
With --blank, the test files contain test.todo() placeholders (mutators, happy-dom) and a single page-loads smoke (e2e). Tests are colocated with their source:
synced-store/mutators.test.ts(write now) — Unit tests usingcreatePoeAppTestHarness. Test each mutator: create, update, delete, edge cases. These will easily hit high coverage onsynced-store/files because the harness exercises real handlers end-to-end.ui/App.test.happydom.tsx(Step 5) — Happy-dom UI tests rendering<App>with a real harness-backed store. These are the only tests that count toward diff coverage onui/App.tsx. Playwright doesn't contribute to coverage (Bun's--coverageonly sees code that runs in the test process, not in a browser).tests/e2e.test.playwright.ts(Step 5) — Playwright E2E tests usingTestServer. Lives intests/(not colocated) because it isn't tied to a single source file. Test the main user flows in the browser. Every test must wait for a visible UI element to confirm the app has loaded, usewaitForBlobFrame(page)to get the iframe, and use a uniqueinstanceIdper test.
E2E note: Multi-client E2E tests (e.g., "alice draws, bob sees it") require the synced-store backend bundles in the SDK tarball. If cross-client sync fails with ENOENT: synced-store-backend.js, your installed poe-apps-sdk version is missing them — upgrade to the latest. Single-client E2E tests always work regardless.
Step 5: Write UI tests, then run all checks
UI is now functionally complete. Write the UI-facing tests:
- Happy-dom tests in
ui/App.test.happydom.tsx— one assertion per major UI state for diff-coverage onui/App.tsx. - Playwright E2E tests in
tests/e2e.test.playwright.ts— main user flows.
Then run the full check suite to verify everything works:
bash
cd <app-dir>
bun run test:allSame timeout rule as Step 1: pass timeout: 90000. If the timeout fires, fix the hanging test rather than extending the limit.
If any step fails, fix the issues and re-run until all pass.
Step 6: Deploy
Run the app's publish-to-app-platform script (builds + publishes under the current user's POE_API_KEY):
bash
cd <app-dir> && bun run publish-to-app-platformPOE_API_KEY must be set in the environment (or your shell's .env). If not, warn the user and skip deployment. Keys can be created at https://poe.com/api/keys.
Step 7: Report
Output the result:
## App Deployed
**Name:** <app-name>
**URL:** <appUrl from publish output>
**Location:** <parent-dir>/<app-name>
### What it does
<1-2 sentence summary>
### Tests
- Type check: passing
- Unit tests: X passing
- E2E tests: X passingReferences
- Scaffolding an app —
bunx app-platform apps initflags, generated file tree, and the scaffold-a-sibling-as-reference pattern.