Appearance
Client API
The poe-tiles-sdk/v1/client.js module is the client-side JavaScript interface for apps running inside Joiner activity frames. It provides store initialization, data access, and bot interaction — all from your app's frontend code.
Docs describe the latest SDK
This reference tracks the most recently published poe-tiles-sdk tarball. If a documented method doesn't exist in your app (TypeScript error, or undefined at runtime), your app's SDK pin is older than the docs — see Upgrading an existing app's SDK. Recently introduced APIs carry an Added <date> note so you can tell at a glance.
typescript
import { createPoe, PostMessageEnvironment, registerPoeTileElement } from "poe-tiles-sdk/v1/client.js";Client APIs
Poe.setupStore()— Initialize a synced storePoe.store— Access the SyncedStoreClient after setup- AI model calls live in an action, not here
Poe.listModels()— List available Poe modelsPoe.getPoeBotAccess()— Whether the user can make Poe-backed bot/agent callsPoe.requestPoeBotAccess()— Same check, but the platform prompts the user to fix a blocked verdict (inline Poe-account linking)Poe.getBundleAssetUrl()— Get a blob URL for a bundled assetPoe.tiles.list()— List all published appsPoe.tiles.get()— Fetch one published app by typeIdPoe.tiles.search()— Search the public app catalogPoe.tiles.preload()— Preload an app's bundle for instant loadingPoe.tiles.syncStatus()— Creation-lifecycle sync status of an instance on this device (subscribable)Poe.open()— Navigate to a different app (or open it in split view alongside the caller)Poe.showTileLauncher()— Ask the host to show the standard activity launcherPoe.users.openProfile()— Open a user's profile UI in the hostPoe.room.openInvitePicker()— Open the host Add members invite surface for the calling app instancePoe.room.openChat()— Reveal the containing room's chat in the host-native surfacePoe.room.pickMembers()— Pick room members, optionally adding contacts to the room firstPoe.room.tileEnd()— Show the host end-of-activity UI and optionally replayPoe.room.setShareLeaderboardId()— Make current-page share links point at an activity-owned leaderboardPoe.room.dismissTileEnd()— Programmatically dismiss this client's tile-end overlay (shared-instance team games)Poe.agents.create()— Create an agent owned by the calling user, optionally adding it to a roomPoe.agents.delete()— Delete one of your agents outright (irreversible)Poe.agents.addToRoom()/Poe.agents.removeFromRoom()— Add/remove an existing agent as a room memberPoe.agents.listTools()— List the first-party agent system-tool catalogPoe.agents.listTemplates()— List the first-party agent-template catalogPoe.agents.listMine()— List your own live agentsPoe.track()— Send a privacy-filtered analytics eventPoe.openExternalUrl()— Open a web link via the host (platform links navigate in place; other links need user confirmation)Poe.openSettings()— Ask the host to open its Settings page<poe-tile>— Embed a child app inlinePoe.getOpenProps()— Read data passed by a parent appPoe.consumeEntryContext()— Read how the user reached the activity (push/banner/badge/direct) + the notification's entry contextPoe.parent— Parent store identity (for child apps)Poe.topOrigin— Origin of the top (host) document, for building absolute URLs from sandboxed iframesPoe.haptics— Trigger cross-platform haptic feedbackresumeAudioContext()— Recover Web Audio after browser or iOS system-audio interruptionscreateVerticalScrollBounceMount()— Create a root render target with native vertical pull bounceinstallVerticalScrollBounce()— Add native vertical pull bounce to a custom scroll areaisIosApp()— Detect the iOS app WebView, including app iframesisAndroidApp()— Detect the Android app WebView, including app iframesnotifyActivity()— Notify the manager of activity (preview, unread)setTurn()/clearTurn()— Declare whose turn it is (the "Your Turn" indicator)notifyInProgress()— Report long-running work (the Recents-row spinner)assertRoomMember()— Server-side validation that a user is still in the caller's roomnotifyUsersAddedToTile()— Standard room-aware "added/assigned to this tile" notificationaddInstanceToRoom()— Register an app instance as a member of a flat roomgetCurrentUserId()— Read the current user's userId from a store (UI/effect helper)
Poe Employee Note
Currently the platform injects an import map into the app's index.html at serve time, which is what makes import { createPoe } from "poe-tiles-sdk/v1/client.js" work without a bundler. In the future we'll probably want creators to include a script tag instead (e.g. <script src="https://poe.com/v1/poe-tiles-sdk.js"></script>) so the mechanism is more explicit and doesn't require server-side HTML rewriting.
Background
Apps run inside sandboxed iframes. To ensure apps load even when offline, app bundles are not fetched over HTTP at runtime. Instead, the top document caches all bundle assets and serves them to the iframe via postMessage. This is why APIs like Poe.getBundleAssetUrl() exist — they request assets from the top document's cache and return blob URLs, rather than making network requests.
Initialization
Every app must explicitly create a Poe instance before using any APIs. This happens once, in your app's entry file — tile/src/entry.ts (or entry.tsx) in the official templates, whose header comment reads "the only file that imports poe-tiles-sdk":
javascript
import { createPoe, PostMessageEnvironment, registerPoeTileElement } from "poe-tiles-sdk/v1/client.js";
const environment = new PostMessageEnvironment();
const Poe = createPoe({ environment });
// Only needed if your app uses <poe-tile> to embed other apps
registerPoeTileElement(environment);createPoe() returns the Poe API object and automatically registers it as the module-level singleton, so code-split chunks can access it. Pass { singleton: false } to disable this (useful in tests).
Reaching Poe from the rest of your app
Every Poe.* example in this reference assumes a Poe in scope. Poe is not a global variable — nothing outside the entry file gets one implicitly. Keep the entry file the only module that imports the SDK, and hand what you need to the rest of your app:
- Pass the whole object when a module uses several APIs.
- Pass one capability when it uses one — give a board component
Poe.hapticsrather than all ofPoe. Narrower to stub in tests.
Types come from the same entry point, so a receiving module can be typed without importing any runtime SDK code:
typescript
import type { PoeAPI } from "poe-tiles-sdk/v1/client.js";
// The whole API…
function createBoard(poe: PoeAPI) {
poe.haptics.impact("light");
}
// …or a single capability.
function createScoreboard(haptics: PoeAPI["haptics"]) {
haptics.notification("success");
}How you thread it through is your framework's business — a function argument, a component prop, a context or store object, or a module-level variable you assign at startup. The templates already do exactly this with the synced store (createApp(appRoot, store) in the plain-JS template, <App store={store} /> in the component ones); extend that same call with whatever else the app needs:
javascript
// entry file
const Poe = createPoe({ environment });
const store = Poe.setupStore(myClientConfig);
createApp(appRoot, store, Poe.haptics); // or <App store={store} haptics={Poe.haptics} />For a module that genuinely cannot be handed the instance — a lazily imported chunk, say — call getPoeInstance() from poe-tiles-sdk/v1/client.js. It returns the singleton that createPoe() registered, and throws if createPoe() hasn't run yet. Prefer passing the value explicitly where you can; it keeps the dependency visible and the module testable.
Core APIs
Poe.setupStore()
Initialize a synced store inside your app.
javascript
async function addTodo(ctx, input) {
await ctx
.table("todos")
.set({ itemKey: input.id, value: { text: input.text, done: false } });
}
const store = Poe.setupStore({ mutators: { addTodo }, schemaVersion: 1 });
await store.waitForBootstrap();
// render UIawait store.waitForBootstrap() before rendering UI. It resolves as soon as authoritative data is ready from either local cache or first server pull, so offline-capable launches (cached instance, or a fresh instance declared via Poe.tiles.prepareNewInstances) unblock immediately. Don't wait for a server milestone here — doing so stalls offline launches and brand-new prepared instances that have no server state to fetch. Reserve waitForServerData() for the first server-origin data event, or waitForInitialPull() for the point when the full initial pull has been applied, in specialized tests and Node-side scripts.
Poe.store
A SyncedStoreClient reference. Available after calling setupStore().
AI model calls live in an action, not here
There is no client-side model API. An activity reaches a model only from a synced-store action — see createModelStream / createImageGenerator. That is not a detour: it keeps your prompts out of the shipped client bundle, lets every player in a room see the same result, and means an activity can never hold or forward a Poe credential.
What stays on the client is deciding whether and which: Poe.listModels() to pick a model and Poe.requestPoeBotAccess() to make sure the user can pay for the call — then dispatch your action.
javascript
const model = pickModel(await Poe.listModels());
if (!(await Poe.requestPoeBotAccess()).canUse) return;
const { text } = await Poe.store.action.askModel({ model, prompt });Poe.listModels()
List all available Poe models. Returns an array of Model objects with metadata like id, description, owned_by, architecture, pricing, and context_window.
javascript
const models = await Poe.listModels();
console.log(models.map(m => m.id)); // ["claude-opus-5", "gpt-5.6-sol", ...] — catalog ids, not Poe bot handlesUse this to discover a model id to pass to your model-calling action, or to build a model picker UI. Match on id SEGMENTS (lab + model), never on the whole string: one model can appear canonically as lab/model/provider or as a bare alias, and a whole-string pattern silently misses one of the two shapes.
The array order is the product's current model ranking (the first entry is Poe's default model), not a stable contract: it changes as the ranking changes. Sort client-side if your activity needs a fixed order.
Poe.getPoeBotAccess()
Check whether the current user can make Poe-backed bot/agent calls right now, and if not, why. Returns a discriminated union — { canUse: true } or { canUse: false; reason } — so reason is present exactly when access is blocked. Only the verdict, never the user's Poe API key.
javascript
const access = await Poe.getPoeBotAccess();
if (!access.canUse) {
// access.reason is one of:
// "poe_link_required" — no Poe account linked
// "poe_relink_required" — the link expired; reconnect
// "poe_pay_with_points_required" — linked, but Poe points aren't enabled
// "poe_bot_backend_unavailable" — Poe-backed bots are unavailable here
// Guide the user to fix it (e.g. open settings) instead of letting the call fail.
}Poe-backed bot/agent calls need a usable Poe account. Use this to gate those features up front — and surface the specific reason — instead of letting the call fail server-side. For the version that also prompts the user to fix a blocked verdict, use Poe.requestPoeBotAccess() below.
Poe.requestPoeBotAccess(input?)
Added 2026-07-22 — upgrade your SDK if your pin predates this. The message option was added later the same day.
Like getPoeBotAccess(), but when access is blocked the platform prompts the user to fix it — a host-owned modal with the reason-specific copy and inline Poe-account linking. Pasting an API key completes without leaving your activity (the promise resolves { canUse: true }, so you can continue the bot call in the same gesture); the "Sign in to Poe" path is a full-page redirect that returns to the current page, so re-run your preflight on mount. Dismissal resolves { canUse: false, reason }.
The prompt's body names your activity ("Word Duel needs a linked Poe account to use AI."). Pass input.message — one short sentence on why your activity wants bot access — and the prompt also shows it as a quote attributed to your activity:
javascript
async function askAi(model, prompt) {
const access = await Poe.requestPoeBotAccess({
message: "The AI opponent uses Poe to pick its moves.",
});
if (!access.canUse) return null; // user declined — keep the feature visible, disabled
// The model round itself runs in an action — see backend-api.md.
const { text } = await Poe.store.action.askModel({ model, prompt });
return text;
}message is plain text, at most 200 characters (newlines and control characters collapse to spaces); a longer message rejects. It appears only for the link/reconnect prompts — the "enable points" and "AI unavailable" variants stay fully platform-worded. The platform title and connect controls are never replaced.
Prefer this over hand-rolling a "link your Poe account" prompt. Use getPoeBotAccess() when you only want to silently adapt UI (e.g. render a hint) without ever popping the platform modal.
If you skip the preflight, the model round inside your action fails server-side with an unlinked-account error instead — which reads as a cryptic failure to the player. Preflight, then dispatch.
Poe.getBundleAssetUrl(path)
Get a URL for a static file from the app's bundle. Returns a blob URL that works both online and offline — the asset is fetched via the parent frame's cache, so it's available even when there's no network connection.
This is how apps reference uploaded assets (images, JSON data, additional JS modules, etc.) in a way that works inside the sandboxed iframe environment.
javascript
// Load an image from the bundle
const url = await Poe.getBundleAssetUrl("assets/hero.png");
document.querySelector("img").src = url;
// Fetch a JSON data file from the bundle
const url = await Poe.getBundleAssetUrl("data/levels.json");
const levels = await fetch(url).then(r => r.json());
// Load a JS file from the bundle
const url = await Poe.getBundleAssetUrl("my-module.js");
const code = await fetch(url).then(r => r.text());Path formats: Bare paths (assets/hero.png), leading slash (/assets/hero.png), and relative paths (./assets/hero.png) all work.
Caching: Repeated calls for the same path return the same blob URL without refetching.
Poe.tiles.list()
List published apps with cursor pagination. Returns { tiles, nextCursor? }, where tiles is an array of Tile objects with id, handle, creator_id, creator_handle, created_at, and updated_at.
javascript
const page1 = await Poe.tiles.list({ limit: 20 });
console.log(page1.tiles.map(a => a.handle));
if (page1.nextCursor) {
const page2 = await Poe.tiles.list({ cursor: page1.nextCursor, limit: 20 });
console.log(page2.tiles.map(a => a.handle));
}Use this to discover available apps or build an app directory UI.
Parameters:
limit— max hits, 1..200 (defaults to 20 server-side)cursor— opaque cursor from the previous pagecreatorHandle— restrict results to a single creator
Poe.tiles.get({ typeId })
Fetch one published app by typeId. Use this when you already have a persisted app reference and need display metadata; it avoids loading the full catalog.
javascript
const app = await Poe.tiles.get({ typeId: "todo-list" });
console.log(app.handle);Parameters:
typeId— app type ID (Tile.id)
Caching: Each typeId is cached persistently in the host with a 5-minute stale time — cached activities stay readable offline.
Poe.tiles.getByHandle({ creatorHandle, tileHandle })
Resolve one published app by creator handle + app handle (detail-quality payload including long_description and media). Same caching and reactive affordances as Poe.tiles.get.
Reactive reads: Poe.query(ref)
Cacheable reads (Poe.tiles.get, Poe.tiles.getByHandle, Poe.tiles.list) also expose .query(input) and .key(input) for reactive UI. Build a ref, then subscribe: the cached value arrives immediately (even offline), followed by a fresh snapshot when the host revalidates.
javascript
const ref = Poe.tiles.get.query({ typeId: "todo-list" });
const unsubscribe = Poe.query(ref).subscribe((snapshot) => {
render(snapshot.data, {
isStale: snapshot.isStale,
isRevalidating: snapshot.isRevalidating,
error: snapshot.error,
});
});The controller also offers read() (one snapshot, never fetches), watch() (for await sugar over the same stream), and refetch() (force a network fetch). SolidJS apps can use createPoeQuery from poe-tiles-sdk/v1/solid instead of hand-wiring subscribe.
Per-call cache overrides ride the second argument of the imperative call: Poe.tiles.get({ typeId }, { cache: { behavior: "cache-only" } }). Behaviors: stale-while-revalidate (default), cache-first, network-only, cache-only (throws on a cache miss instead of fetching).
Poe.tiles.search({ query, limit? })
Full-text search the public app catalog by handle / creator handle. Returns full Tile records — same shape as Poe.tiles.list() — so you can render avatars and descriptions without a second round-trip.
javascript
const matches = await Poe.tiles.search({ query: "chess", limit: 10 });
console.log(matches.map((a) => a.handle));Parameters:
query— search string (1..500 chars after trim)limit— max hits, 1..50 (defaults to 20 server-side)
Caching: Each (query, limit) pair is cached in the host for 5 minutes, so debounced retypes of the same query are served from memory.
Poe.tiles.preload({ typeId, instanceId? })
Preload an app's bundle so that a subsequent <poe-tile type-id="..."> loads instantly. The top document fetches all bundle files and caches the self-contained HTML template in IndexedDB.
javascript
// Preload an app you know the user is likely to open
await Poe.tiles.preload({ typeId: "my-game" });
// Preload with an instance ID (reserved for future use)
await Poe.tiles.preload({ typeId: "my-chat", instanceId: "room-42" });Poe.tiles.syncStatus({ typeId, instanceId })
Creation-lifecycle sync status of one activity instance as known on this device. Pure local read — never a network request — so it works offline and for instances that are not currently rendered.
Returns { status } with one of three values (monotonic per device):
"unknown"— no local record of the instance on this device. It may still exist server-side; a local client cannot know."local-only"— created locally (e.g. offline viaPoe.tiles.prepareNewInstancesor an optimistic first mutation) but the server has not yet confirmed the instance exists. This is normal offline operation that reconciles automatically on reconnect — never render it as an error. A subtle "waiting to sync" hint is appropriate at most."server-confirmed"— the server has confirmed the instance (at least one server-verified commit landed on this device).
Pending mutations after creation do not change the status — this API tracks creation lifecycle only, not "are all my edits flushed" (use store.getPendingMutations() / store.onPendingMutationsChanged for that).
javascript
const { status } = await Poe.tiles.syncStatus({
typeId: "my-game",
instanceId: "match-42",
});Subscribe to changes with Poe.query (same reactive contract as Poe.tiles.get.query):
javascript
const ref = Poe.tiles.syncStatus.query({ typeId: "my-game", instanceId: "match-42" });
const unsubscribe = Poe.query(ref).subscribe((snapshot) => {
if (snapshot.data) updateSyncBadge(snapshot.data.status);
});
// later: unsubscribe();Snapshots stream as the status changes; consecutive snapshots may repeat a status, so compare snapshot.data.status before reacting. The direct call always recomputes from local storage, so it is never stale.
Poe.open({ typeId, instanceId, openProps?, anchorSortKey?, placement?, viewWidth? })
Navigate the root app to a different app. This is how a sub-app requests the platform to switch to another app at the top level (as opposed to embedding it inline with <poe-tile>).
To open a store that has never existed before, first declare its genesis with Poe.tiles.prepareNewInstances and await it — there is no isNew parameter on Poe.open.
javascript
// Open another app, passing data via openProps
await Poe.open({
typeId: "my-game",
instanceId: "lobby-123",
openProps: { inviteCode: "abc123" },
});
// Open a large synced-store app around a known sortKey.
await Poe.open({
typeId: "chat",
instanceId: "room-42",
anchorSortKey: "msg/042",
openProps: {
openedSearchResult: {
itemKey: "message-042",
tableName: "messages",
sortKey: "msg/042",
},
},
});
// Open the target alongside the caller in a split layout on desktop. On
// mobile, this falls through to a normal forward navigation, so the same
// call works on every form factor without branching in app code.
await Poe.open({
typeId: "my-canvas",
instanceId: "canvas-for-chat-42",
placement: "splitView",
viewWidth: "260px", // caller's pane width; opened app fills the remainder
});The opened app reads the data via Poe.getOpenProps(). By default Poe.open() replaces the current view (the root app navigates to the target). Pass placement: "splitView" to ask the root to render the target alongside the caller; on roots that do not support split view, or on narrow viewports where there is no room for two panes, the hint is ignored and the call behaves like a normal navigation.
| Parameter | Type | Required | Description |
|---|---|---|---|
typeId | string | Yes | The app type ID to open |
instanceId | string | Yes | Instance ID for the app's store |
openProps | JSONValue | No | JSON-serializable data passed to the opened app (readable via Poe.getOpenProps()) |
anchorSortKey | string | No | Sort key used by outward pull windows in the opened app. Use it with app-specific openProps when you need both store-level anchoring and UI-level context/highlighting. |
placement | "current" | "splitView" | No | "current" (default) replaces the root view. "splitView" opens the target alongside the caller on roots that support a split layout (e.g. the manager's two-column layout on desktop and tablets); phones in any orientation and other narrow / unsupported form factors ignore the hint and behave like "current". |
splitViewFallback | "navigate" | "none" | No | What happens when a "splitView" placement cannot be honored (narrow viewport, nothing in the main pane to sit beside, a user-pinned side pane). "navigate" (default) falls back to a plain forward navigation — right for a user gesture, which should still land somewhere. "none" drops the open instead — the only safe choice for an open no user gesture caused (e.g. auto-docking a companion view when new data arrives), which must never yank the user off the surface they are looking at. The host also remembers when the user closes a "none"-opened pane and drops later automatic opens from the same caller until they deliberately reopen it or revisit the surface. Only meaningful with placement: "splitView". |
paneWidth | string | No | Width of the OPENED pane for automatic (splitViewFallback: "none") opens, which dock as a fixed-width sidecar rather than a flex split. px lengths only (e.g. "840px" to fit two phone-width frames); other values fall back to the host's default sidecar width, and the host clamps against the viewport. Ignored for gesture splitView opens — size those with viewWidth. |
simulate | boolean | No | With placement: "splitView" + splitViewFallback: "none": ask the host to dock a MULTIPLAYER SIMULATION of typeId (side-by-side simulated players, created or reused by the host) instead of mounting instanceId — the useful companion for an activity that cannot be played alone. Creator-only: the host's server refuses simulations of tiles the caller did not author. |
viewWidth | string | No | CSS length in px or % controlling the caller's pane in the split layout (e.g. "260px", "30%"). Other units (rem, em, vw, calc(...), etc.) are rejected to keep the host-side parser tight; invalid values silently fall back to the default split. The opened app's pane takes the remaining space. Only meaningful with placement: "splitView". Useful when the caller is a fixed-width picker/sidebar and wants the opened app to take the rest of the screen. |
room | tagged union | No | Flat-room mode for the opened app. One of: { kind: "self" } (opened app owns its own roster, standalone); { kind: "inherit" } (opened app joins the caller's room — DEFAULT when omitted); { kind: "explicit", storeTypeId, instanceId } (opened app joins an explicit room). See <poe-tile> Attributes for the equivalent HTML form. |
Inside an iframe, the app reads its actual rendered size with window.innerWidth / window.innerHeight (and ResizeObserver for changes) — the viewWidth value is a hint for the host layout, not something the opened app needs to read directly.
Poe.tiles.prepareNewInstances({ instances })
Declare $bootstrapStore genesis for one or more freshly-minted stores. Await it BEFORE rendering the store (<poe-tile>) or Poe.open-ing it. It only writes to durable per-instance client metadata (no network round-trip); the genesis actually runs when one of the stores is opened.
instances is an array of BootstrapStoreSpec entries for the whole gesture (the target plus any room/helper siblings). Each spec names typeId, instanceId, room (self, rootGroup, or memberOf, optional parent), an optimistic PoeUserInfo[] roster, and optional roomMemberInstances.
Opening ANY one activity in the group creates ALL of them. The opened store's session carries the persisted specs; the server resolves + validates the whole group's rosters and fans out $bootstrapStore genesis to every sibling (self-healing until all are pinned). You only need to open one.
Creating a new room with seeded membership
Use prepareNewInstances to create a new activity in a NEW room whose membership is seeded from specs you supply — the "rematch" pattern. Mint a new instanceId yourself, declare the genesis, then open the activity with room: { kind: "self" } when it owns its own room. Build a BootstrapStoreSpec from your own $users / $userInfo tables (available offline):
javascript
// Rematch: same players, fresh game, fresh room.
const roster = await Poe.store.query(async (tx) => {
const members = await tx.table("$users").scan().values().toArray();
const userInfo = new Map(
(await tx.table("$userInfo").scan().values().toArray()).map((info) => [
info.userId,
info,
]),
);
return members
// Drop removed members, and drop agent members (`agent_*`): the server
// resolves every seeded id through the central user directory, which
// agents are not in, so a listed agent is dropped from the new room. Re-add
// agents through the normal flow once the rematch opens.
.filter((m) => m.removedAt === undefined && !m.userId.startsWith("agent_"))
.map((m) => userInfo.get(m.userId))
.filter((info) => info !== undefined);
});
// Use the portable `generateUUID()` helper, not `crypto.randomUUID()` — the
// latter is unavailable on non-secure LAN / dev origins (common for local and
// mobile testing) and would throw before `Poe.open()` runs.
const newInstanceId = generateUUID();
const selfRef = { typeId: MY_TYPE_ID, instanceId: newInstanceId };
// 1. Declare genesis (writes durable client metadata only).
await Poe.tiles.prepareNewInstances({
instances: [
{
...selfRef,
room: { type: "self" },
users: roster,
roomMemberInstances: [selfRef],
},
],
});
// 2. Open the tile — this drives the genesis for the whole group.
await Poe.open({
typeId: MY_TYPE_ID,
instanceId: newInstanceId,
room: { kind: "self" },
openProps: { rematch: { players: roster.map((user) => user.userId) } },
});Semantics and rules:
- Every fresh store opened by the gesture needs a matching spec in
instances. If you create a separate chat/room store plus an app store, include both: the room spec usually usesroom: { type: "self" }, and the app spec usesroom: { type: "memberOf", room: roomRef }. - The caller must be included in each spec's
users, and every listed user must already be an active member of the caller's room or the parent room. This is a same-membership primitive, not an invite primitive — to add NEW people afterward, usePoe.room.pickMembers(). - Validation happens server-side when the request arrives. The server re-resolves profile data from the canonical user source, validates membership, and drops invalid seed entries into the rejected-bootstrap table instead of trusting client-supplied display data.
- Works offline: the seed survives reloads and long offline sessions. The kernel stores pending bootstrap specs in durable local metadata and replays creator context for the browser that created the instance until the server has applied the bootstrap.
- Discovery is app-level. Seeding gives users access; it does not put the new room in their sidebar. Announce it from a mutator (e.g.
notifyActivity/setTurn) after opening, and pass the roster's display data viaopenPropsso your UI can render players before the first sync.
Auto-replace semantics for placement: "splitView"
The split-view (right) pane has an owner. A subsequent placement: "splitView" call replaces it only when:
- the pane is empty, or
- the current pane was opened by a previous
placement: "splitView"call (center-owned).
A pane the user opened manually from the host's UI (e.g. a "pin to side" menu) is treated as user-pinned and is preserved — the new call falls back to a plain forward navigation in the caller's pane and the user-pinned side is left alone. A plain Poe.open() (default placement: "current") likewise leaves a user-pinned side pane intact.
Poe.showTileLauncher()
Ask the host manager to render the standard activity launcher. The host owns catalog browsing/search UI, mints the selected activity instance and child room ids, dispatches recieveTileStarted on the calling store, then opens the selected activity. Cancel resolves null.
typescript
const result = await Poe.showTileLauncher();
if (result) {
console.log("Started", result.tileTypeId, result.tileInstanceId);
}The calling app must call Poe.setupStore() before using this API — the call resolves your store to identify the caller, and throws without one.
Implement a store mutator named recieveTileStarted (spelling intentional) to persist the launch in your app:
typescript
type TileLauncherStartedInput = {
itemKey: string;
tileTypeId: string;
tileInstanceId: string;
room: { storeTypeId: string; instanceId: string };
parentRoom?: { storeTypeId: string; instanceId: string };
timestamp: number;
};For chat-style launchers, use room as the newly-created child room and validate parentRoom against the current store before writing any launch rows.
Poe.users.openProfile({ userId, username? })
Ask the host to open a user's profile UI, for example from "Forwarded from Alice" attribution in a chat message.
javascript
await Poe.users.openProfile({
userId: "u123",
username: "alice",
});| Parameter | Type | Required | Description |
|---|---|---|---|
userId | string | Yes | Stable user ID for the profile to open |
username | string | No | Optional, tolerated hint only — the host resolves the profile from userId. The call never rejects on a missing username. Whether another user's profile can be opened is gated by the host's viewOtherProfiles capability (check Poe.capabilities before offering the affordance); the host drops an open it can't route. |
For an account you know only by its public handle (no user id — e.g. a model lab from the model catalog's owner.handle), pass { handle } instead; the host navigates to its handle-resolving profile route:
javascript
await Poe.users.openProfile({ handle: "anthropic" });Prefer the userId form whenever you hold an id (roster rows, $userInfo): handles are renameable, and the host resolves an id to the account's current handle at open time.
This is a UI navigation request only. It does not grant access to private profile data or bypass host-side permissions.
Poe.room.openInvitePicker()
Ask the host to open its Add members invite surface for the calling app instance. Takes no parameters — the host resolves the caller's identity from the trusted RPC context (the app cannot forge it). The host resolves the caller's canonical room, lets the user add contacts to that room, and keeps share/copy invite links targeted at the calling activity instance.
javascript
await Poe.room.openInvitePicker();The call resolves after the host accepts the request to show the surface; it does not return selected contacts, added members, or copied-link status. Calling from the root app is rejected — the root app owns the invite UI directly and does not need an RPC hop.
Poe.room.openChat()
Ask the host to reveal the chat for the calling activity's containing room. The method takes no parameters: the host derives the calling activity from trusted RPC context, resolves its canonical room, and opens chat in the layout-native surface (an accessory pane on wide manager layouts or a sheet on compact layouts). If that room chat is already visible, the request is a no-op that preserves its mounted state and scroll position. Calls from an activity that is not inside a chat room are ignored.
javascript
await Poe.room.openChat();Calling from the root app is rejected because the root already owns the chat surface.
Poe.room.pickMembers()
Open the host's room member picker and return the selected room members. Existing room members are just selected. Child rooms show active parent-room users in a separate People in parent room section as existing room members unless the child has a live local $users row for that user (someone who left the child room stays pickable — the platform re-admits them on access). Contacts are shown by default; pass addFromContacts: false to restrict the picker to existing room members. Selecting a contact adds them to the room before returning them. If the calling app is in a dm-* room, selecting contacts moves the app to a 1:1 DM or a new group room as needed instead of adding people to the frozen DM membership.
typescript
const result = await Poe.room.pickMembers({
title: "Choose player",
selection: { max: 1 },
// `excludeUserIds` hides rows entirely — e.g. drop the current user, since a
// "Sit here" affordance already covers seating yourself.
excludeUserIds: [currentUserId].filter(Boolean),
// Already-seated players stay visible but render as "Playing" and are not
// selectable. Pass them as `playingUserIds`, NOT `excludeUserIds` — excluded
// rows are filtered out before the "Playing" marker is applied, so seats put
// in `excludeUserIds` would be hidden instead of shown as taken.
playingUserIds: [game.whiteUserId, game.blackUserId].filter(Boolean),
});
const user = result?.users[0];
if (user) {
await store.mutate.assignSeat({
seatId: "black",
userId: user.userId,
notify: "default",
now: Date.now(),
});
}Use excludeUserIds to hide users the app does not want selectable. Use playingUserIds to keep a room member visible but mark them as already Playing; those rows are not selectable. Contacts and the share/copy invite-link footer are both shown by default. Pass addFromContacts: false to restrict selection to existing room members, or shareLink: false to hide the link footer.
The result is null when the user cancels. Otherwise it includes the resolved room ref plus user snapshots:
typescript
type RoomPickMembersResult = {
room: { storeTypeId: string; instanceId: string };
users: Array<{
userId: string;
username: string;
displayName: string;
profilePicture: string;
source: "existingRoomMember" | "addedContact";
addedToRoom: boolean;
}>;
};Treat the result as UI input, not authority. Mutators that write app state for a picked user should call assertRoomMember(ctx, { userId }) on the server-authoritative pass before committing the assignment.
Poe.room.tileEnd(input)
Ask the host to show the standard end-of-activity UI for a persisted leaderboard. The host derives the calling activity instance from trusted RPC context, subscribes to getLeaderboard(ctx, { leaderboardId }) in that activity's synced store, and hydrates profiles. Ranked entries render first; every other active human room member is appended without a score in room join order, while agents are excluded. Optimistic mutator writes appear immediately and confirmed updates remain live while the sheet is open.
Because the end sheet opens immediately, a game must not call tileEnd() in the same moment it detects a terminal result. First keep the playfield visible long enough to finish the decisive animation and explain why the player won or lost, then leave a readable beat before calling this API. See game UX best practices for the terminal-reveal sequence.
Persist scores and presentation from an activity-defined mutator with setLeaderboard(...) or setLeaderboardScore(...); tileEnd() carries only the board id plus optional actions/round behavior. Use the same leaderboardId for writes, getLeaderboard(...) reads, and this call. If your page shows a persistent leaderboard without opening tile-end, call Poe.room.setShareLeaderboardId() instead.
Leaderboard scores are numeric: setLeaderboard accepts players: Record<string, number> or everyone: number, never "WON", "LOST", or "TIE". Choose your activity's numeric scoring rules explicitly; the SDK does not convert outcomes into scores. To show an outcome label, use setLeaderboardScore with a numeric score and a separate displayScore (for example, { userId, score: 1, displayScore: "Won" }). For a current-round result that can decrease, use mode: "replace"; the default "merge" keeps each user's best score.
typescript
const result = await Poe.room.tileEnd({ leaderboardId: "default" });
if (result.playAgain) {
await store.mutate.startRound({});
}typescript
await store.mutate.finishRun({
score: finalScore,
});
await Poe.room.tileEnd({
leaderboardId: "daily",
});typescript
type RoomTileEndInput = {
leaderboardId: string;
round?: string;
dismissable?: boolean;
actions?: {
nudge?: boolean;
playAgain?: boolean;
review?: { label: string };
};
};
type RoomTileEndResult = {
// `true` only when `outcome === "playAgain"` (kept for back-compat).
playAgain: boolean;
// How the end UI settled:
// - "playAgain" — user tapped Play Again
// - "review" — user tapped your `actions.review` button (see below)
// - "closed" — user dismissed otherwise (picked another tile, navigated away)
// - "dismissed" — superseded programmatically via Poe.room.dismissTileEnd()
outcome: "playAgain" | "review" | "closed" | "dismissed";
};dismissable defaults to true: the overlay shows an on-frame close button (and Escape dismisses) so the player can return to the activity. Pass dismissable: false for a terminal tile-end where nothing behind the overlay is actionable — e.g. a score-chase run that is already over — so a stray close can't strand the player on a dead post-run screen. The overlay's own actions (Play Again, Try Next) and the host chrome's back button remain the exits; programmatic closes and dismissTileEnd() are unaffected.
bestScore, label, unit, and optional per-entry displayScore / markdown come from the persisted leaderboard row. Labels are limited to 40 characters; each unit form is limited to 20 characters. When provided, the label and both unit forms must contain a non-whitespace character; blank or whitespace-only values are rejected at the tile-end API boundary.
Each entry may also carry a markdown string (≤ 2048 chars), passed to setLeaderboardScore(ctx, { …, markdown }). The host renders it — sanitized (raw HTML stripped, then run through DOMPurify, images dropped), with single newlines as line breaks — as a detail column to the right of that player's name/score, so tall content (e.g. a Wordle-style attempt grid) uses horizontal space instead of stacking below and inflating the row. It is display-only and never affects ranking. Keep it compact and presentational (it is not a document surface), and never put secrets or user-identifying URLs in it (it is shown to every room member).
When a row has markdown, its displayScore moves under the player's name (on the left). displayScore may be multi-line (rendered as stacked lines), so you can show a primary score plus a secondary line — e.g. a guess count over a streak:
typescript
await setLeaderboardScore(ctx, {
leaderboardId: "daily",
userId: ctx.userId,
score: guessCount,
bestScore: "lowest",
displayScore: `${guessCount}/6\n🔥 Streak ${streak}`, // stacked under the name
markdown: "🟩⬜⬜🟨⬜\n🟩🟩⬜⬜⬜\n🟩🟩🟩🟩🟩", // grid, to the right
});Where a board is stored: sortKey
Board rows are stored always-loaded by default, which means every board your app writes is delivered on every pull. That is what makes getLeaderboard(ctx, { leaderboardId }) safe to call from any surface at any time, so most apps should leave it alone.
Pass a sortKey only when you deliberately want boards paged in — a store with many boards, or boards large enough that shipping them all on every pull is the wrong trade — and add a pullWindows entry to your schema that covers the keys you pick:
typescript
await setLeaderboardScore(ctx, {
leaderboardId: `season-${seasonId}`,
sortKey: `boards/${seasonId}`, // paged in by a "boards" pull window
userId: ctx.userId,
score,
});Two rules if you do. Keep the sortKey stable per board — it is the row's storage position, so changing it moves the row and every write would re-key it. And make sure any board a surface can open is inside a window that reaches it: a board outside the delivered window reads as an empty board, not as "still loading", and synced-store does not backfill a row that fell past the window's byte budget on a later pull. Only windowed reads are sortKey-sensitive this way — getLeaderboard(...) from a mounted store. Share links are not: the invite page reads the board by exact (table, id) identity, so a link minted with Poe.room.setShareLeaderboardId resolves a paged board fine. If you are not sure you need this, you do not — omit sortKey.
actions.playAgain: false hides the Play again button for activities with no meaningful replay (a daily puzzle, a one-shot). actions.review adds a activity-owned review button (label required, ≤ 40 chars — e.g. "Watch replay", "View board"). When the user taps it the overlay closes and the promise resolves with outcome: "review"; show your own review surface (the final board, a step-through replay) with an in-activity way back, and call Poe.room.tileEnd() again with the same leaderboard id when the user is done so they return to the end screen:
typescript
const result = await Poe.room.tileEnd({
leaderboardId: "replay-scores",
actions: { review: { label: "Watch replay" } },
});
if (result.outcome === "playAgain") startNewRound();
else if (result.outcome === "review") openReplay(); // re-call tileEnd() on closeactions.nudge: true shows a Nudge control for each active human member who has no score on this board. A member can nudge the same person once every 24 hours per existing board. Before the first score creates a board, all not-yet-created boards share one 24-hour limit for each sender and target. This prevents fabricated board IDs from bypassing the cooldown. Only enable this for a board that is still meaningfully playable: the platform intentionally does not know whether an app-owned board is current, archived, or evergreen. The option defaults to false.
The optional round is an app-owned id for this terminal round (e.g. the run's start timestamp or a round id from your synced store). Pass it so a later Poe.room.dismissTileEnd({ supersedesRound }) can target exactly this overlay. Keep it short — round (and supersedesRound) are rejected at ingestion if they exceed 256 characters, so don't pass a serialized game state as the id.
When the user chooses another activity from the end UI, the host creates the rematch on the device after local synced-store hydration has supplied the exact roster and room refs: it reads cached/external $$system.room, $users, and $userInfo, mints a fresh room and instance, seeds the new room with the human players from the round, then opens the selected activity, unmounts the current activity, and resolves { playAgain: false }. No server round-trip is needed for the end overlay or rematch creation itself, so the seeded room is durable across reloads and reconnects when the device is offline after creation. Agent players are not carried into the rematch room — a new round adds its own agents. If the current activity is already in a child room, the new room is a sibling; otherwise the current room becomes the parent. Choosing Play Again resolves { playAgain: true } without navigating. For score payloads, the host displays scored active humans' names and profile pictures ranked by the leaderboard settings, followed by unscored active humans as blank rows in room join order. { everyone: score } is shown as a team score for all active players.
Poe.room.setShareLeaderboardId(leaderboardId)
Tell the host which activity-owned leaderboard should be used when the user copies or shares the current page's invite link. The host derives the calling activity instance from trusted RPC context, so activities pass only the leaderboard id. The manager applies the setting only while that activity instance is the visible current page; route changes clear it.
Use this for pages that show a persistent leaderboard but do not open the tile-end overlay:
typescript
await store.mutate.submitScore({ score });
await Poe.room.setShareLeaderboardId("daily");typescript
Poe.room.setShareLeaderboardId(leaderboardId: string): Promise<void>;leaderboardId is app-owned, bounded, and should match the id you use with setLeaderboard(ctx, ...), setLeaderboardScore(ctx, ...), or getLeaderboard(ctx, ...). Use "default" for the default board.
Poe.room.dismissTileEnd(opts?)
Programmatically dismiss this client's active tile-end overlay (instead of waiting for the user to tap). The calling activity instance is derived from trusted RPC context; you can only dismiss your own instance's overlay. The pending Poe.room.tileEnd() promise on this client resolves with outcome: "dismissed".
This is the building block for a shared-instance team game where everyone plays one persistent instance: when one player advances the shared round, every other client clears its now-stale overlay on its own. It is per-client — the "everyone's screen clears" effect comes from each client calling this in reaction to shared synced-store state, not from a broadcast.
typescript
type RoomDismissTileEndInput = {
// Dismiss ONLY if the active overlay's `round` equals this (so a delayed
// call can't close a newer overlay). Omit to dismiss the active overlay
// unconditionally (simple single-round apps).
supersedesRound?: string;
};
Poe.room.dismissTileEnd(opts?: RoomDismissTileEndInput): Promise<void>;Resolves once the host handles the dismiss (no-op if no matching overlay is showing); rejects if no host supports dismissal or if called from the root app.
Typical shared-game pattern — tag the round on tileEnd, then dismiss when your synced state shows a new round is live:
typescript
// Install the dismiss watcher BEFORE awaiting tileEnd: the await blocks until
// this client's overlay settles, so a watcher registered AFTER it resolves is
// too late — there is no overlay left to dismiss. When the shared round advances
// (a peer started the next round), dismiss our now-stale overlay; tear the
// watcher down in `finally` once tileEnd has settled.
const unsubscribe = store.subscribe(
(tx) => tx.table("game").get("game"),
(game) => {
if (game?.round === finishedRoundId) return;
void Poe.room.dismissTileEnd({ supersedesRound: finishedRoundId });
},
);
try {
// Every client reports the team result tagged with the finished round id.
const result = await Poe.room.tileEnd({ leaderboardId: "team", round: finishedRoundId });
if (result.outcome === "playAgain") restartIfStillCurrent(finishedRoundId); // generation-guarded
// outcome "dismissed" / "closed" → another client drove it, or the user left; do nothing.
} finally {
unsubscribe();
}Make the restart generation-guarded / idempotent (restart only if the just-finished round is still current) — when several players tap Play Again at once, the platform does not serialize the restarts.
Poe.agents.create({ agentId, name, model, tools?, room? })
Create a new agent owned by the calling user. You mint the agent's agentId (an agent_<uuid>) and pass it as the idempotency key; the trusted host runs the whole creation server-side in one round-trip: it claims the per-creator-unique name in the caller's agents registry, initializes the agent's store with the given model and tools, and — when you pass a room — adds the new agent to that room in the same call.
typescript
// Use the portable `generateUUID()` helper, not `crypto.randomUUID()` — the
// latter is unavailable on non-secure LAN / dev origins (common for local and
// mobile testing).
const agentId = `agent_${generateUUID()}`;
const result = await Poe.agents.create({
agentId, // client-minted idempotency key — reuse it across retries
name: "Code Reviewer",
model: "claude-sonnet-4-6",
tools: ["read_file"], // restricts the agent to these system tools (order ignored)
room: { typeId: "chat", instanceId: roomId }, // optional: create + add in one call
});agentId, name, and model are required (name/model are trimmed by the host); name is unique among the calling user's agents (case-insensitive). tools is the system-tool allow-list — an explicit grant list (deny-by-default): the agent may use exactly the tools named, and omitting it (or passing []) gives the agent no system tools (the default for auto-created agents; grant tools later via a config edit). Tool order doesn't matter — it's treated as a set. room is optional; when supplied you must be a live member of that room (direct-message rooms are refused), and the room is validated before the agent is created so a bad room never leaves an orphaned agent.
The agentId is the idempotency key. Mint it once per logical create intent and reuse it across retries (don't generate a fresh id on retry, or you lose idempotency). Calling create again with the same agentId returns your existing agent and ignores the rest of the payload — reused is true when the existing agent was returned and false when a fresh one was minted; the optional room add still applies on reuse. So a retry (or a double-submit) safely resolves to the same agent. Config changes go through the edit flow, never create. The promise rejects only on a real conflict — a new agentId whose name is already taken by a different agent — or when the room is invalid or initialization fails.
The returned agentId echoes the id you minted (an agent_… id usable as a room member id), and addedToRoom reports whether the optional room add succeeded. Agents join rooms through the membership APIs (below); apps never mount or read the agent's own store.
Poe.agents.delete({ agentId })
Delete one of your agents outright. The trusted host runs the whole cascade server-side: it removes the agent from every room it belongs to, releases its name back to your registry, purges its conversation history, and finally destroys the agent's own store (its config and memory). Creator-only.
This is irreversible. There is no undo and no tombstone to restore from — confirm with the user before calling it.
typescript
const result = await Poe.agents.delete({ agentId });
if (result.failedRooms.length > 0 || result.cleanupErrors.length > 0) {
// Not a failure — the delete is INCOMPLETE. Call it again to finish; every
// step is idempotent, so a retry re-attempts only what is left.
showRetryPrompt(result);
}The promise resolving does not mean the agent is gone. The whole cascade is idempotent and retryable, so a partial run reports what it finished instead of throwing:
roomsRemoved— rooms the agent's membership was removed from this call.failedRooms— rooms whose removal failed. Non-empty means the agent is mid-delete: its name is not released and it can no longer be used, but it is not deleted either.registryReleased— whether the name claim was tombstoned.agentStoreDestroyed— whether the agent's own store is gone. This is the last step and only runs when everything before it succeeded.cleanupErrors— one message per step that did not complete.
Check those fields and re-call delete with the same agentId to finish an incomplete delete. The promise rejects only when the request itself failed — you are not the agent's creator, or the agentId is malformed. Deleting an already-deleted agent is a no-op.
Poe.agents.addToRoom({ agentId, typeId, instanceId }) / Poe.agents.removeFromRoom({ agentId, typeId, instanceId })
Add or remove an existing agent as a member of a room. Use addToRoom to bring one of your agents into a room you're a live member of; use removeFromRoom to take it out. Both resolve once the membership change is committed.
typescript
await Poe.agents.addToRoom({ agentId, typeId: "chat", instanceId: roomId });
await Poe.agents.removeFromRoom({ agentId, typeId: "chat", instanceId: roomId });addToRoom requires that you are the agent's creator and a live member of the room; removeFromRoom is creator-only.
Poe.agents.listTools()
List the first-party agent system-tool catalog — the { id, displayName, description } metadata for every built-in system tool an agent can be granted. Use it to render tool ids and display names in your own agent-building UI without hardcoding the list.
typescript
const tools = await Poe.agents.listTools();
// e.g. [{ id: "calculator", displayName: "Calculator", description: "Evaluates arithmetic expressions." }]
// The ids are exactly what `Poe.agents.create({ tools })` accepts:
await Poe.agents.create({
name: "Mathbot",
model: "claude-sonnet-4-6",
tools: tools.map((tool) => tool.id),
});Read-only public metadata — it takes no arguments, requires no permissions, and returns the same catalog for everyone.
Poe.agents.listTemplates()
List the first-party agent-template catalog — the preset agent configurations (name, model, and tool grant) that pre-fill the agent-create form. Use it to render a template picker in your own agent-building UI without hardcoding the presets.
typescript
const templates = await Poe.agents.listTemplates();
// e.g. [{
// id: "math-helper",
// displayName: "Math Helper",
// description: "Works through arithmetic step by step using a calculator tool.",
// iconEmoji: "🧮",
// config: { suggestedName: "Math Helper", model: "claude-sonnet-4-6", tools: ["calculator"] },
// }]
// A template's `config` is exactly what `Poe.agents.create` accepts — instantiation is a copy:
const template = templates.find((t) => t.id === "math-helper");
if (template) {
await Poe.agents.create({
name: template.config.suggestedName,
model: template.config.model,
// `config.tools` is `readonly string[]`; spread into a mutable copy for the create input.
tools: [...template.config.tools],
});
}Read-only public metadata — it takes no arguments, requires no permissions, and returns the same catalog for everyone. Picking a template only pre-fills the create form; there is no live template↔agent link after creation.
Poe.agents.listMine()
List your own live agents — the { agentId, name, model } metadata for every agent you have created. Use it to render a picker of the caller's agents, or to resolve one of your agents by name to its agentId so you can add it to a room with Poe.agents.addToRoom.
typescript
const mine = await Poe.agents.listMine();
// e.g. [{ agentId: "agent_ab12…", name: "Math Helper", model: "claude-sonnet-4-6" }]
// Resolve a name to an agentId (case-insensitive), then add it to a room:
const match = mine.find((a) => a.name.trim().toLowerCase() === "math helper");
if (match) {
await Poe.agents.addToRoom({ agentId: match.agentId, typeId: "chat", instanceId: roomId });
}Caller-scoped: it lists only your own agents (listing your own is not a permission escalation), so there is no way to enumerate or add another user's agents by a guessed name — always add by the opaque agentId.
Poe.track(event, properties?)
Send a fire-and-forget analytics event through the host-owned analytics pipeline.
javascript
Poe.track("tile_opened", { tileType: "chat" });
Poe.track("space_invite_sent", { channel: "share-link" });| Parameter | Type | Required | Description |
|---|---|---|---|
event | string | Yes | Event name matching /^[a-z0-9_$-]+$/i, up to 128 characters |
properties | object | No | JSON-serializable property bag, up to 100 top-level keys and 32 KB serialized |
Poe.track() returns immediately and does not report whether an event was forwarded. The SDK, host kernel, and first-party relay all validate the envelope; invalid events, reserved keys, PII-looking keys, oversized payloads, anonymous users, or disabled analytics are silently dropped.
Poe.openExternalUrl({ url })
Ask the host to open a web link. This is the only way an app can open a link outside itself — the iframe sandbox has no allow-popups, so window.open and target="_blank" are blocked before the host can see them.
javascript
await Poe.openExternalUrl({ url: "https://example.com/rules" });| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | Yes | An http(s) URL, up to 8192 characters |
The host decides how the link opens. A link back into the platform itself (same origin as the host and matching a real platform page — for example an invite link shared in chat) navigates in place, like any other in-app navigation, with no confirmation. Any other link shows the user a confirmation with the destination host before anything opens; the user can decline. When the user confirms, the link opens in a new tab on the web, or in the platform in-app browser inside the mobile apps. The returned promise resolves when the request is accepted (not when the user confirms or navigation happens) and rejects for invalid URLs (non-http(s) schemes, over-length).
Poe.openBugReport({ debugId })
Opens the host’s editable bug-report form with the debug ID and host-stamped tile/instance IDs. It does not submit feedback; the user reviews and sends the report. Keep original diagnostics in durable storage under that ID, and show product-owned error copy in the UI.
debugId must be 1–256 ASCII letters, digits, or _./:- characters. Use Poe.has("openBugReport") during rollout; on older hosts, tell the user to include the ID in Report a bug from the account menu. Calls reject for invalid input, a host that cannot open the form within 30 seconds, or a non-browser environment.
Poe.openSettings({ section? })
Ask the host to navigate to its Settings page. Fire-and-forget; the promise resolves once the request is accepted.
javascript
await Poe.openSettings({ section: "poe-account" });| Parameter | Type | Required | Description |
|---|---|---|---|
section | "poe-account" | No | Hint for which settings sub-section to focus. The host may ignore it. |
Pair this with Poe.getPoeBotAccess(): when the user can't use bots, send them to { section: "poe-account" } to connect / reconnect / enable Poe points.
<poe-tile> Custom Element
html
<poe-tile type-id="my-game" instance-id="lobby-123"></poe-tile>A custom HTML element that renders a child app inline. This is how one app embeds and renders another app inside itself.
The child app runs in a sandboxed iframe within the element's shadow DOM. The embedded app calls Poe.setupStore() normally — it doesn't know it's embedded.
To use <poe-tile>, register it in your entry file:
javascript
import { registerPoeTileElement } from "poe-tiles-sdk/v1/client.js";
registerPoeTileElement(environment);Attributes
| Attribute | Type | Required | Description |
|---|---|---|---|
type-id | string | Yes | The app type ID to embed (max 128 characters) |
instance-id | string | Yes | Full instance ID for the child app's store. Callers construct this themselves, typically as ${parentInstanceId}-${childHandle}. The same instance ID reconnects to the same store data. (max 256 characters) |
open-props | string (JSON) | No | JSON-serializable data to pass to the child app at open time (max 10 MB). The child reads it via Poe.getOpenProps(). |
room | "self" | "inherit" | "explicit" | No | Flat-room mode. "inherit" (default when omitted) — child joins the DOM-parent's room. "self" — child owns its own $users roster (standalone). "explicit" — child joins the room identified by paired room-type-id + room-instance-id attrs. |
room-type-id | string | When room="explicit" | Store type ID of the explicit room (max 5,000 characters). |
room-instance-id | string | When room="explicit" | Store instance ID of the explicit room (max 5,000 characters). |
opener-store-type-id | string | No | Logical parent identity (type ID half) when this <poe-tile> is mounted by the root app on behalf of a Poe.open call. Lets room="inherit" resolve against the opener's $$system:room instead of the DOM-parent's (manager). Honored only by the root app — untrusted iframes stamping this on their own embedded <poe-tile> have no effect. (max 5,000 characters) |
opener-store-instance-id | string | No | Pairs with opener-store-type-id. (max 5,000 characters) |
focus-on-mount | boolean attribute | No | Focus the child iframe as soon as it mounts (including remounts and reloads), so keyboard-driven content receives key events without the user first clicking inside it. Set it only on the primary activity in view. It never steals focus from a focused text entry or from another <poe-tile>; a host-hidden activity defers the focus until it is revealed. When parent chrome that took focus (an overlay, a results sheet) closes and control returns to the still-mounted activity, call the element's focusChild() method to hand keyboard focus back — same opt-in and never-steal guards as the mount pass. |
To mount a <poe-tile> with an instance-id that has never existed before, first call Poe.tiles.prepareNewInstances to declare its genesis, then render the element. There is no is-new attribute: the kernel reads the prepared spec from durable client metadata when the store mounts, treats it as a fresh creator (skips the IndexedDB probe and the server-clientOrdinal wait), and pins it via $bootstrapStore.
Visibility
document.visibilityState is not enough. The platform keeps your iframe mounted while it draws its own chrome over you — the room chat sheet opening on top of a running game, a modal, an app switcher. Nothing in the DOM changes: document.visibilityState stays "visible", your iframe keeps its size, and no focus event arrives. Every timer, animation frame, and scored countdown keeps running behind the cover unless you pause it.
isPoeTileForeground() is the answer to "is my app actually on screen". It is false when either the host covers you OR the browser page is hidden:
javascript
import {
isPoeTileForeground,
subscribePoeTileForegroundState,
} from "poe-tiles-sdk/v1/client.js";
if (isPoeTileForeground()) startCountdown();
const unsubscribe = subscribePoeTileForegroundState((foreground) => {
if (foreground) resumeCountdown();
else pauseCountdown();
});subscribePoeTileForegroundState fires on transitions only — read isPoeTileForeground() for the current value — and returns an unsubscribe function.
The listener also receives a detail object if you want to know why the app went dark. Most apps ignore it and just pause:
javascript
subscribePoeTileForegroundState((foreground, { reason }) => {
if (foreground) return resume(); // reason: "visible"
if (reason === "covered") return pauseButStayWarm();
teardown(); // reason: "backgrounded"
});"covered" is platform chrome drawn over your still-running iframe; "backgrounded" is the browser's own page visibility, which also throttles your timers and stops requestAnimationFrame. The listener does not re-fire when only the reason changes (covered, then also backgrounded) — the boolean is the contract.
Any timed, scored, or animated app should pause on this. A timer that keeps counting behind chat is a wrong score, not just wasted CPU.
Being covered does not change your app's standing with the platform: presence keeps ticking (other players still see you here) and your store keeps syncing. It only tells you nobody is looking.
isPoeTileHostVisible() / subscribePoeTileHostVisibility() are the narrower pair — host cover only, ignoring browser page visibility. Use them for "has the user actually seen this content" bookkeeping where a backgrounded tab is handled separately; prefer the foreground pair for anything you want to pause.
If your app itself hosts a child <poe-tile> and covers it with your own chrome, tell the child:
javascript
import {
setChildPoeTileHostCovered,
setChildPoeTileHostVisible,
} from "poe-tiles-sdk/v1/client.js";
// Your chrome is drawn OVER the child, but the user is still "in" it.
setChildPoeTileHostCovered(childPoeTileElement, true);
setChildPoeTileHostCovered(childPoeTileElement, false);
// The child is no longer the surface the user is on at all.
setChildPoeTileHostVisible(childPoeTileElement, false);
setChildPoeTileHostVisible(childPoeTileElement, true);Both reach the child as the same isPoeTileForeground() === false. Prefer setChildPoeTileHostCovered when the user is still in that child: hostVisible: false additionally tells the platform the child is no longer attended, which pauses its presence heartbeat and foreground store pulls.
Foreground-only work — animation loops, physics, polling, audio, WebGL rendering — should gate its scheduling on the same signal:
javascript
let foreground = isPoeTileForeground();
let raf = 0;
const unsubscribe = subscribePoeTileForegroundState((next) => {
foreground = next;
if (foreground) scheduleRenderLoop();
});
function scheduleRenderLoop() {
if (raf !== 0 || !foreground) return;
raf = requestAnimationFrame(renderLoop);
}
function renderLoop() {
raf = 0;
if (!foreground) return;
// Do foreground-only work.
scheduleRenderLoop();
}
scheduleRenderLoop();Usage
html
<!-- Vanilla HTML -->
<poe-tile type-id="chat" instance-id="parent-123-my-chat-1"></poe-tile>
<!-- With open props (HTML attribute) -->
<poe-tile type-id="chat" instance-id="parent-123-my-chat-1" open-props='{"theme":"dark"}'></poe-tile>jsx
// React — construct instance-id from parent's instanceId + child handle
<poe-tile
type-id={selectedApp.typeId}
instance-id={`${instanceId}-${selectedApp.id}`}
style={{ display: "block", flex: "1", minHeight: "0" }}
/>javascript
// Programmatic — set openProps via JS property (overrides attribute)
const el = document.createElement("poe-tile");
el.setAttribute("type-id", "chat");
el.setAttribute("instance-id", "parent-123-my-chat-1");
el.openProps = { theme: "dark", userId: "abc" };
document.body.appendChild(el);TypeScript JSX Support
To use <poe-tile> in TypeScript React projects, add a type declaration:
typescript
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"poe-tile": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
"type-id": string;
"instance-id": string;
"open-props"?: string;
},
HTMLElement
>;
}
}
}How It Works
- On mount, the element calls
apps.openChildviapostMessageto the top-level document (with optionalopenProps) - The host registers the instance, injects session config and openProps into the HTML template
- A blob URL is created from the HTML and loaded in a sandboxed iframe (
allow-scripts allow-forms) - The child app communicates with the platform via
window.top.postMessageusing a nonce for routing - On unmount, the iframe is removed and the blob URL is revoked
Poe.getOpenProps()
Read JSON data passed by the parent app when this app was opened via <poe-tile>. Returns null if no props were passed or if this is a top-level app.
javascript
const props = Poe.getOpenProps();
if (props) {
console.log(props.theme); // "dark"
}Open props are read-once at startup — they are baked into the HTML when the iframe is created and are not reactive. For reactive parent-child communication, use Synced Store.
Poe.consumeEntryContext()
Read the entry context for this mount — how the user got here, and the small creator-defined payload attached to the notification they tapped. Like getOpenProps(), it is available synchronously before your activity code runs.
typescript
type EntryContext =
| { source: "direct" }
| {
source: "push" | "banner" | "badge";
notification: {
context: JSONValue; // what a sender attached via notifyActivity's push.context
senderId?: string; // the responsible user (absent for system notifications)
sentAtMs: number; // when the notification was enqueued
};
};
const entry = Poe.consumeEntryContext();
if (entry.source !== "direct") {
// context is creator-defined JSON — narrow it defensively before use
const context = entry.notification.context;
if (
context !== null &&
typeof context === "object" &&
!Array.isArray(context) &&
context.kind === "challenge"
) {
// …opened from a challenge push — decide whether to show the overlay
}
}source—"push"(tapped an OS notification),"banner"(tapped the in-app foreground notification banner),"badge"(tapped a badged in-app row / app-icon badge), or"direct"(a normal open, with no notification provenance).pushandbannerresolve the exact notification tapped;badgeresolves the newest unconsumed notification for the activity. Branch onsource !== "direct"when you only care that the user arrived from a notification.notification.context— the exact JSON a sender passed tonotifyActivity'spush.context. This is a stale-able hint, not authoritative state: by the time the recipient opens the activity the challenge may already be beaten, the turn already played, the message already read. Always re-derive display state from your store and use the context only to decide what to surface.- Consumed at most once per notification-originated mount. The first call returns the notification and fires a host acknowledgement; later calls in the same mount — and every normal open — return
{ source: "direct" }. Read it once at startup and thread the result into your activity. - Route-mounted activity only. A DOM-nested
tiles.openChildchild always reads{ source: "direct" }; if a child needs the context, the parent forwards what it needs viaopenProps. - Per-device, at-least-once. Consumption is tracked per device (so opening on your phone doesn't consume it on your tablet), and a crash between read and acknowledgement re-delivers on the next mount — a rare duplicate overlay is acceptable, so keep the reaction idempotent.
When to attach context, and when to show nothing. Attach push.context when tapping the notification should land the user somewhere more specific than the activity's default screen — a challenge overlay, a game-over recap, first-turn framing, or a scroll to the referenced entity. Keep the payload tiny (an id plus a kind discriminator) and validate against your store on arrival: show the overlay only if the challenge still stands, scroll only if the message is in loaded history, otherwise fall through to a normal open. Do not attach context (or show any special UI) for routine, low-signal events — an ordinary turn on move 23, a daily nudge — where a plain open is exactly right; a mistimed overlay is worse than none.
Poe.parent
The parent store's identity, available for child apps opened via <poe-tile> or Poe.open(). Returns null for root apps (apps not opened as children of another app).
typescript
type ParentStoreInfo = {
storeTypeId: string;
instanceId: string;
};
Poe.parent; // { storeTypeId: "my-parent-app", instanceId: "room-42" } or nullUse this to pass the parent's store identity as input to mutators that dispatch external mutations to the parent store:
javascript
// In the child app's UI code (client-only):
await store.mutate.notifyParent({
parentTypeId: Poe.parent.storeTypeId,
parentInstanceId: Poe.parent.instanceId,
message: "task completed",
});
// In the mutator (runs on both client and server):
notifyParent: async (ctx, input) => {
ctx.mutateExternal({
storeTypeId: input.parentTypeId,
instanceId: input.parentInstanceId,
mutationName: "receiveChildNotification",
input: input.message,
});
},This pattern works because the parent identity flows as regular mutation input — the server doesn't need to know about parent/child app relationships.
Poe.topOrigin
Origin of the top (host) document — e.g. "https://poe.com" in production, "http://localhost:5105" in dev. Apps read it to build absolute URLs that resolve against the host instead of the sandboxed iframe.
typescript
Poe.topOrigin; // "https://poe.com" or undefinedJoiner activities run inside a sandboxed blob-URL iframe. Inside that iframe, window.location.origin is the string "null" (sandboxed) or an app-hosting subdomain — neither routes to the host's UI. The platform therefore injects the top document's origin into the iframe via <div id="poe-config" data-top-origin="...">, which PostMessageEnvironment reads and exposes here.
Returns undefined on older hosts that don't inject the attribute; apps should fall back to path-only URLs in that case so they degrade gracefully.
typescript
// Build a shareable invite URL.
const path = `/invites/${encodeURIComponent(code)}`;
const shareUrl = Poe.topOrigin ? `${Poe.topOrigin}${path}` : path;
await navigator.clipboard.writeText(shareUrl);For top-document apps (trusted apps not running in an iframe) that construct a PostMessageEnvironment manually, pass topOrigin: window.location.origin in the constructor options.
Poe.capabilities
Resolved host surface capabilities — a flag map describing which surfaces the host enables. Always a full object: any flag the host did not restrict (or the whole config when the host imposes no restrictions) reads as enabled, so Poe.capabilities.<flag> is safe to read without a null check.
typescript
Poe.capabilities; // { agents: true, ownChats: true, ... }Most apps do not need this — it exists for first-party host surfaces that hide their own affordances when the host runs in a restricted mode (for example a host that disables agent/AI affordances). The host bakes the set into the iframe via <div id="poe-config" data-capabilities="...">, which PostMessageEnvironment reads and createPoe resolves against the all-enabled default. It is UI defense-in-depth only — the server is always the authority on what an app may actually do.
Poe.haptics
Trigger cross-platform hardware haptic feedback. Fire-and-forget — calls return immediately and the device buzzes on platforms that have hardware support. Modeled on Apple's UIFeedbackGenerator taxonomy because it's the richest target the platform mapping has to satisfy.
typescript
// Discrete tactile feedback for a user action.
Poe.haptics.impact("light" | "soft" | "medium" | "rigid" | "heavy");
// Outcome feedback for a completed operation.
Poe.haptics.notification("success" | "warning" | "error");
// A small tap each time the selected value changes (slider tick, picker wheel).
Poe.haptics.selection();Safe to call from any context — no isPoeNativeBridgeAvailable() gate needed. Platforms with no haptic-capable path silently no-op.
Platform support
| Platform | What plays |
|---|---|
| iOS app | UIImpactFeedbackGenerator / UINotificationFeedbackGenerator / UISelectionFeedbackGenerator via a JS-bridge call. Best fidelity. |
| Android app and mobile web (same code path) | navigator.vibrate with a fixed duration per style. The Android app's WebView is Chromium and supports the Vibration API directly, so the feel is identical between in-app and mobile web on the same device. |
| iOS Safari 17.4+ (mobile web) | A single subtle tap, via the <input switch> label-click trick. All styles collapse to the same tick on this path — see "Limitations" below. |
| Desktop browsers, older iOS Safari | Silent no-op. |
Usage
typescript
// In a button handler:
function onTapAttack() {
Poe.haptics.impact("medium");
// …apply game-state change
}
// On a successful save:
async function onSave() {
await persist();
Poe.haptics.notification("success");
}
// While dragging a value slider:
function onSliderTick() {
Poe.haptics.selection();
}Haptics calls are fire-and-forget by design — there's no await and no return value to check. Don't gate UI on a successful haptic; let it be a finishing touch on top of whatever the user did.
Limitations
- No custom patterns. The API is intentionally semantic-only. iOS doesn't expose arbitrary haptic patterns to web code, so a raw-pattern API would silently degrade on half your users. The semantic taxonomy maps cleanly to every platform that has any haptic support.
- iOS Safari is one-intensity. On iOS mobile web, every style fires the same subtle tap —
impact("heavy")andselection()feel identical. - iOS Safari < 17.4 is silent. No fallback exists short of audio cues you'd implement yourself. Same answer applies to desktop browsers.
- User can disable haptics. All platforms respect the user's system-level haptics setting — the call still resolves, but the device stays still. Don't treat haptic feedback as a reliable signal that the user noticed the action.
Synced Store Helpers
Convenience helpers for common store operations — notifying the sidebar of activity. These wrap ctx.mutateExternal() so you don't need to know the manager's store type ID or mutation names.
Import from the client SDK:
typescript
import {
notifyActivity,
notifyInProgress,
setTurn,
clearTurn,
addInstanceToRoom,
} from "poe-tiles-sdk/v1/client.js";
import {
assertRoomMember,
notifyUsersAddedToTile,
} from "poe-tiles-sdk/v1/shared.js";notifyActivity()
Notify the manager of activity in this app instance. Five independent dials — preview (sidebar text), recency (whether the space also moves to the top of the recents list), unread: "increment" (app-owned unread count → numeric badge in the sidebar and contribution to the RECENTS-header total), push (OS-level push notification), and postToChat (append one announcement row to the containing chat room). Recency defaults by attention: a call carrying push, unread: "increment", or postToChat (a new transcript item) bumps the space to the top; a passive preview-only call refreshes the row in place without reordering (matching mainstream messenger behavior, where only new items and badge-worthy events reorder the list). Leave unread omitted in both the schema and client config to use the standard SDK behavior. Do not add expectNoUnread() merely because the activity currently sends no notifications — it asserts the activity never badges anyone and cannot actually suppress a badge if it does. Pick the right activity combination and target it precisely with targetUserIds. See When to notify, and at what level below.
typescript
await notifyActivity(ctx, {
preview: input.text.slice(0, 200),
previewTimestamp: Date.now(),
unread: "increment",
// No `title`: the manager composes the recipient's room title at delivery
// ("<tile>: <room/opponent>"). Pass one only to override it.
push: {
body: input.text.slice(0, 200),
},
});Parameters:
| Field | Type | Description |
|---|---|---|
preview | string | Preview text for the sidebar (e.g., last message). Self-contained — no sender prefix is added. |
previewTimestamp | number | Timestamp of the activity (content metadata). The recents row's visible time label advances only on bumping activity, so the times users see always match the ordering they see — a "preserve" refresh updates the preview text without restamping the row's time. |
recency | "bump" | "preserve" (optional) | Whether this activity moves the space to the top of the recents list ("bump") or refreshes the row's preview in place — position and visible time label unchanged ("preserve"). Defaults by attention: "bump" when the call carries push, unread: "increment", or postToChat (a new transcript item in the containing chat), "preserve" otherwise. Override with "bump" for a badge-less event that is still a new item users would call "the latest thing that happened here" (e.g. a system announcement whose unread is handled elsewhere), or "preserve" for a badge-worthy event that only mutates an existing item (e.g. a reaction push). |
unread | "increment" (optional) | Increments each non-caller recipient's app-owned unread count by 1, lights up the per-space numeric badge, and contributes to the RECENTS-header total. Works with the default simple unread policy, or with an explicit simpleUnread({ clearOn: "active" }); omit it for preview updates that should not grow the badge. Not policy-aware: it badges recipients regardless of the declared unread policy (which is why the deprecated noUnread() cannot suppress it). |
unreadToCaller | boolean (optional, default false) | Opts the caller into unread: "increment" for a system-attributed receipt delivered back to that user (for example, an agent finishing work the user started). Requires unread: "increment" and requires the caller to be in the activity recipient set. |
targetUserIds | string[] (optional) | Specific users to notify. Omit to notify all active members. On the client, also controls whether the optimistic pass runs — see Behavior. |
push | { title?, body, pushToCaller? } (optional) | When present, enqueues a push notification. Defaults to "every activity recipient except the caller" — see Push Notifications below. |
push.title | string (optional) | Notification title. Omit it to have the manager compose "<tile>: <room/opponent>" at delivery (matching the recipient's recents-row title), the same as a setTurn push. Pass an explicit string only to override that. |
push.body | string | Notification body (typically preview / message text). |
push.pushToCaller | boolean (optional, default false) | Opt the caller into the push subset. Use only when the activity is not user-attributable to the caller (e.g. a system event the caller happened to trigger, like a horse-race result). Throws if true and the caller is not in the activity recipient set. |
push.context | JSONValue (optional) | A small creator-defined entry context the recipient's activity reads at launch (via Poe.consumeEntryContext()) when they open the activity by tapping this notification. Size-capped at 4 KB serialized and validated at ingestion. A stale-able hint, not authoritative data — see Entry context. Must not contain secrets: only an opaque id transits the push vendor, but the payload is delivered to the recipient's devices and retained until TTL/cap cleanup. |
postToChat | { messageId, text, timestamp } (optional) | Also appends one app-owned announcement message to the chat room that contains this app. The destination is resolved server-side from the source store's pinned $$system/room; callers cannot provide a chat id. If there is no containing chat room, notifyActivity() logs a warning and skips only the chat append. Chat validates messageId for API compatibility, but ignores it for row identity, uses its next msg/... sortKey, and derives a separate chat-owned itemKey from that sortKey. |
Behavior:
- Client: dispatches to the current user's manager only if
ctx.userIdis intargetUserIds(ortargetUserIdsis omitted); otherwise the client-side pass is a no-op and only the server's authoritative fan-out lands. This prevents a wrong-sidebar-state flash whentargetUserIdsexcludes the caller (e.g. a "Your turn" notification sent to a single non-caller). - Server: dispatches to each user in
targetUserIds, or all active members if omitted. Users not in the app's$userstable are filtered out. - Chat posting: when
postToChatis present, the server declares a sibling mutation to the containing chat room after the manager/unread declarations. If the app is not running inside a chat room, the helper logs a warning and skips that sibling mutation. The chat receiver writes the announcement row only; it does not call back into the manager, so onenotifyActivity()call stays one manager activity.
Actor-bump invariant: every meaningful accepted user action that changes activity state or advances play must move this activity to the top of the actor's Recents. Emit the activity from the mutator that commits the action. When gameplay state is otherwise local, invoke a small activity mutator once at the semantic boundary (start/restart, completed stroke or drag, submitted choice), not on every pointer frame. Include ctx.userId in an existing activity fan-out and use recency: "bump", or send a separate actor-only activity with targetUserIds: [ctx.userId], recency: "bump", and without unread or push. Sender suppression keeps the actor from receiving their own push, but it does not make a preview-only activity bump: the explicit recency is still required. setTurn for the next player does not bump the actor; it only bumps newly marked turn-holders. Do not emit activity for rejected/no-op actions or transient input such as pointer movement, hover, or unsaved typing. Also skip per-user preferences visible only to the acting user — mute, volume, haptics, theme, and reduced motion — because they change only that person's presentation. Persist those preferences without notifying. Shared settings that affect other players or play — house rules, difficulty, round length, board size, and similar room-wide configuration — still bump.
typescript
await notifyActivity(ctx, {
preview: `${actorName} played ${moveLabel}`,
previewTimestamp: input.playedAt,
targetUserIds: [ctx.userId],
recency: "bump",
});setTurn() / clearTurn()
Declare whose turn it is in the calling app instance — the data behind the manager's "Your Turn" indicator (a pill on the room/activity). Call these from inside your mutator, the same place you call notifyActivity. A new turn mark also moves the recipient's visible room to the top of Recents without overwriting its existing preview. Each marked user's turn state is per-user-private — only that user ever sees their own "Your Turn."
setTurn(ctx, input) marks users up:
| Field | Type | Description |
|---|---|---|
userIds | string[] | Users to mark as "it's your turn" in this app instance. |
replace | boolean (optional, default true) | true → declarative replace: members currently up but not in userIds are cleared, so passing the turn to the next player clears the previous holder automatically. false → additive: only userIds are touched (e.g. a simultaneous game where players become ready one at a time). |
push | boolean | { title?, body } (optional, default true) | true → default OS push to newly-added users (body "It's your turn.", title filled by the manager from the game/room name). false → mark silently. Object → override title/body. |
Marking is idempotent — re-marking an already-up user does nothing (no duplicate push, unread increment, or recents bump). A new turn mark increments the target's unread and bumps the target's visible room to the top of Recents, so the actionable activity stays discoverable. Capped at MAX_TURN_USER_IDS users per call.
clearTurn(ctx, input) is explicit down-marking — the readable counterpart to setTurn's implicit replace-clear:
{ userIds }— clear just those users.{ all: true }— clear every current turn-holder in this instance (e.g. game over).
clearTurn never touches unread (unread clears on view).
Behavior: the server pass fans out to each target user's manager; the client pass optimistically updates only the caller (so the mover's own pill clears the instant they take their turn). The authoritative fan-out lands on the server.
typescript
import { clearTurn, setTurn } from "poe-tiles-sdk/v1/client.js";
// Sequential game (chess, checkers): pass the turn to the next player. This
// automatically bumps their room to the top of Recents, increments unread,
// marks "Your Turn," and sends the context-rich push.
const preview = `${moverName} played ${moveLabel}`;
await setTurn(ctx, {
userIds: [nextPlayerId],
push: { body: `${preview} — your move` },
});
// Simultaneous game: several users up at once (still a replace).
await setTurn(ctx, { userIds: [aliceId, bobId] });
// Add a player to the active set without disturbing the others.
await setTurn(ctx, { userIds: [carolId], replace: false });
// Mark up silently (no OS push), e.g. a low-stakes nudge.
await setTurn(ctx, { userIds: [nextPlayerId], push: false });
// Context-rich body — say what just happened instead of the generic default.
// Build it from the move/turn that triggered the change.
await setTurn(ctx, {
userIds: [nextPlayerId],
push: { body: `${moverName} took your ${pieceName} — your move` },
});
// Tile end — clear everyone, explicitly.
await clearTurn(ctx, { all: true });
// Clear one specific player (e.g. they resigned).
await clearTurn(ctx, { userIds: [aliceId] });Make the push body context-rich and engaging. The default "It's your turn." works, but a body that names what just happened — "Jacob just took your bishop", "It's your turn to guess the word", "Aaron checked you — your move" — is far more likely to pull a player back into the game. Build it from whatever triggered the turn change (the captured piece, the played card, the round/phase, the score to beat) and fall back to the generic default only when there's genuinely no context to add (e.g. an undo, the opening move). You usually only set body; the manager fills title from the game/room name.
Do not add notifyActivity only to make a turn discoverable. setTurn already bumps each newly marked recipient's room to the top of Recents. Call notifyActivity separately when the sidebar preview should describe the move, or when the actor, spectators, or other non-turn-holders should also receive an activity update. Omit unread for the next player in that companion call because setTurn already increments it.
notifyInProgress()
Report that long-running work is happening in this app instance — the data behind the spinner on the room's Recents row. Call it from inside your mutator, the same place you call notifyActivity. It is not agent-specific: an AI turn, an image generation, a simulation, or any job the room is waiting on is the same signal to someone scanning their room list.
| Field | Type | Description |
|---|---|---|
inProgress | boolean | This store's aggregate answer: is anything still running here? |
attemptKey | string | Opaque identity of the active window of work (a job id, the driving queue item). Required while inProgress; ignored otherwise. |
heartbeatSeq | number (optional) | Monotonic "still working" counter. Each increment renews the viewer's stall clock; an unchanged value does not. |
source | string (optional, ≤32 chars) | Short producer label for display/debugging. Defaults to this store's type id. |
typescript
import { notifyInProgress } from "poe-tiles-sdk/v1/client.js";
// The work starts.
await notifyInProgress(ctx, { inProgress: true, attemptKey: jobId });
// ...and when it ends, or you find nothing running.
await notifyInProgress(ctx, { inProgress: false });Send the aggregate, not one job's boolean. The platform keeps one entry per app instance, so the last thing you send is the whole instance's answer. With two jobs running, reporting false when the first finishes clears the spinner while the second is still going. Derive "is anything still running here?" from your store at every call site — which also means calling it from every mutator that can change that answer, including the ones that end work (a completion, a cancel, an error), not only the one that starts it.
Work that runs longer than ~5 minutes must heartbeat. The viewer expires an attempt roughly five minutes after its last new signal, so a crashed producer never strands a spinner. If your work legitimately runs longer, increment heartbeatSeq from something that is genuinely still alive and doing the work (a worker or bridge timer) — never from a UI re-render or a periodic reconcile, or a dead job's spinner would spin forever. Re-sending the same attemptKey with no heartbeat is always safe: it restates the current answer without reviving an attempt the viewer already gave up on. A retry that starts fresh work should send a new attemptKey, which restarts the clock.
Behavior: the server pass dispatches to this instance's active members; the client pass dispatches to the caller only, so their own spinner appears without a round trip. The signal is display-only — it never bumps Recents order, previews, unread badges, or push notifications, so it is safe to send freely. Delivery is best-effort and covers the first ~150 members of a room; beyond that, members simply see no spinner. Calling it from a mutation another store dispatched throws, because cross-store mutations cannot dispatch further — defer that emission to an action.
assertRoomMember()
Validate inside a mutator that a user is still an active member of the caller's resolved room.
typescript
await assertRoomMember(ctx, { userId: input.userId });The client optimistic pass is a no-op. The server pass reads the trusted $$system.room row: self-room stores validate against their own $users, and room-member stores validate against the canonical room store's $users. For child rooms with $$system.room.parent, a user who is active in the parent also passes — whether they have no child $users row yet or previously left the child room (a leave carries no veto; the platform re-admits them on access). This matters when a user is in the room but has never opened the activity, so they do not appear in the activity instance's local $users mirror yet.
notifyUsersAddedToTile()
Send standard "added/assigned to this tile" activity and push notification to selected room members.
typescript
await notifyUsersAddedToTile(ctx, {
targetUserIds: [input.userId],
appName: "Checkers",
reason: "assigned",
previewTimestamp: input.now,
});The helper is server-only and validates every target against the caller's canonical room before dispatching. It uses the same manager receiveActivity path as notifyActivity, but does not filter against the activity instance's local $users, so it can notify room members who have not opened the activity. Use notifyActivity() directly for custom copy, unread behavior, or chat announcements.
Unread policy is app-owned. Leave unread omitted in both the schema and client config: by default, the SDK stores a per-user private unread projection and clears it when the host reports that the user is active in the activity. Do not add expectNoUnread() merely because the activity currently sends no notifications; keeping the default makes later setTurn and notifyActivity({ unread: "increment" }) additions work without a policy migration. Apps with custom read semantics can declare customUnread() in both places and call setUnreadCount(ctx, { count }) from their own mutators.
expectNoUnread() is the third choice, for an app that will never badge anyone. It is an assertion, not a suppression switch — hence the name. The increment paths are per-call and not policy-aware, so an app that declares it and then calls unread: "increment", setTurn, or the automatic leaderboard notify still badges its recipients while declaring it does not. What the declaration buys is that the platform withholds the increment mutator, and treats any count that does appear as damage: it keeps the clear-side repair and clears on open whenever the local count reads positive. Declare it only if that is really true of your app; when in doubt, leave unread omitted. (noUnread() is deprecated — it is the former name for this policy. It read as "this app has no unread", a guarantee the platform cannot make, and it stays exported so activities already published keep booting.)
Push Notifications:
When push is present, each recipient's manager enqueues a pending delivery row alongside the sidebar update.
- Default sender-suppression. The helper strips
pushfrom the caller's own dispatch. The caller's other devices still get the activity update via sync but no OS-level push for their own action. Override only when the activity isn't user-attributable to the caller — setpush.pushToCaller: true. - OS-level "currently in the app" suppression. Don't ring the device a recipient is actively using is handled at the OS layer (
UNUserNotificationCenterDelegateon iOS, equivalents on Android/web), not by the helper. - Tap target is implicit. Delivery channels construct deep-links from the calling app's
typeId+instanceId— apps don't specify a URL.
Limits: Each notified user's manager is one external mutation target. The limit is MAX_EXTERNAL_MUTATION_TARGETS (200) unique target stores per commit.
When to notify, and at what level
| Activity kind | unread | push | Example |
|---|---|---|---|
| Action required from a specific user | "increment" | yes | "Jacob just took your bishop" — sent to the player whose turn it is (name the triggering event, not a bare "It's your turn") |
| Opt-in interesting event | "increment" | yes | "Your friend beat your high score!" |
| Passive update worth surfacing | "increment" | no | "Aaron reacted with 🎉" / "Spymaster gave a clue" — bumps the app and adds to the badge count, no ring |
| Preview refresh without unread | omit | no | Updating a move preview for the actor/spectators, importing your own old messages, todo edits — refreshes the row's preview in place without reordering recents or growing the badge (pass recency: "bump" explicitly if the event should also reorder) |
Rules of thumb:
- Push only when the user would want their phone to ring. Required-action moments (turn games, incoming chat message, invite) and high-signal opt-in events (someone beat your score, a friend you know just played). Don't push on every state change — multi-player apps generate dozens of mutations per session, and pushing all of them is spam.
- Use
unread: "increment"(withoutpush) for "nice to know when you look". Reactions, partial-progress events from collaborators, another player taking a non-blocking turn (e.g. spymaster choosing a clue while it's not yet your guess phase). This bumps the space, increments its numeric badge, and contributes to the sidebar's RECENTS total — but doesn't ring the device. - Omit
unreadfor preview updates that should not grow the badge. This includes a player's own durable action when another mechanism, such assetTurn, owns the recipient's unread state. If a player would expect a number to change and nothing else increments it, use"increment". A badge-less call also does not reorder recipients' recents lists (therecencyattention default) — the recipient whose action matters is usually being bumped bysetTurnor anunread/push-carrying call already. - Keep caller unread opt-in exceptional. User-authored activity is sender-suppressed by default. Set
unreadToCaller: trueonly when the event is attributed to the app, system, or agent and is delivered back to the triggering user as a receipt. - Choose
targetUserIdsdeliberately. Omit it when every active member — including the actor — should receive the new preview and recents timestamp. For targeted pushes, include only the intended recipients: the next player for "your turn," or the previous record-holder for "friend beat your score." Default sender-suppression handles "don't push the user who triggered the action" for you. - Make the push body specific and engaging, not generic.
"Jacob just took your bishop"or"It's your turn to guess the word"pulls a player back far better than a bare"It's your turn."Build the body from whatever just happened — the move, the capture, the card played, the round/phase, the score to beat — and reserve the generic default for when there's genuinely no context. This applies to bothsetTurn'spushandnotifyActivity'spush; compute the text in the same mutator that detects the event. - Make
previewself-contained. It shows up in the recents list with no other context —"Alice: nice move"reads better than"nice move".
Concrete examples:
typescript
// Turn-based game: setTurn alone makes the action discoverable to the next player.
const nextPlayer = computeNextPlayer(state);
const preview = `${currentPlayerName} played ${moveLabel}`;
await setTurn(ctx, {
userIds: [nextPlayer.userId],
push: { body: `${preview} — your move` },
});
// Optional: update the sidebar preview for everyone, including the actor.
// Push-less and unread-less, so it refreshes each row in place — only the
// next player's room (bumped by setTurn) reorders.
await notifyActivity(ctx, {
preview,
previewTimestamp: input.playedAt,
});
// Same game: spymaster picked a clue — badge + unread for the guessing team, no push
await notifyActivity(ctx, {
preview: `Spymaster: "${clue}" (${count})`,
previewTimestamp: Date.now(),
unread: "increment",
targetUserIds: guessingTeamUserIds,
});
// High-score game (e.g. poe-jump): someone finished a run — badge bump, no push.
// Default sender-suppression keeps the runner's own count from incrementing, so
// targetUserIds can safely list all members.
const timestamp = Date.now();
await notifyActivity(ctx, {
preview: `${playerName} scored ${score}`,
previewTimestamp: timestamp,
unread: "increment",
postToChat: {
messageId: scoreEventId,
text: `${playerName} scored ${score}`,
timestamp,
},
});
// Same game: new player beat the previous record — push the previous record-holder
if (score > previousBest.score && previousBest.userId !== ctx.userId) {
await notifyActivity(ctx, {
preview: `${playerName} beat your score (${score})`,
previewTimestamp: Date.now(),
unread: "increment",
targetUserIds: [previousBest.userId],
push: { body: `${playerName} beat your high score: ${score}` },
});
}You can skip hand-rolling the high-score case: setLeaderboardScore fires this notification for you on a new high score (personalized push to every other active member + a chat announcement when the #1 changes hands, scorer suppressed) — it is on by default. Pass { notifyOnHighScore: false } — setLeaderboardScore(ctx, { … }, { notifyOnHighScore: false }) — to opt out; hand-roll it yourself only when you want custom copy/targeting, and then opt out so it doesn't double up.
When the board has a persisted label, every push/activity body and the chat announcement name it: Alice just beat your score on May 30 best attempts with 9!. This is how an activity that mints one board per day (or week, or season) tells the recipient WHICH board the score landed on — put the period in the label (May 30 best attempts), never deictic wording like "Today's best attempts": the notification is often read after the day rolls over, and each period's board keeps its label forever. A single evergreen board can skip the label and keep the shorter wording.
The chat announcement is built from the entry you just wrote: displayScore (or the raw score) as the headline. Add summaryInChatAnnouncement: true — setLeaderboardScore(ctx, { … }, { summaryInChatAnnouncement: true }) — and the entry's markdown rides under it, so a board that already annotates each row with a result summary shows that result in the transcript instead of a bare number:
🏆 Alice took the top spot with 2/6
🔥 Streak 1
🟨⬛⬛🟩⬛
🟩🟩🟩🟩🟩It is off by default, because a markdown written for the tileEnd overlay is read only by members who open the board, while chat is read by the whole room. Opt in only for a summary you mean to publish there — and remember chat renders it as markdown, so keep it to the result itself rather than links or headings.
addInstanceToRoom()
Register an app instance as a member of a flat room. The room owns its $users roster; member instances mirror that roster via fan-out from the room. Once registered, every $addUsers / $removeUser against the room reaches the member automatically — including users admitted before the member joined.
Import from the client SDK:
typescript
import { addInstanceToRoom } from "poe-tiles-sdk/v1/client.js";Call from inside a mutation handler:
typescript
const mutators = {
// Running on the chat (a room). Register a launched game with the
// chat's $room_member_instances so the game inherits the chat's
// $users via the platform's room fan-out.
launchGame: async (ctx, input) => {
await addInstanceToRoom(ctx, {
storeTypeId: input.gameTypeId,
instanceId: input.gameInstanceId,
});
await ctx.table("games").set({
itemKey: input.gameInstanceId,
value: input,
});
},
};Input shape:
typescript
{
storeTypeId: string; // The app instance being registered
instanceId: string;
room?: { // Optional: explicit room ref
storeTypeId: string;
instanceId: string;
};
}- Omit
roomwhen the calling store IS the room (the commonlaunchGame-style case — the helper dispatches to the local store). - Pass
roomwhen the calling store is a member of the room and needs to register another app instance on the room's$room_member_instances. App-level mutators cannot read the local$$system:roomrow to auto-detect role, so the caller specifies it.
Idempotent on re-call (the platform mutator's set overwrites the identically-keyed row). Safe to race with the client-side <poe-tile room="inherit"> flow — both converge on the same row.
The platform enforces a single-room invariant: if the target instance is already a member of a different room (its $$system:room points elsewhere) or is itself a room, the dispatch throws RoomMembershipConflictError and the row never lands. An instance can be a member of at most one room at a time.
getCurrentUserId()
Read the current user's userId from a synced-store client.
typescript
import { getCurrentUserId } from "poe-tiles-sdk/v1/client.js";
// Once at app mount:
const userId = await getCurrentUserId(store);store.userId does not exist by design — the user identity lives in the query/mutator ctx. This helper runs a one-shot query that resolves to tx.userId, which is the recommended way to read it from UI code (effects, async resources, manual reads). For per-render reactive access, prefer reading tx.userId inside a store.subscribe() query callback.
typescript
// SolidJS / async-init pattern:
const [userIdResource] = createResource(
() => store,
(s) => getCurrentUserId(s),
);store.files.getQuotaUsage() — exact upload quota counters
Read the exact instance usage and limits plus the authenticated user's attributed usage:
typescript
const usage = await store.files.getQuotaUsage();
// usage.instance: { bytes, count, limitBytes, limitCount }
// usage.user: { userId, bytes, count }Client code can inspect only the acting user's usage. Backend actions can call ctx.files.getQuotaUsage({ userId? }) when they need to inspect another user. The publish-time platform check remains authoritative if concurrent uploads race a client-side preflight.
store.files.upload() — managed file uploads
The store handle returned by Poe.setupStore() carries a files namespace for uploading user files (photos, attachments) into the activity's own store. The scaffold's canonical InferSyncedStoreClient<YourSchema> type describes this complete handle, including files; do not manually intersect a second extension type or inspect generated declaration files. Uploads are app-controlled: they work only when your schema declares the reserved _requestUploadUrl action and your backend implements it (see backend-api.md) — that handler is your policy gate, run server-side before every upload.
typescript
const file = await store.files.upload({
data: photoBlob, // Blob | Uint8Array
name: "capture.jpg", // optional display metadata
contentType: "image/jpeg", // optional hint; bytes are inspected anyway
});
// file: { status: "staged", fileId, fileKey: null, url: null, ... }Enforce app rules (one per day, one per round) in the MUTATOR that claims the fileId — it is the authoritative gate anyway; the grant handler only sees { fileId, sizeBytes, name? } and should gate what must be decided before the bytes upload, like a size cap (see backend-api.md).
upload() resolves after the host has durably staged the bytes and its background upload task. It returns a usable fileId immediately; subsequent mutations may reference that ID, and the store's push queue waits for publication before sending them. The file is NOT yet retained: write file.fileId into a table field declared under that table's fileRefs so the platform derives the $files claim row and keeps the bytes alive. An upload that no row ever claims expires.
await store.files.uploads.get(file.fileId) reports local staging or terminal failure state. It returns null after successful publication. Use it to render an upload failure UI; do not wait for publication before creating the claiming mutation.
typescript
// schema.ts — declare the claim field:
tables: {
submissions: {
schema: table(z.object({ fileIds: z.array(z.string()) })),
fileRefs: { strong: ["fileIds"] as ["fileIds"] },
},
},
// then claim from a mutator:
await ctx.table("submissions").set({
itemKey: key,
value: { fileIds: [file.fileId] },
});Persist only fileIds in your rows — never a URL. Mutator args are client-controlled, so a URL column would let any member display bytes that bypassed the pipeline. Resolve the display URL at render time instead:
store.files.url(fileId) — resolve a claimed file's display URL
typescript
const src = await store.files.url(submission.fileIds[0]);Resolved by the host from the current deployment's serving origin, so it never goes stale across origin moves. It waits (bounded, ~15s) for the server-derived $files claim row to sync — safe to call as soon as your optimistic row renders — and rejects for a fileId no row ever claimed. Claims from public and privateOfUser(self) tables both resolve; a file claimed only by a server-only table cannot be resolved client-side (its claim row never syncs). The url on the upload result remains available when you need a durable link at upload time (e.g. to hand to an action immediately).
Failures reject with a FileUploadError (import isFileUploadError from poe-tiles-sdk/v1/client.js) carrying a typed code:
uploads_not_enabled— the schema declares no_requestUploadUrlhandler.upload_denied_by_app— your handler threw anActionError; its bounded code rideserror.appCode(e.g."photo_too_large").quota_exceeded— the shared limit for the whole activity instance.error.quotaScopeis"instance".rate_limited,file_too_large— other platform limits.idempotency_conflict— thefileIdbelongs to a different uploader.uploads_disabled/upload_failed— deployment or transient failures (error.retryablesays whether re-invokingupload()may succeed).
Uploads run in the foreground and require connectivity; on failure, keep the user's bytes and let them retry. Downscale camera photos client-side (canvas → JPEG) before uploading — grant handlers commonly cap sizeBytes.
Recording which configuration produced an agent reply
If your app triggers agents (Poe.agents.*) and stores their replies, you can record the configuration each run actually executed on — the model, the tools it was allowed, the tool→model bindings, and which of those a per-trigger override replaced.
This is worth storing rather than looking up later, for two reasons: an agent's configuration is overwritten in place with no history, so editing it retroactively changes what every past reply appears to have been generated with; and a per-trigger override (modelOverride / enabledTools / toolModels) is never persisted anywhere, so it is unrecoverable once the run ends.
typescript
import {
type EffectiveAgentRunConfig,
parseEffectiveRunConfigPlain,
} from "poe-tiles-sdk/v1/shared.js";Declare the field on the row that stores the reply. Use z.custom for the type — do not import a Zod schema for it, because a schema reachable from your mutators lands in your app's eager bundle:
typescript
// schema.ts
generationConfig: z.custom<EffectiveAgentRunConfig>().optional(),Then validate at the boundary where the snapshot arrives, and write it once:
typescript
// In the mutator that records the reply. No `undefined` guard is needed —
// `parseEffectiveRunConfigPlain` returns null for an absent or malformed value.
const generationConfig = parseEffectiveRunConfigPlain(input.generationConfig);
// Spread only when non-null — see the two rules below.
await ctx.table("messages").set({
/* … */
...(generationConfig !== null ? { generationConfig } : {}),
});Two rules to follow, both of which are easy to get wrong:
nullmeans NOT RECORDED — never "the agent's current configuration." A row written before you added the field, a reply from something that is not an agent, and a run that failed before resolving a configuration all read asnull. Rendering any of them as the live configuration reintroduces exactly the problem this solves. The same applies one level down: a field missing from a snapshot whosevpredates it is also not-recorded, so never??a default onto a snapshot field.- Write it on create only, and never rewrite it. The snapshot cannot change within a run, so a later dispatch carrying one should be ignored. That is what makes the record trustworthy — and it means a retried run keeps the original.
parseEffectiveRunConfigPlain returns null instead of throwing, on purpose: a malformed snapshot should cost you the provenance record, never the reply it annotates. It is deliberately Zod-free so it is safe to call from a mutator.
Platform Helpers
Small utilities for detecting or configuring the runtime environment. Safe to call from any Joiner activity entry.
resumeAudioContext()
Resume a Web Audio context after a normal browser suspension or an iOS system-audio interruption such as an alarm or phone call. WebKit reports the non-standard state "interrupted"; checking only for "suspended" leaves the context silent until the activity is reopened.
Call the helper before scheduling a sound on a reused context. It is a no-op for running or closed contexts and contains resume failures because optional audio feedback must not interrupt input.
typescript
import { resumeAudioContext } from "poe-tiles-sdk/v1/client.js";
const context = new AudioContext();
function playCue() {
resumeAudioContext(context);
// Create and start this cue's AudioNodes.
}The helper also covers a newly-created context that starts suspended, but it does not bypass autoplay policy: create or resume audio from a user gesture when the browser requires one.
createRenderBudget() / resolveRenderBudgetDefaults()
Every continuous-render activity (a requestAnimationFrame loop that redraws a canvas every frame) should adopt the platform render budget. It applies the platform power defaults — a 60 fps render cap on mobile-like clients (desktop stays uncapped), a device-pixel-ratio cap of 1.5 on mobile (2 on desktop), no antialiasing or soft shadows above DPR 1, and the default WebGL powerPreference — and it reports detailed render stats (fps, missed frames, render CPU, draw calls) that the platform uses to find battery-hungry activities. On lab hardware the DPR + fps defaults cut rendered pixels by ~59% on a high-refresh Android phone with no visible quality loss. They are defaults, not an enforced ceiling: an activity that knows its workload can override them (see "Quality overrides" below).
If your activity owns its render loop (plain canvas 2D, three.js, raw WebGL), create a budget and let it drive renderer construction and the frame gate:
typescript
import { createRenderBudget } from "poe-tiles-sdk/v1/client.js";
const budget = createRenderBudget({
canvas, // prices each rendered frame for the pixels metric
getDrawCalls: () => renderer?.info.render.calls, // three.js only; omit for canvas 2D
});
const renderer = new THREE.WebGLRenderer({ canvas, ...budget.rendererOptions() });
budget.configureRenderer(renderer); // applies the DPR cap + shadow downgrade
function loop(now: number) {
// The gate skips RENDERING only. Keep simulation and input handling at
// full rAF cadence (with your usual dt clamp) so a skipped frame never
// desyncs game state.
const renderThisFrame = budget.shouldRenderFrame(now);
stepSimulation();
if (renderThisFrame) {
budget.renderFrame(() => renderer.render(scene, camera));
}
requestAnimationFrame(loop);
}WebGL context loss: when the budget's canvas is a real canvas element, the budget also watches it for webglcontextlost / webglcontextrestored and records the event for the platform's reliability telemetry. A reclaimed context (common in mobile WebViews under memory pressure) makes every GL call a silent no-op — the loop keeps running at full fps while the canvas turns transparent, so the player sees your page background and nothing else. Pass onContextLost / onContextRestored to show and clear a visible notice; three.js re-initializes its own GL state on restore, so hiding the notice is usually all the recovery an activity needs:
typescript
const budget = createRenderBudget({
canvas,
onContextLost: () => showNotice("Graphics paused by your device…"),
onContextRestored: () => hideNotice(),
});Create ONE budget per document, not one per game round: the platform's power metric reads cumulative counters, so a fresh budget on every remount resets them mid-session and corrupts the activity's numbers. If your activity rebuilds its renderer across rounds or component remounts, use the SDK's shared-budget factory instead of managing the singleton yourself — acquireSharedRenderBudget() returns the document's one budget (creating it on first call), and adoptSharedRenderBudgetRenderer(renderer) re-points frame pricing, draw-call sampling, and the DPR cap at each freshly built renderer (adoptSharedRenderBudgetCanvas(canvas) is the flavor for canvas-2D activities with no renderer object). Soft shadows: pick the shadow-map type from budget.defaults.softShadows instead of hardcoding PCFSoftShadowMap.
typescript
import { adoptSharedRenderBudgetRenderer } from "poe-tiles-sdk/v1/client.js";
// In the mount path that (re)builds the renderer — every mount adopts the
// same document-scoped budget, so the platform's cumulative counters survive:
const budget = adoptSharedRenderBudgetRenderer(renderer);If an engine owns the loop (Phaser), you cannot wrap the render call — map the resolved values into the engine's own config with the pure resolver instead:
typescript
import { resolveRenderBudgetDefaults } from "poe-tiles-sdk/v1/client.js";
const defaults = resolveRenderBudgetDefaults();
const game = new Phaser.Game({
...(defaults.targetFps !== null ? { fps: { limit: defaults.targetFps } } : {}),
render: { powerPreference: defaults.powerPreference },
// ...rest of your config
});Do NOT call createRenderBudget() from an engine-owned activity just to read .defaults: constructing a budget registers the helper as the iframe's render-stats producer and silences the platform's automatic instrumentation — if nothing then drives shouldRenderFrame/renderFrame, the activity stops reporting render stats entirely. resolveRenderBudgetDefaults() is side-effect free.
If an activity does neither, it still works and is still measured: the platform auto-instruments every activity with coarse render stats. But it renders unbudgeted — high-refresh Android panels run the loop at 90–120 fps and retina screens get uncapped-DPR buffers, both pure battery cost — and its telemetry stays coarse, so it shows up worse in the platform's per-activity power ranking without the detail to explain why.
Quality overrides
The resolved values are the platform's defaults, nothing more. An activity that wants different quality — a graphics-settings page, richer settings chosen from its own knowledge of its workload — passes explicit overrides, and the SDK honors them verbatim:
typescript
const budget = createRenderBudget({
canvas,
maxPixelRatio: 3, // raise (or lower) the DPR cap
antialias: true, // replace the resolved-DPR-based default
targetFps: 30, // change the frame-gate target; applies on desktop too
});
// The same options work on acquireSharedRenderBudget() (first call only)
// and on resolveRenderBudgetDefaults() for engine-owned tiles.Prefer overriding through the budget over bypassing it: shouldRenderFrame() is also what counts frames and paces the platform's render stats, so routing around the gate corrupts your activity's telemetry, while an explicit targetFps keeps the whole instrument coherent (the gate paces against it, missed frames are judged against it, and the reported target reflects it). Overrides are stamped into the activity's power telemetry, so an opted-up activity shows up hotter in the fleet ranking with the explanation attached — never disguised as a regression.
Platform behavior worth knowing before overriding (documented, not enforced):
- iOS WKWebView clamps
requestAnimationFrameto ~60 Hz even on 120 Hz ProMotion panels, so a highertargetFpsthere renders at ~60 while being truthfully judged against your request — expect a poor missed-frame ratio. - Android WebView runs rAF at the panel rate, so targets above 60 are reachable only on high-refresh panels.
- Sub-60 targets read as visibly choppy on phones (the platform default is 60 for this reason); test on real hardware before shipping one.
maxPixelRatioabove the device's real DPR buys nothing — the resolved ratio ismin(device DPR, maxPixelRatio).
createVerticalScrollBounceMount()
Create an inner render target inside a root element and opt the root into native vertical pull bounce, even when the app's content is shorter than the viewport. Use this for the normal iframe-document case where #root is the top-level scroll container.
Apps scaffolded from the official templates already call this in their entry file, so most apps do not need to add it manually.
typescript
import { createVerticalScrollBounceMount } from "poe-tiles-sdk/v1/client.js";
const root = document.getElementById("root");
if (root) {
const appRoot = createVerticalScrollBounceMount(root);
renderApp(appRoot);
}The helper clears root and appends one generated content wrapper on every platform. Inside the iOS app, it also applies vertical overflow/momentum styles to root and makes the wrapper at least calc(100% + 1px) tall. The 1px overflow is intentional: it is the smallest reliable amount needed for WKWebView to enter the native rubber-band path.
installVerticalScrollBounce()
Opt a specific custom scroll area into native vertical pull bounce. Use this when only part of the app should bounce, such as a chat message list, while settings/invite/member panels should keep their own behavior.
typescript
import { installVerticalScrollBounce } from "poe-tiles-sdk/v1/client.js";
const cleanup = installVerticalScrollBounce({
scrollElement: messagesScroller,
contentElement: messagesList,
});contentElement must be a descendant of scrollElement. The helper returns a cleanup function that restores the previous inline styles. Outside the iOS app it validates the input and otherwise no-ops, so desktop, mobile web, and Android do not get a forced 1px scroll range. If the content later grows taller than the viewport, the same element remains the normal scroll container; no re-install is needed.
isIosApp()
Returns true when running inside the iOS app WebView, including sandboxed app iframes. Use this only for behavior that depends on the native app shell; use isIosWebkit() for broader iOS WebKit checks.
typescript
import { isIosApp } from "poe-tiles-sdk/v1/client.js";
if (isIosApp()) {
// Native-app-only iOS behavior
}isAndroidApp()
Returns true when running inside the Android app WebView, including sandboxed app iframes. The Android counterpart of isIosApp(); use it for behavior that depends on the native Android app shell. (There is no isAndroidWebkit() — the Android WebView is Chromium, not WebKit.)
typescript
import { isAndroidApp } from "poe-tiles-sdk/v1/client.js";
if (isAndroidApp()) {
// Native-app-only Android behavior
}isIosWebkit()
Returns true when running in iOS Safari or WKWebView (including the iOS app). Use only for genuine platform differences — most code should be platform-agnostic.
typescript
import { isIosWebkit } from "poe-tiles-sdk/v1/client.js";
if (isIosWebkit()) {
// iOS-specific workaround
}isMobileLikeClient()
Returns true when the app is running inside the iOS or Android app or in a browser whose primary pointer is coarse (phones, tablets, touchscreen laptops in tablet mode). Returns false on regular desktops with a mouse and in Node / SSR contexts.
Use to gate UI that only makes sense on touch-primary devices — touch-only controls or mobile-specific install hints.
typescript
import { isMobileLikeClient } from "poe-tiles-sdk/v1/client.js";
if (isMobileLikeClient()) {
// Render the touch joystick.
}applyNativeAppGestureOverrides()
Suppress default browser gestures (text selection, the iOS callout menu, and native link-drag) inside the native app's WebView, so the app feels like a native mobile app. Opt-in per app — call once at app startup (e.g. in your entry file).
Inputs, textareas, and contenteditable elements are opted back in so users can still select and copy text they've typed.
No-op outside the native app WebView — desktop browsers, iOS Safari, and Android Chrome keep their default behavior. Idempotent.
typescript
import { applyNativeAppGestureOverrides } from "poe-tiles-sdk/v1/client.js";
applyNativeAppGestureOverrides();suppressLongPressMagnifier()
Suppress the iOS long-press magnifier loupe on a hold/drag gameplay surface — a game canvas, a board, a draggable piece. Opt-in per element.
applyNativeAppGestureOverrides() stops the selection callout and text selection, but on iOS it does not stop the round magnifier loupe WebKit shows once a still finger starts its text-selection gesture. That loupe fires even over non-selectable content, so a press-and-hold-to-charge or drag interaction pops a magnifier mid-play. CSS can't reach it; WebKit only withholds the gesture when the page cancels touchstart, which is what this helper does.
Pass the gameplay element. The helper installs a non-passive touchstart listener that calls preventDefault() on it, and returns a function that removes the listener (call it on unmount). Applies on all iOS WebKit; a no-op that returns a no-op remover elsewhere (Android, desktop, server).
Scope it deliberately. Cancelling touchstart also cancels that element's other native touch defaults — the synthesized click, double-tap zoom, and touch-initiated scrolling. Use the plain call only where a pointer-driven press or drag is the interaction (input from pointerdown / pointerup, no click handlers on the surface). An element that must scroll natively cannot use the helper at all.
Dual-mode surfaces (preserveTaps). If the same elements support drag and tap, and the tap action runs on click handlers (tap-to-move board cells, tap-to-place racks — click is also how keyboard activation reaches <button> cells), pass { preserveTaps: true }. Quick still touch taps are then re-dispatched as a synthetic bubbling click at the touched element, so existing click handlers and framework event delegation keep working; holds and drags produce no click; mouse, keyboard, and non-iOS platforms are untouched. The synthetic click is isTrusted: false and does not focus inputs — never point this mode at form fields or navigation links.
typescript
import { suppressLongPressMagnifier } from "poe-tiles-sdk/v1/client.js";
// Pointer-driven canvas — plain call:
const canvas = document.querySelector("canvas")!;
const removeMagnifierSuppression = suppressLongPressMagnifier(canvas);
// Tap+drag board whose cells act on click — preserve taps:
const board = document.getElementById("board")!;
const removeBoardSuppression = suppressLongPressMagnifier(board, {
preserveTaps: true,
});
// later, on teardown:
removeMagnifierSuppression();
removeBoardSuppression();installKeyboardLayoutInset()
Keep your app's layout above the iOS on-screen keyboard. On iOS the WKWebView does not resize when the keyboard opens — it just draws the keyboard on top of your content, so a bottom-anchored input or button ends up hidden behind it. Call this once at startup to opt in: the native shell then shrinks your scroll container (#root by default) by the live keyboard height, so the space the keyboard occupies is removed from your layout and bottom-anchored content stays visible. It animates in sync with the keyboard and is a no-op outside the iOS app (desktop, Safari, Chrome, Android are unaffected — they resize or scroll natively).
typescript
import { installKeyboardLayoutInset } from "poe-tiles-sdk/v1/client.js";
installKeyboardLayoutInset(); // shrinks #root; call once at startup
installKeyboardLayoutInset({ selector: ".app-root" }); // custom containerUse it for a full-viewport app that owns its own keyboard layout — one with a scroll scaffold (html, body { height: 100dvh; overflow: hidden } and the container overflow-y: auto) and a docked input bar, like a chat composer. Registering also disables the WebView's native document scrolling while the app is mounted (so a drag with the keyboard up can't push your fixed chrome off-screen).
Consequence — a non-docked input needs help. Because registering suppresses WebKit's native scroll-to-focused, an input that lives in ordinary scroll flow (not docked above the keyboard) is no longer auto-revealed when it's focused — the keyboard will cover it. You have two options: dock the input's controls above the keyboard (a bottom bar that rides the shrunk container), or scroll them into view yourself with createKeyboardFocusScroller() below.
createKeyboardFocusScroller()
The companion to installKeyboardLayoutInset() for the scroll-it-yourself case: it scrolls a focused input's controls above the keyboard. On focus it eases the scroll container each animation frame so the controls ride up with the rising keyboard (rather than the keyboard covering the input and the page snapping afterward), and a manual drag aborts it. It's a no-op where the browser's own scroll-to-focused already reveals the input, so it's safe to leave in for every platform.
Wire it to the element wrapping the input and its buttons (e.g. the <form>): attach ref to that element, onFocusIn as its focus handler, and call dispose() on unmount.
tsx
import {
createKeyboardFocusScroller,
installKeyboardLayoutInset,
} from "poe-tiles-sdk/v1/client.js";
import { onCleanup } from "solid-js";
// once at startup:
installKeyboardLayoutInset();
// in the component that renders the input (SolidJS shown; same idea in React/Preact):
function GuessForm() {
const scroller = createKeyboardFocusScroller();
onCleanup(scroller.dispose);
return (
<form ref={scroller.ref} onFocusIn={scroller.onFocusIn} class="pb-4">
<input placeholder="Your guess…" />
<button type="submit">Guess</button>
</form>
);
}Options: scrollRoot (element or getter; defaults to the #root element) and maxDurationMs (how long to keep tracking the keyboard's rise; defaults to 800). Add a little bottom padding (e.g. pb-4) below the last control so it doesn't sit flush against the keyboard.
Framework Hooks
React — useLiveQuery (poe-tiles-sdk/v1/react)
A React hook that subscribes to a live store query. Handles subscription lifecycle automatically and re-renders when data changes.
typescript
import { useLiveQuery } from "poe-tiles-sdk/v1/react";
function App({ store }) {
const { data: items, isLoading } = useLiveQuery(store, (tx) =>
tx.table("items").entries().toArray(),
);
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{(items ?? []).map(([, item]) => <li key={item.id}>{item.text}</li>)}
</ul>
);
}Parameters:
| Parameter | Type | Description |
|---|---|---|
store | InferSyncedStoreClient<Schema> | null | The store to subscribe to. Pass null to get loading state. |
queryFn | (tx: QueryContext) => Promise<T> | Query function run against a read transaction |
Returns: { data: T | undefined, isLoading: boolean }
dataisundefineduntil the first query result arrives- When
queryFnreference changes, previous data is kept until new results arrive - The hook automatically unsubscribes on unmount
SolidJS — createLiveQuery (poe-tiles-sdk/v1/solid)
A SolidJS reactive primitive that subscribes to a live store query. Both parameters are accessors for fine-grained reactivity.
typescript
import { Show, For } from "solid-js";
import { createLiveQuery } from "poe-tiles-sdk/v1/solid";
import type { InferSyncedStoreClient } from "poe-tiles-sdk/v1/client.js";
import type { MySchema } from "./synced-store/schema";
type MyStoreClient = InferSyncedStoreClient<MySchema>;
function App(props: { store: MyStoreClient }) {
const { data, isLoading } = createLiveQuery(
() => props.store,
() => (tx) => tx.table("items").entries().toArray(),
);
return (
<Show when={!isLoading()} fallback={<div>Loading...</div>}>
<For each={data() ?? []}>
{([, item]) => <li>{item.text}</li>}
</For>
</Show>
);
}Parameters:
| Parameter | Type | Description |
|---|---|---|
storeAccessor | Accessor<InferSyncedStoreClient<Schema> | null> | Accessor returning the store (or null for loading) |
queryFnAccessor | Accessor<(tx: QueryContext) => Promise<T>> | Accessor returning the query function |
Returns: { data: Accessor<T | undefined>, isLoading: Accessor<boolean> }
- Both params are accessors — SolidJS tracks signal reads for automatic re-subscription
onCleanuphandles unsubscription automatically
SolidJS — createLiveQueryResource (poe-tiles-sdk/v1/solid)
Suspense-aware sibling of createLiveQuery. It subscribes to the same live query shape, but exposes the initial result through a Solid Resource; later subscription results mutate that resource.
typescript
import { For, Suspense } from "solid-js";
import { createLiveQueryResource } from "poe-tiles-sdk/v1/solid";
function App(props: { store: MyStoreClient }) {
const { data } = createLiveQueryResource(
() => props.store,
() => (tx) => tx.table("items").entries().toArray(),
);
return (
<Suspense fallback={<div>Loading...</div>}>
<For each={data() ?? []}>
{([, item]) => <li>{item.text}</li>}
</For>
</Suspense>
);
}Returns: { data: Resource<T | undefined>, isLoading: Accessor<boolean> }