Manifest reference
Last updated 2026-09-21
manifest.json sits at the root of the .plu and declares what the app is and what it may do.
The box parses it (internal/apps/manifest.go), the store validates it on upload, and
plum-dev validate checks the same rules locally against
manifest.schema.json.
At most 32 KiB. Unknown keys are preserved, not rejected.
Fields
| Field | Type | Required | Rules |
|---|---|---|---|
id |
string | yes | ^[a-z0-9][a-z0-9._-]{0,127}$, and inside a namespace you own |
name |
string | yes | 1–80 characters |
version |
string | yes | non-empty, 1–40 characters. The box does not parse it; the store requires each upload to be higher than the last (dotted numeric comparison), so use semver |
entry |
string | no | HTML entry point, default index.html; must exist in the bundle |
icon |
string | no | bundle-relative image; must exist in the bundle |
description |
string | no | shown in listings |
mimeTypes |
string[] | no | file types this app opens (see below); not validated |
permissions |
string[] | no | any of files:read, files:write, user:profile, service:call; unique |
mobile |
boolean | no | default false. true shows the app in the Plum mobile app's launchpad, full-screen. Leave it off for apps that assume a pointer and a wide viewport |
server |
object | no | a box-side service — see Service apps |
clients |
object[] | no | companion OAuth clients — see Companion apps |
author |
string | no | not part of the schema, but the box passes it through to GET /api/apps/overview |
protocols |
object[] | no | standard-protocol mounts your service answers — core 1.11+ |
protocols[] (core 1.11+)
A service app can answer WebDAV, CalDAV or CardDAV on a path of its own, and the box proxies
that path to it unchanged. Each entry is {"type": "webdav" | "caldav" | "carddav", "mount":
"/dav/<your app id>/"}, optionally with one extra segment: /dav/com.acme.notes/calendar/.
At most four entries, no repeats. The app id is the namespace, so no app can claim another's
mount or the box's own /dav/files/. It requires server and the service:call permission —
a mount with nothing behind it is just a 503 generator.
Requests arrive with the path untouched (so <D:href> and Destination: line up) plus a
X-Plum-Mount header telling you the prefix to build hrefs from. Authentication is a personal
access token scoped service:call:<your app id>, sent as a Bearer token or as the password in
HTTP Basic; a session cookie is never accepted on a mount. See
Service apps.
Not accepted today: pricing. It appears in the platform design but not in the shipped
core; paid features are read-only entitlements, not a manifest field.
permissions
Four strings, nothing else:
| Permission | Grants |
|---|---|
files:read |
read the user's files through handles, the picker, and the control socket |
files:write |
write and publish files |
user:profile |
plum.user.current() and the control socket's /user |
service:call |
the app's own panel may call its own backend at /apps/<id>/svc/* |
Permissions are declared here and can be switched off by the user per app; the box enforces the
effective set (GET /api/apps/{id}/permissions). A panel that calls something it did not declare
gets PermissionDeniedError.
For OAuth, the same words appear as scopes, with service:call:<app_id> naming the app
explicitly. read and write are accepted as aliases (write implies both read and write).
mimeTypes
Extensions (".md" or "md"), exact MIME types ("text/markdown") and wildcards
("image/*") all work. Declaring them puts the app in Drive's Open with menu; the box then
launches it with a file handle, which the app reads with plum.files.launchFile().
server
"server": {
"bin": "svc",
"args": [],
"healthPath": "/healthz",
"limits": { "memory": "256M", "cpu": 50, "pids": 64 }
}
| Field | Rules |
|---|---|
bin |
required; a safe relative path inside the bundle; 64-bit arm64 ELF |
args |
optional argv |
healthPath |
default /healthz; a status below 500 means ready |
limits.memory |
256M, 1G, 512MiB… Default 256 MiB, clamped to 32 MiB–1 GiB |
limits.cpu |
percent of one core (100 = one core). Default 50, clamped to 5–200 |
limits.pids |
Default 64, clamped to 8–256 |
The manifest schema accepts cpu up to 400 and pids up to 1024, but the box clamps to the
values above, so declaring more does not get you more.
clients
Up to 10 entries. Each registers a native client that may ask a box for delegated access under your app id:
"clients": [{
"client_id": "dev.jan.notes:ios",
"display_name": "Notes for iPhone",
"platform": "ios",
"redirect_uris": ["notes://auth/callback", "http://127.0.0.1/callback"],
"scopes_allowed": ["files:read", "files:write", "service:call:dev.jan.notes"]
}]
| Field | Rules |
|---|---|
client_id |
<your manifest id>:<label>, label ^[a-z0-9-]{1,32}$ |
display_name |
1–80 characters; this is what the consent screen shows |
platform |
ios, android, macos, windows, linux, web or cli |
redirect_uris |
1–8 entries, each ≤ 512 characters. A custom scheme, or http loopback (127.0.0.1, ::1, localhost, any port). https is refused |
scopes_allowed |
1–8 entries from files:read, files:write, user:profile, service:call:<your app id> — naming another app's service is an error |
The store registers manifest clients on upload; a paired box also honours them straight from the installed manifest, so you can test a companion app before publishing anything.
A complete example
{
"id": "dev.jan.notes",
"name": "Notes",
"version": "1.2.0",
"entry": "index.html",
"icon": "icon.png",
"description": "Plain-text notes that stay on your box.",
"author": "Jan",
"mobile": true,
"mimeTypes": [".md", "text/markdown"],
"permissions": ["files:read", "files:write", "user:profile", "service:call"],
"server": {
"bin": "svc",
"healthPath": "/healthz",
"limits": { "memory": "128M", "cpu": 50, "pids": 32 }
},
"clients": [{
"client_id": "dev.jan.notes:ios",
"display_name": "Notes for iPhone",
"platform": "ios",
"redirect_uris": ["notes://auth/callback"],
"scopes_allowed": ["files:read", "files:write", "service:call:dev.jan.notes"]
}]
}
Bundle limits
The .plu itself is checked too: at most 2000 files, 32 MiB per file, 200 MiB
uncompressed, and 50 MiB for the archive when installing directly on a box (the store accepts up
to 64 MiB). No symlinks, no device nodes, no absolute paths, no ... plum-dev validate reports
all of it before you upload.