Appearance
Client API
The poe-tiles-sdk/v1/client.js module is the client-side JavaScript interface for apps running inside Poe canvas frames. It provides store initialization, data access, and bot interaction — all from your app's frontend code.
typescript
import { createPoe, PostMessageEnvironment, registerPoeAppElement } from "poe-tiles-sdk/v1/client.js";Client APIs
Poe.setupStore()— Initialize a synced storePoe.store— Access the SyncedStoreClient after setupPoe.stream()— Stream a response from a Poe botPoe.call()— Call a bot with automatic tool executionPoe.createTool()— Define a tool for use withPoe.call()Poe.listModels()— List available Poe modelsPoe.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.open()— Navigate to a different app (or open it in split view alongside the caller)Poe.users.openProfile()— Open a user's profile UI in the hostPoe.room.openInvitePicker()— Open the host invite picker for the calling app instancePoe.track()— Send a privacy-filtered analytics event<poe-app>— Embed a child app inlinePoe.getOpenProps()— Read data passed by a parent appPoe.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 feedbackcreateVerticalScrollBounceMount()— 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 iframesnotifyActivity()— Notify the manager of activity (preview, unread)addInstanceToRoom()— 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:
javascript
import { createPoe, PostMessageEnvironment, registerPoeAppElement } from "poe-tiles-sdk/v1/client.js";
const environment = new PostMessageEnvironment();
const Poe = createPoe({ environment });
// Only needed if your app uses <poe-app> to embed other apps
registerPoeAppElement(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).
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 new instance opened with is-new) unblock immediately. Don't use waitForServerData() here — it always waits for a server pull, which stalls offline launches and brand-new is-new instances that have no server state to fetch. Reserve waitForServerData() for tests and Node-side scripts.
Poe.store
A SyncedStoreClient reference. Available after calling setupStore().
Poe.stream()
Stream a response from a Poe bot. Returns an async iterator that yields partial message chunks as they arrive.
javascript
for await (const chunk of Poe.stream({
botName: "Claude-3.5-Sonnet",
prompts: "What is the capital of France?",
})) {
console.log(chunk.text);
}prompts can be a string, a single message object { role, content }, or an array of either.
Poe Employee Note
Poe.stream() is not thoroughly tested outside of unit tests and may have rough edges.
Poe.call()
Call a bot with automatic tool execution. Like stream(), but runs an agentic loop — when the bot emits tool calls, they are executed automatically and the results are fed back until the bot produces a final response.
javascript
const weatherTool = Poe.createTool({
name: "get_weather",
description: "Get current weather for a city",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
run: async ({ city }) => `Weather in ${city}: 72F sunny`,
});
for await (const event of Poe.call({
botName: "GPT-4o",
prompts: "What's the weather in Tokyo?",
tools: [weatherTool],
maxIterations: 10, // default
})) {
console.log(event.text);
}Poe Employee Note
Poe.call() is not thoroughly tested outside of unit tests and may have rough edges.
Poe.createTool()
Create an executable tool definition for use with Poe.call(). Tools define a JSON Schema for their parameters and a run function that returns a string result.
javascript
const calculator = Poe.createTool({
name: "calculate",
description: "Evaluate a math expression",
parameters: {
type: "object",
properties: { expression: { type: "string" } },
required: ["expression"],
},
run: async ({ expression }) => String(eval(expression)),
});Poe Employee Note
Poe.createTool() is not thoroughly tested outside of unit tests and may have rough edges.
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-3.5-Sonnet", "GPT-4o", ...]Use this to discover valid bot names for Poe.stream() / Poe.call(), or to build a model picker UI.
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 in the host for 5 minutes.
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-app 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.open({ typeId, instanceId, openProps?, isNew?, 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-app>).
javascript
// Open another app, passing data via openProps
await Poe.open({
typeId: "my-game",
instanceId: "lobby-123",
openProps: { inviteCode: "abc123" },
});
// Mark a freshly-created instance (e.g. when branching a chat) so the
// opened app can distinguish a new instance from revisits.
await Poe.open({
typeId: "chat",
instanceId: "branch-abc",
openProps: { branch: { messages: [...] } },
isNew: true,
});
// 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 mobile 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()) |
isNew | boolean | No | Pass true whenever the instanceId you're opening has never existed before (freshly-minted invite, new game lobby, etc.). The root forwards it as is-new on the <poe-app> element — see <poe-app> Attributes for the offline / latency wins this unlocks. Defaults to false. |
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. desktop manager); roots / form factors without split-view support ignore the hint and behave like "current". |
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-app> 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.
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.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 username hint for hosts that can navigate by handle |
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 invite picker 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) and scopes the share link + contacts picker to the caller's (storeTypeId, instanceId). Room-aware scoping (using the caller's $$system:room roster instead of the instance's) is a follow-up.
javascript
await Poe.room.openInvitePicker();The call resolves after the host accepts the request to show the picker; it does not return the selected contacts or whether an invite was sent. Calling from the root app is rejected — the root app owns the invite UI directly and does not need an RPC hop.
Poe.track(event, properties?)
Send a fire-and-forget analytics event through the host-owned analytics pipeline.
javascript
Poe.track("app_opened", { appType: "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-app> Custom Element
html
<poe-app type-id="my-game" instance-id="lobby-123"></poe-app>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-app>, register it in your entry file:
javascript
import { registerPoeAppElement } from "poe-tiles-sdk/v1/client.js";
registerPoeAppElement(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(). |
is-new | boolean | No | Performance hint. When present, the platform skips reading cached data from IndexedDB (since a new instance has none) and enables the client to assume it is the store creator (optimistic clientOrdinal 0 for offline ID generation). Always set is-new whenever you mount a <poe-app> with an instance-id that has never existed before (e.g. you just generated it for a fresh feed card, a freshly-created game, an invite-time instance). Omitting it on a brand-new instance is a missed offline / latency win — the client wastes work probing IndexedDB for state that can't be there and falls back to a server round-trip for clientOrdinal. Don't set it when revisiting a known instance. |
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-app> 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-app> have no effect. (max 5,000 characters) |
opener-store-instance-id | string | No | Pairs with opener-store-type-id. (max 5,000 characters) |
Host Visibility
If a parent keeps a child <poe-app> mounted while covering it with parent chrome, set the element's JS hostVisible property. This is separate from browser page visibility: the child iframe's document.visibilityState can still be "visible" while the host has hidden it behind a switcher, drawer, or modal.
javascript
import { setChildPoeAppHostVisible } from "poe-tiles-sdk/v1/client.js";
setChildPoeAppHostVisible(childPoeAppElement, false); // covered by host UI
setChildPoeAppHostVisible(childPoeAppElement, true); // visible againChild apps that need "user has actually seen this" behavior can check and subscribe to host visibility:
javascript
import {
isPoeAppHostVisible,
subscribePoeAppHostVisibility,
} from "poe-tiles-sdk/v1/client.js";
if (document.visibilityState === "visible" && isPoeAppHostVisible()) {
// Safe to count visible content as seen.
}
const unsubscribe = subscribePoeAppHostVisibility((visible) => {
console.log("Host visibility changed:", visible);
});Apps with foreground-only work, such as animation loops, physics, polling, audio, or WebGL rendering, should use the foreground helper. It combines host visibility with document.visibilityState, so the app pauses when either the browser tab is hidden or the host covers the iframe with platform chrome:
javascript
import {
isPoeAppForeground,
subscribePoeAppForegroundState,
} from "poe-tiles-sdk/v1/client.js";
let foreground = isPoeAppForeground();
let raf = 0;
const unsubscribe = subscribePoeAppForegroundState((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-app type-id="chat" instance-id="parent-123-my-chat-1"></poe-app>
<!-- With open props (HTML attribute) -->
<poe-app type-id="chat" instance-id="parent-123-my-chat-1" open-props='{"theme":"dark"}'></poe-app>jsx
// React — construct instance-id from parent's instanceId + child handle
<poe-app
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-app");
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-app> in TypeScript React projects, add a type declaration:
typescript
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"poe-app": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
"type-id": string;
"instance-id": string;
"open-props"?: string;
"is-new"?: boolean | 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-app>. 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.parent
The parent store's identity, available for child apps opened via <poe-app> 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 undefinedPoe apps 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 /room-invite/ URL.
const path = `/room-invite/${roomId}/${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.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 in the Poe native app | UIImpactFeedbackGenerator / UINotificationFeedbackGenerator / UISelectionFeedbackGenerator via a JS-bridge call. Best fidelity. |
| Android (Poe native app and mobile web — same code path) | navigator.vibrate with a fixed duration per style. The Poe 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,
addInstanceToRoom,
} from "poe-tiles-sdk/v1/client.js";notifyActivity()
Notify the manager of activity in this app instance. Four independent dials — preview (sidebar text + bumps the space in recents), 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). Apps that want standard unread behavior should opt into simpleUnread({ clearOn: "active" }) in their schema and client config. Pick the right combination for the kind of activity, 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",
push: {
title: senderName,
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. Bumps the space in the recents list. |
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. Requires simpleUnread({ clearOn: "active" }); omit it for preview / sort-order updates that should not grow the badge. Sender-suppression always applies — a user's own activity never increments their own count. |
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 | Notification title (typically sender name or app name). |
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. |
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. Reusing a messageId from the same source app instance is idempotent; reusing one owned by another app or a human message is rejected by the chat receiver. |
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.
Simple unread clearing is app-owned. With simpleUnread({ clearOn: "active" }), the SDK stores a per-user private unread projection and clears it when the host reports that the user is active in the app. Apps with custom read semantics can call setUnreadCount(ctx, { count }) from their own mutators.
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 | "It's your turn to play" — sent to the player whose turn it is |
| 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 |
| Cosmetic refresh / sort bump | omit | no | Importing your own old messages, todo edits — preview/sort updates without growing the badge |
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 cosmetic/non-content updates. Bumpingpreviewand sort order without growing the badge. If a player would expect a number to change, use"increment". - Always set
targetUserIdsdeliberately. Omitting it fans out to every active member, which is rarely what you want for apush. For "your turn" notifications, target only the next player. For "friend beat your score", target only the previous record-holder. Default sender-suppression handles "don't push the user who triggered the action" for you — you do not need to filter them out oftargetUserIds. - 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 (e.g. codenames): push the next player on phase change
const nextPlayer = computeNextPlayer(state);
await notifyActivity(ctx, {
preview: `It's ${currentPlayerName}'s turn`,
previewTimestamp: Date.now(),
unread: "increment",
targetUserIds: [nextPlayer.userId],
push: { title: gameTitle, body: "It's your turn" },
});
// 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: { title: playerName, body: `Beat your high score: ${score}` },
});
}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 addUser / 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-app 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),
);Platform Helpers
Small utilities for detecting or configuring the runtime environment. Safe to call from any Poe app entry.
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 entry.tsx, 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
}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 Poe native 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.
}applyVirtualKeyboardOverlayHint()
Best-effort hint to Chromium to opt out of automatic visual-viewport resize and focused-input auto-scroll when the virtual keyboard opens. Call once at app startup (e.g. in entry.tsx) — safe on every platform; no-op outside Chromium (Safari, Firefox) and on any platform without a virtual keyboard.
Inside the Poe Android app the native host already pads the WebView by the ime() window inset only; system safe areas stay web-owned through CSS env(safe-area-inset-*). This hint mainly helps reduce focus-scroll jumps in other Chromium contexts. See docs/mobile-webview-best-practices.md "Keyboard and Virtual Keyboard" for the full keyboard-handling model.
typescript
import { applyVirtualKeyboardOverlayHint } from "poe-tiles-sdk/v1/client.js";
applyVirtualKeyboardOverlayHint();applyNativeAppGestureOverrides()
Suppress default browser gestures (text selection, the iOS callout menu, and native link-drag) inside the Poe 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 entry.tsx).
Inputs, textareas, and contenteditable elements are opted back in so users can still select and copy text they've typed.
No-op outside the Poe 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();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> }