Skip to content

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.sh

If 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-platform

Report 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:all

test: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.tsappSchema with tables: {} / mutators: {}
  • synced-store/mutators.tsappMutators: InferMutatorHandlers<AppSchema> = {}
  • synced-store/client-config.ts and backend-config.ts — wire the empty schema/mutators
  • client.ts — re-exports appSchema / 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 — keeping app* 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 App component receives { store } as a prop — use store.subscribe() for reads and store.mutate.<name>() for writes.

  • Use semantic HTML IDs on interactive elements so Playwright tests can target them.

  • Do NOT import from poe-apps-sdk in the UI component — only use the store prop.

  • store.userId is not exposed at the top level. Read the user id via store.query(async (tx) => tx.userId).

  • store.subscribe(tx => tx.table("foo").entries().toArray(), entries => ...) returns entries as Array<[EntryKey, T]> where EntryKey is an object with .itemKey: string. Destructure as [k, v] and read the key via k.itemKey (NOT as 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 $userInfo system table — don't display raw user IDs. The $users system table tells you who's in the instance (membership: userId, addedAt, etc.); pair each userId with a $userInfo lookup for the human-readable name and avatar URL. Subscribe once and build a Map<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. See poe-apps/codenames/ui/App.tsx for 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 no allow-same-origin, so all storage APIs are blocked. Persist per-user state in a privateOfUser(self) synced-store table. Persist shared state in a public table. Never reach for localStorage as a "quick fix" for refresh-survival — it will throw.
  • URL navigationwindow.location.href = "...", window.location.assign(...), window.open(...) for top-level nav, anchor target="_top", and history.pushState to a different origin all fail (no allow-top-navigation). To navigate between apps, use Poe.open({ typeId, instanceId, openProps? }) from poe-apps-sdk. To link out, render an <a target="_blank" rel="noopener"> — new tabs are allowed.
  • window.location.origin — returns "null" inside the sandbox. Use Poe.topOrigin if you need an absolute URL pointing at the host.
  • Reading the parent document / cross-frame DOM — blocked by sandbox + cross-origin. Use postMessage via 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 using createPoeAppTestHarness. Test each mutator: create, update, delete, edge cases. These will easily hit high coverage on synced-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 on ui/App.tsx. Playwright doesn't contribute to coverage (Bun's --coverage only sees code that runs in the test process, not in a browser).
  • tests/e2e.test.playwright.ts (Step 5) — Playwright E2E tests using TestServer. Lives in tests/ (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, use waitForBlobFrame(page) to get the iframe, and use a unique instanceId per 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:

  1. Happy-dom tests in ui/App.test.happydom.tsx — one assertion per major UI state for diff-coverage on ui/App.tsx.
  2. 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:all

Same 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-platform

POE_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 passing

References

  • Scaffolding an appbunx app-platform apps init flags, generated file tree, and the scaffold-a-sibling-as-reference pattern.