Skip to content

Scaffolding a new Poe app

End-to-end workflow for creating a new app from a natural-language prompt. Scaffold → schema → UI → tests → ship.


Step 1: Scaffold

Pick a template: react, preact, solidjs, vanilla-js.

bash
app-platform apps init <name> --template <t> --blank
cd <name> && bun install

Don't pass --committed — it's for use inside the ai-app monorepo only. It rewrites poe-apps-sdk to workspace:* (won't resolve outside the monorepo), pins zod to a version your project may not have, and emits a tsconfig that extends a parent which doesn't exist.

Why --blank is mandatory

Without --blank, the scaffold writes a working todo-list app you'd have to delete and rewrite. With --blank you get the same boilerplate (package.json, vite config, tsconfig.json, biome, index.html, styles.css) but these files are stubs:

  • synced-store/{schema,mutators,client-config,backend-config}.ts — empty schema/mutators wired through.
  • client.ts — re-exports appSchema / AppSchema / AppStoreClient / appClientConfig / appMutators.
  • ui/App.{tsx,ts} — hello-world stub.
  • synced-store/mutators.test.ts, ui/App.test.happydom.tsxtest.todo() placeholders.
  • tests/e2e.test.playwright.ts — single page-loads smoke.

Day-zero bun run type-check / test / build / test:playwright all pass — no half-converted broken state while you write your real code.

Generated files

<name>/
├── app/                       # entry point + backend wiring
│   └── src/
│       ├── entry.tsx          # calls Poe.setupStore(clientConfig); renders UI
│       └── backend.ts         # re-exports appBackendConfig as default
├── synced-store/              # store contract
│   ├── schema.ts
│   ├── mutators.ts
│   ├── mutators.test.ts       # colocated unit tests
│   ├── client-config.ts
│   └── backend-config.ts
├── ui/                        # store-agnostic components
│   ├── App.{tsx,ts}
│   └── App.test.happydom.tsx  # colocated UI tests
├── tests/                     # e2e (Playwright) + setup-dom helper
├── scripts/doctor.sh          # toolchain health check
├── client.ts                  # client-safe re-exports
├── package.json
├── tsconfig.json
├── vite.config.ts
└── playwright.config.ts

Step 1.5: Verify scaffold

bash
cd <app-dir>
bun run test:all

Treat a fired timeout as a real failure — find/fix the hanging test, don't extend the limit. test:all chains type-check → test → build → playwright install → test:playwright.


Step 2: Schema and mutators

Read @../synced-store/SKILL.md now before continuing — Step 2 depends on it.

Walk every cross-turn / cross-player handoff and pick a visibility tier per piece of state. Skipping this is the #1 cause of mid-implementation rewrites.

Tiers:

  • Public (ctx.table(...)) — synced to everyone in the instance.
  • Per-user private (ctx.privateOfUser(userId).table(...)) — role/user-specific (active player's input, in-flight LLM prompt, drafts).
  • Server-only (ctx.serverOnly().table(...)) — secrets the server uses but never exposes (answer keys, RNG seeds).

Don't design around tiers with client-side hiding/encryption — synced-store enforces at the server boundary.

Before writing schema, list every table with its tier AND every cross-player handoff with the table backing it; state the design to the user for confirmation. For turn-based games answer:

  • Active player's view this turn? → typically privateOfUser(activePlayer) written by the prior player's mutator.
  • Mid-turn state surviving refresh (e.g. in-flight bot call)? → privateOfUser(self), NOT sessionStorage / component state.
  • Hidden during play, revealed at end? → serverOnly() during play, copied to public on reveal.

If any answer is "figure out later," figure it out now.

Either keep generic app* names (simplest) or rename across synced-store/* + client.ts + app/src/entry.{tsx,ts} + app/src/backend.ts + ui/App.{tsx,ts} together.

Lift patterns from a reference app, not 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] with EntryKey.itemKey: string, store.query(tx => tx.userId) for user id.


Step 3: UI

Replace the stub in ui/App.{tsx,ts}.

  • App receives { store } prop. store.subscribe() for reads, store.mutate.<name>() for writes.

  • Semantic HTML IDs on interactive elements (Playwright targets).

  • Don't import from poe-apps-sdk in the UI — only use the store prop.

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

  • store.subscribe(tx => tx.table("foo").entries().toArray(), entries => ...) returns Array<[EntryKey, T]>. Destructure as [k, v], key is k.itemKey (NOT as string).

  • Avatars + names from $userInfo. Pull from the $userInfo system table — never raw user IDs. Pair $users membership with $userInfo lookup. Subscribe once, build Map<userId, PoeUserInfo>:

    ts
    // PoeUserInfo (from @poe/synced-store-system-mutators): { userId, username, displayName, profilePicture, isDev? }
    // displayName / profilePicture are required strings but may be EMPTY — 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);
        },
    );
    // <img src={info.profilePicture || PLACEHOLDER} alt={info.displayName || info.username} />
    // Name fallback: info?.displayName || info?.username || userId — empty strings count as missing.

    Use || not ?? so empty strings fall through.


Step 4: Mutator tests

Complete the UI before writing UI tests. Mutator tests bind to schema (Step 2), so write those now; happy-dom + Playwright wait until Step 5. UI churns fast Steps 2–3 — UI tests against in-flux UI get rewritten 3–5×.

--blank test files = test.todo() placeholders + page-loads smoke. Tests colocated with source:

  • synced-store/mutators.test.ts (now)createPoeAppTestHarness unit tests; create / update / delete / edge cases. Easily hits high coverage on synced-store/.
  • ui/App.test.happydom.tsx (Step 5) — happy-dom UI tests render <App> with a harness store. Only tests counting toward ui/App.tsx diff coverage (Bun --coverage skips browser code).
  • tests/e2e.test.playwright.ts (Step 5) — Playwright E2E with TestServer. Lives in tests/ (not colocated). Each test: wait for a visible UI element, waitForBlobFrame(page) for the iframe, unique instanceId.

With substantial UI: write one happy-dom test per major UI state (lobby / playing / generating / reveal). Without these, bun run pre-commit passes locally but CI's check-diff-coverage (80% threshold) fails — App.tsx routinely lands 30–50% with only a lobby test, and merging requires a human-attested oath in the PR description. One assertion per state is enough; drive transitions via store.mutate.<...> (faster, deterministic) or DOM clicks.

E2E note: Multi-client E2E tests (e.g., "alice draws, bob sees it") need synced-store backend bundles in the SDK tarball. If cross-client sync fails with ENOENT: synced-store-backend.js, republish with bun run publish-tar from packages/poe-apps-sdk/. Single-client E2E always works.


Step 5: UI tests + full check

UI is functionally complete. Write:

  1. Happy-dom in ui/App.test.happydom.tsx — one assertion per major UI state.
  2. Playwright E2E in tests/e2e.test.playwright.ts — main user flows.

Then:

bash
cd <app-dir>
bun run test:all

Same timeout: 90000. Iterate until green.


Step 6: Publish

Build + publish under the current user's POE_API_KEY:

bash
cd <app-dir> && bun run publish-to-app-platform

Report the appUrl from the output as a clickable markdown link the user can try.

If POE_API_KEY is missing, warn and skip. Keys: https://poe.com/api/keys.



Final step: Report

## App Scaffolded

**Name:** <app-name>
**Location:** poe-apps/<app-name>
**Draft PR:** <PR URL from /commit>

### What it does
<1-2 sentence summary>

### Tests
- Type check: passing
- Unit tests: X passing
- E2E tests: X passing