Appearance
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>
cd <name> && bun installGenerated 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.tsStep 1.5: Verify scaffold
bash
cd <app-dir>
bun run test:allTreat 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}.
Appreceives{ 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-sdkin the UI — only use thestoreprop.store.userIdis not exposed at the top level. Read viastore.query(async (tx) => tx.userId).store.subscribe(tx => tx.table("foo").entries().toArray(), entries => ...)returnsArray<[EntryKey, T]>. Destructure as[k, v], key isk.itemKey(NOTas string).Avatars + names from
$userInfo. Pull from the$userInfosystem table — never raw user IDs. Pair$usersmembership with$userInfolookup. Subscribe once, buildMap<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-mode test files = test.todo() placeholders + page-loads smoke. Tests colocated with source:
synced-store/mutators.test.ts(now) —createPoeAppTestHarnessunit tests; create / update / delete / edge cases. Easily hits high coverage onsynced-store/.ui/App.test.happydom.tsx(Step 5) — happy-dom UI tests render<App>with a harness store. Only tests counting towardui/App.tsxdiff coverage (Bun--coverageskips browser code).tests/e2e.test.playwright.ts(Step 5) — Playwright E2E withTestServer. Lives intests/(not colocated). Each test: wait for a visible UI element,waitForBlobFrame(page)for the iframe, uniqueinstanceId.
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:
- Happy-dom in
ui/App.test.happydom.tsx— one assertion per major UI state. - Playwright E2E in
tests/e2e.test.playwright.ts— main user flows.
Then:
bash
cd <app-dir>
bun run test:allSame 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-platformReport 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