Skip to content

Sandbox restrictions ​

Tiles run in a blob-URL iframe sandboxed with allow-scripts allow-forms allow-downloads only. Same-origin, popups, and top-navigation are off, the iframe inherits the host page's Content Security Policy, and the host delegates no permissions-policy feature to it other than clipboard-write. Several browser APIs therefore throw, silently fail, or never fire:

  • Storage APIs blocked. localStorage, sessionStorage, IndexedDB, and cookies all throw — there is no allow-same-origin. Don't reach for localStorage to survive refresh; it won't work. Persist per-user state in a privateOfUser(self) table and shared state in a public table.
  • No requests to third-party hosts, from the client or the server. See Network access below. A tile cannot fetch() a third-party API, open a WebSocket to a creator-owned server, or load a script, font, or image from an arbitrary host. Outbound work goes through the platform APIs the SDK documents.
  • Motion and orientation sensors are gated, not blocked. See Device sensors below: tilt and shake work in the Joiner apps and on mobile web behind the browser's own permission where it has one, never on desktop, so touch controls come first.
  • No top-level URL navigation. window.location.href = …, location.assign, top-level window.open, target="_top", and cross-origin history.pushState all fail. To switch apps, call Poe.open({ typeId, instanceId, openProps? }) from poe-tiles-sdk. To open an outside web page, call Poe.openExternalUrl({ url }); window.open and <a target="_blank"> are blocked because the sandbox has no allow-popups.
  • window.location.origin is "null". Use Poe.topOrigin for an absolute host URL.
  • No cross-frame DOM access. Reading the parent document or any other frame is blocked. Talk to the host via the SDK's postMessage wrappers.
  • Web Workers work, but only from a blob or data URL. A worker script has to be embedded in your code, not loaded by path — there is no origin or server behind the iframe, so new Worker("./worker.js") and new Worker(new URL("./worker.ts", import.meta.url)) both fail (the latter with TypeError: Invalid URL, and poeTile() rejects it at build time). Bundled apps: import MyWorker from "./worker.ts?worker&inline" — the &inline matters, since a plain ?worker import compiles to a path nothing serves here (poeTile() rejects that at build time too). No-build apps: blob your worker source yourself, or new Worker(await Poe.getBundleAssetUrl("worker.js")). Recipes for both are in Vite Plugin → Web Workers. Use classic worker syntax; a module script cannot be fetched from a blob URL here. SharedWorker is denied to the opaque origin outright, and navigator.serviceWorker.register is blocked. A worker inherits the sandbox, so it has no storage or network of its own.

Network access ​

A tile cannot reach a host the creator chooses, anywhere its code runs. Direct access to creator-chosen external services is unsupported; use the documented platform APIs instead. This is a deliberate platform boundary, not a missing feature to work around.

Client code (the iframe). The host page's Content Security Policy applies to the tile frame, and connect-src allows only origins the platform itself names (its own pages, blob:, and a few path-scoped platform services). fetch(), XMLHttpRequest, navigator.sendBeacon, WebSocket, EventSource, cross-origin form posts, and <script>/<link>/<img> loads are all blocked by the browser when the target is a third-party host. fetch() still works against blob: URLs from Poe.getBundleAssetUrl() and against platform URLs such as store.files.url(...). It does not work against data: URLs, which includes a Vite ?url import inlined under assetsInlineLimit. media-src also omits data:, so <audio> and <video> cannot play one either. Use an inlined URL only for images, CSS, and fonts; for media or bytes, decode its base64 yourself (see assets.md). Bundle every asset the tile needs (see assets.md); the only remote images that load are platform-served ones such as $userInfo profile pictures and store.files.url(...) URLs.

Server code (mutators, actions, hooks). The backend runtime has no WebSocket or XMLHttpRequest, and fetch is a stub that throws fetch is unavailable in the QuickJS backend runtime. Use an explicit ctx.platform.call(...) capability instead. Server code uses ctx.platform.call(...) for platform services and synced-store's cross-store APIs to talk to other stores. Neither provides general-purpose HTTP access.

NeedUse
Call a language modelpoe.modelProxy.stream, or createModelStream(ctx.platform) from poe-tiles-sdk/v1/backend.js
Generate an imagepoe.modelProxy.generateImage / poe.modelProxy.generateHostedImage, or createImageGenerator(ctx.platform)
Talk to another tile's storectx.externalStore({ storeTypeId, instanceId }) for reads and, in actions, .action(name, input); ctx.mutateExternal(...) for writes from mutators (see external stores)
Read deployment settingsenv.get
Open a web page for the userPoe.openExternalUrl({ url }) from client code (the user confirms any destination outside the platform)

There is no fetch proxy, webhook, or allowlist for creator-chosen hosts. A tile that needs a live external data feed (weather, a scoreboard service, a creator's own backend) cannot be built on the platform today. Say so when a creator asks, and offer the closest platform-native design: keep the data in the synced store, bundle a snapshot or let players enter it, or let a model action produce fictional content or summarize data the tile already holds. Never present model output as live weather, scores, or other real-world facts.

Device sensors ​

The host delegates the accelerometer, gyroscope, and magnetometer permissions-policy features to every tile frame, so deviceorientation and devicemotion reach a tile in the Joiner apps and on mobile web. They remain gated: iOS requires a permission granted from a tap (the Joiner iOS app grants it for its own pages once its release with that grant ships; an older shell shows WebKit's own prompt once per app launch, and iOS Safari prompts per site), and desktop has no sensors at all. Read the shared device motion reference before designing input: touch first, tilt opt-in, and the recipe that treats every gated outcome as "no usable sensor". The apps' native shake-to-report stands down while an integrated tile holds Poe.motion.claim() (see client-api.md). Vibration feedback is available through Poe.haptics (see client-api.md).