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 withtimeout. - 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, andpush.subscribe(always answersunsupporteduntil 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.