Appearance
Composing apps (advanced)
Most apps don't need this. Build a single app first — one synced-store schema with the right visibility tiers handles role-specific or hidden state without splitting. Read this only when you have a concrete reason to compose (below) or are wiring a multi-user space that launches sub-apps.
Single app vs composition
Default: single app. Codenames, Chess, Tic-Tac-Toe, polls, whiteboards — all single apps.
Compose into multiple apps only when at least one holds:
- Sub-experience private to a subset of parent's users — per-instance privacy beats per-channel ACLs in one giant schema.
- Reusable, independently-publishable primitive — e.g. a "threaded chat" multiple parents embed.
- Lifecycles diverge sharply — parent long-lived (server membership, tournament season), children created / used / archived independently (channels, matches).
Not enough reasons: "cleaner architecture", different screens / modes / roles of the same experience, splitting lobby + game. Routes inside one app are simpler than cross-app coordination.
<poe-app> resolves children by type-id, which only exists once the child has been published. When composing: publish each child first, capture type-id from poe-tiles apps list, wire into parent as constants, then republish the parent.
Picker + detail: split view
When a parent is essentially a picker (server channel list, gallery, lobby) and the "experience" lives in the picked sub-app, prefer split view over inline <poe-app> embedding. Split view runs the two apps side-by-side at the root level on desktop, and falls back to a normal forward navigation on mobile — one call site, both form factors:
javascript
// From the picker app
await Poe.open({
typeId: pickedAppId,
instanceId: pickedInstanceId,
placement: "splitView",
// Picker is a fixed-width list; opened app gets the rest of the screen.
viewWidth: "260px",
});Subsequent placement: "splitView" calls from the same picker swap the side pane in place, so a single picker drives a single detail pane through many selections without churning its own state.
Embedding with <poe-app>
Alternative to split view: render a sub-app directly inside the parent's DOM using the <poe-app> custom element. Each <poe-app> instance gets its own synced-store instance scoped to its instance-id; the child inherits the parent's room by default.
html
<poe-app type-id="my-chat" instance-id="room-42"></poe-app>javascript
// Register the element once on entry so the parent can render <poe-app> in its JSX/HTML.
// `environment` is the same `PostMessageEnvironment` you pass to `createPoe()` — see
// the Initialization snippet in client-api.md for the full wiring.
import {
createPoe,
PostMessageEnvironment,
registerPoeAppElement,
} from "poe-tiles-sdk/v1/client.js";
const environment = new PostMessageEnvironment();
const Poe = createPoe({ environment });
registerPoeAppElement(environment);Use this over Poe.open({ placement: "splitView" }) when the sub-app should be a real DOM child of the parent (parent's layout drives sizing, multiple sub-apps render simultaneously, parent owns the surrounding chrome). Use split view when the sub-app should behave as a top-level app (own back button, deep links on mobile, swaps in place across selections).
See <poe-app> attributes for the full attribute reference (including room="explicit" + room-type-id / room-instance-id for cross-room embeds).
See Poe.open() in client-api.md for the full parameter reference.
Rooms
Most apps don't need this. Refer to this section only when launching a sub-app that breaks out of the current room (e.g. a chat launching a game whose
$usersshould differ from the chat's roster, or an app registering members against a foreign room). Standalone apps and simple parent/child embeds inherit the right roster automatically — the platform sets sensible defaults.
What a room is
A room is an app instance whose $users roster is the source of truth for membership across a set of related sub-app instances. The platform fans every addUser / removeUser on the room out to every registered member instance, so sub-apps inherit the roster automatically — including users admitted before the sub-app existed.
Most apps never touch room wiring directly:
- Top-level apps opened from the manager become their own room.
- Children opened via
Poe.open()or<poe-app>default to inheriting the opener's room. - Apps just read
$usersand trust the roster.
Reach for the explicit APIs below only when the calling app is the room and needs to register sub-apps as members or react when they're added.
Room launches a sub-app
A parent that is the room mints a sub-app instance from its own UI. Two pieces:
- Parent-side mutator — call
addInstanceToRoomto register the new instance on$room_member_instancesso the platform fans the parent's$usersinto the sub-app. App-level mutators can't read their own$$system:roomto auto-detect role, so the caller picks based on what it knows: when the calling store IS the room, omitroomand the helper dispatches to itself. From a member store registering some other instance, passroom: { storeTypeId, instanceId }explicitly. - Client — call
Poe.open({ placement: "splitView" })to mount the sub-app alongside the parent. The client-sideroom: { kind: "inherit" }fan-out writes the same$room_member_instancesrow idempotently, so racing the mutator and open is safe.
typescript
// Parent mutator (runs on the room store).
import { addInstanceToRoom } from "poe-tiles-sdk/v1/client.js";
launchSubApp: async (ctx, input) => {
await addInstanceToRoom(ctx, {
storeTypeId: input.appTypeId,
instanceId: input.appInstanceId,
});
},typescript
// Parent client. Mint the instanceId, fire the mutator, open in split view.
const appInstanceId = generateUUID().slice(0, 8);
await Promise.all([
store.mutate.launchSubApp!({ appTypeId: app.id, appInstanceId }),
Poe.open({
typeId: app.id,
instanceId: appInstanceId,
placement: "splitView",
isNew: true,
}),
]);
// Re-open later (any member). The instance already exists — no `isNew`.
Poe.open({ typeId, instanceId, placement: "splitView" });Security: if you persist the launch as a styled, attributed event (transcript row, activity log, "X started Y" announcement), store only
(appTypeId, appInstanceId)plus server-trusted launcher identity and resolve the creator handle / app name at render time fromPoe.tiles.get({ typeId: appTypeId }). Persisting client-supplied creator handle / app name lets a member call the mutator directly with spoofed values, then the styled UI renders verified-looking attribution on every viewer's screen.
RoomMembershipConflictError
addInstanceToRoom dispatches addAppInstanceToRoom on the target room store via ctx.mutateExternal. When the target store throws RoomMembershipConflictError — which happens when:
- The target instance is already a member of a different room (
$$system:roompins to anothermemberOfref). - The target instance is itself a room (
{ type: "self" }already pinned).
— the throw fires post-commit on the room store, not on the source promise. ctx.mutateExternal is fire-and-forget (it returns void, not a promise), so the source mutator's await store.mutate.launchSubApp!(...) resolves cleanly even when the dispatch later fails. There is no try/catch recovery on the source path.
The single-room invariant means the right defense is upstream: mint a fresh appInstanceId for every launch (the example above slices a generateUUID() for exactly this reason), and never reuse one that already belongs to a foreign room or is itself a room. If you genuinely need to verify post-hoc whether the registration landed, observe the $room_member_instances row on the room (the headless flat-room suite polls for row absence as the canonical signal); a missing row after the dispatch settles is the only client-side surface for the conflict.
Reacting to a new member instance
The room store can run code when a sub-app is registered (seed per-instance state, log a transcript row, send a notification) via the onAddAppInstanceToRoom system hook. Fires only on first registration; idempotent re-registers are suppressed.
Cross-store dispatch carries the room
When a member dispatches to another store via ctx.mutateExternal, the trusted server stamps ctx.source.room on the receiver with the source's resolved room ref (a { storeTypeId, instanceId } pair, server-resolved from the source's $$system:room). Use this to authorize / scope writes against the same room without trusting client input. See synced-store external-stores.md for full semantics.
Sharing the room
Poe.room.openInvitePicker() asks the host to open an invite picker. Today it is instance-scoped — the host resolves the caller's identity from the trusted RPC context (app cannot forge it) and scopes the picker to the caller's (storeTypeId, instanceId), not the caller's $$system:room. Room-aware scoping (using the room's $users roster instead of the calling instance's) is a follow-up. Until then, calling this from a member sub-app adds invitees to the sub-app instance's roster, not the room's — call it from the room store itself if you want invitees to join the room. See Poe.room.openInvitePicker().
Reference: room mode forms
Three places talk about rooms, each with its own representation. They are the same concept in three forms:
| Where | Form | Values |
|---|---|---|
Poe.open({ room }) (JS) | tagged object, kind field | { kind: "self" } · { kind: "inherit" } · { kind: "explicit", storeTypeId, instanceId } |
<poe-app room="…"> (HTML attr) | bare string + companion attrs | "self" · "inherit" · "explicit" (with room-type-id / room-instance-id) |
$$system:room (stored, server-pinned) | tagged object, type field | { type: "self" } · { type: "memberOf", storeTypeId, instanceId } |
The first two are intents set when the instance is first opened. The server resolves the intent and pins one stored state:
selfstaysself.explicitbecomes{ type: "memberOf", ...givenRef }.inheritresolves to the opener's room —memberOfof the opener's foreign room if it has one, otherwisememberOfof the opener itself (which is the room). Falls back to{ type: "self" }only when the logical parent cannot be a room (e.g. the manager /disallowedRoomTypeIds).
The pin cannot change afterwards — the platform enforces a single-room invariant.
See Poe.open() parameters and <poe-app> attributes for the wire-level form.