Panel SDK

Last updated 2026-09-21

A panel is a normal web page the box serves at /apps/<id>/. The box injects one script tag and a small prelude; everything else is your app.

<script src="/apps/runtime/plum-sdk.js"></script>
<script type="module">
  const me = await plum.user.current();
</script>

(plum-sdk.js itself is a classic script and defines window.plum; your own code needs type="module" only if you want top-level await.)

There is one copy of plum-sdk.js per box, so every app on a device runs the same version. You do not bundle it, and you do not pin it. plum-dev serve substitutes a mock with the same API so you can work with no box.

The prelude

Before your page runs, the box inserts:

window.__PLUM_APP__ = { id, perms, version, token, dev };

perms is what the manifest declared, token is a short-lived app token (HMAC, 24 hours), and dev is true for a directly installed developer build. The SDK reads all of this for you: it sends X-Plum-App-Id and X-Plum-App-Token on handle and picker calls so the box can tell which app is asking, not just which user. You never touch the token.

A developer build is served with Cache-Control: no-store, and the SDK reloads the page when the box publishes apps:<id> {"type":"installed"} — so plum-dev push --watch refreshes the browser for you.

Namespaces

window.plum has nine: files, user, app, service, collab, events, ui, entitlement, photos.

plum.files — the user's storage

openPicker(opts?: { accept?: string | string[]; multiple?: boolean }): Promise<FileHandle | FileHandle[] | null>
saveAsPicker(opts: { defaultName: string }): Promise<FileHandle | null>
launchFile(): Promise<FileHandle | null>      // the file the app was opened with
readBytes(handle): Promise<Uint8Array>        // files:read
writeBytes(handle, bytes): Promise<void>      // files:write
stat(handle): Promise<{ name, size, mtime }>
url(handle): string                           // streaming URL for <video>/<img>/Range fetch

A FileHandle is {id, name} — opaque, valid for the page session only. Do not store the id. Use url(handle) for media instead of pulling whole files into memory.

plum.user

current(): Promise<{ id, username, email, displayName }>   // user:profile

plum.app

host(): Promise<{ deviceName, coreVersion, osVersion, apiLevel }>
capabilities(): Promise<{ sdk: "0.2", apiLevel, native, ui }>
open(appId, path?): Promise<void>       // switch to another app; not installed -> its store page
theme(): "light" | "dark"; onThemeChange(fn): () => void
locale(): string;        onLocaleChange(fn): () => void
setDocumentState({ name?, state? }): void   // what the shell shows in its title bar

plum.service — your own backend (service:call)

fetch(path, init?): Promise<Response>   // -> /apps/<id>/svc<path>, box session attached
url(path): string                       // for <img src>, EventSource, streaming fetch

See Service apps.

plum.events — changes on the box

subscribe(kinds, cb): () => void        // kinds: "drive" | "photos" | "notification" | "apps:<id>" | "apps:*"

An EventSource on GET /api/events under the hood. The callback gets {id, kind, at, payload}. If the box has lost track of how much you missed it sends kind: "reset" once — re-read rather than patch.

plum.ui — native where there is native, web where there is not

Call Native Browser fallback
share({text?, url?, handles?}) system share sheet Web Share, UnsupportedError if absent
clipboard.write(text) / .read() native clipboard navigator.clipboard
capture({mode: "photo" \| "video"}) camera, saved to Drive Camera/, returns a handle <input capture> + save picker
haptic(style) haptics navigator.vibrate, else ignored
openExternal(url) system browser (http(s) only) window.open(url, "_blank", "noopener")
biometric.confirm(reason) Face ID / fingerprint UnsupportedError
nav.setBackHandler(fn \| null) the app receives Back; returns true returns false, browser Back unchanged
nav.close() closes the app screen asks the shell to close

Ask before you assume:

const caps = await plum.app.capabilities();
if (caps.ui.capture !== "none") {            // "native" | "web" | "none"
  const photo = await plum.ui.capture({ mode: "photo" });
  await plum.ui.share({ text: "today", handles: [photo] });
}

plum.photos

pick(opts?: { multiple?: boolean }): Promise<FileHandle | FileHandle[] | null>   // files:read

The same picker as files.openPicker, opened on its Photos tab.

plum.entitlement

get(): Promise<{ skus: {sku, kind, expires_at, active}[], refreshed_at, stale }>
refresh(): Promise<number>    // ask the box to refetch now; returns the receipt count

stale means the box has not refreshed receipts for 48 hours — show a quiet notice, do not lock the user out. What to lock is your decision; the box only proves what was bought. Payment happens in the box UI or on the web, never inside the app.

plum.collab — a relay between instances of your app

join(room); leave(room); publish(room, message); subscribe(room, fn): () => void

The box forwards messages between instances in the same room and neither reads nor stores them. Rooms are scoped to your app id but shared between the box's users, so do not put secrets in a room name or a message. Limits: 4 MB per message, 16 rooms per socket, no history.

Errors

PermissionDeniedError, FileNotFoundError, QuotaExceededError, NetworkError, UnsupportedError (this host cannot do it) and CancelledError (the user dismissed a sheet). All extend Error and carry a code.

Launch URL parameters

The shell adds these to /apps/<id>/; the box does not parse them, the SDK does:

Param Meaning
theme=light\|dark initial theme; reaches you as plum.app.theme()
lang=<locale> initial locale; plum.app.locale()
handle=, name= Drive Open with — read them with plum.files.launchFile(), not by parsing
collabRoom= the room the host assigned; plum.collab.room()
shareToken= a share-link visitor; the SDK forwards it as X-Share-Token
mode=preview the app was opened to preview a file

Versioning

The host API level stays 1 and rises only when something disappears or changes meaning. Everything in SDK v0.2 was added, so detect by existence:

if (typeof plum.ui !== "undefined") { /* v0.2 host */ }
const caps = plum.app.capabilities ? await plum.app.capabilities() : null;

The native bridge (what plum.ui talks to)

Inside the Plum mobile app, plum.ui.* calls a JSON bridge. Apps do not call it directly — this is here so you know what the fallbacks are and how errors reach you.

  • Page → native: window.plumNative.postMessage(JSON.stringify({id, method, params})).
  • Native → page: window.__plumNativeReply(id, {ok, result|error}), exactly once per request; a request with no answer is rejected after 30 seconds with timeout.
  • Events: window.__plumNativeEvent(name, payload) — back, theme, locale, resume, pause.
  • Capability advertisement: window.__PLUM_NATIVE__ = {version, platform, capabilities[]}.
  • Methods: share, clipboard.write, clipboard.read, capture, haptic, openExternal, biometric.confirm, nav.setBackHandler, nav.close, nav.openApp, and push.subscribe (always answers unsupported until the push gateway ships).
  • Error codes: unsupported, invalid_params, cancelled, denied, unavailable, timeout, failed.

Two rules worth knowing: file bytes never cross the bridge (handles only), and the bridge exists only in the main frame of a box-origin page.