Service apps
Last updated 2026-09-21
A service app ships a binary that the box runs. The box supervises one process per (user, app),
under the app's own uid, inside resource limits, with no network listener of its own: it speaks
HTTP over a unix socket, and the box proxies /apps/<id>/svc/* to it with the caller's identity
already verified.
This is where an app's data and background work belong. The panel or the companion app is the face.
Declare it
{
"id": "dev.jan.notes",
"name": "Notes",
"version": "0.2.0",
"entry": "index.html",
"permissions": ["user:profile", "service:call", "files:write"],
"server": {
"bin": "svc",
"args": [],
"healthPath": "/healthz",
"limits": { "memory": "256M", "cpu": 50, "pids": 64 }
}
}
binis a bundle-relative path to a 64-bit arm64 ELF. Bothplum-dev validateand the store reject anything else.healthPathdefaults to/healthz. The box marks the service ready when it answers with a status below 500.limitsare optional. Defaults: 256 MiB memory, 50 % of one core, 64 pids. The box clamps what you declare into 32 MiB–1 GiB, 5 %–200 %, 8–256 pids. (cpu: 100is one full core.)
Build it
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o ../svc .
plum-dev init <name> --template server-go writes exactly that as build.sh, along with a
working server/main.go. Run plum-dev validate before pushing; it parses the ELF header and
tells you if you built for the wrong architecture.
The runtime contract
The box starts your binary with these environment variables:
| Variable | Meaning |
|---|---|
PLUM_APP_SOCKET |
the unix socket to listen on — this is what /apps/<id>/svc/* reaches |
PLUM_CTL_SOCKET |
the control socket back into the box |
PLUM_APP_DATA_DIR |
your writable, persistent directory (also the working directory and HOME) |
PLUM_APP_ID, PLUM_USER_ID, PLUM_APP_VERSION |
the execution context |
The bundle directory is read-only. PLUM_APP_DATA_DIR survives updates. Your process runs as the
app's own uid, so it cannot read the user's files directly — everything user-facing goes through
the control socket.
A minimal service:
package main
import (
"log"
"net"
"net/http"
"os"
"github.com/plum-networks/plum-sdk/plumsvc"
)
func main() {
log.SetFlags(0) // the box timestamps every line
sock := plumsvc.ServeSocket()
if sock == "" {
log.Fatal("PLUM_APP_SOCKET not set: this binary is started by the Plum supervisor")
}
_ = os.Remove(sock) // a stale socket from an unclean stop
ln, err := net.Listen("unix", sock)
if err != nil {
log.Fatalf("listen %s: %v", sock, err)
}
defer ln.Close()
ctl, err := plumsvc.New() // wraps PLUM_CTL_SOCKET
if err != nil {
log.Fatalf("control socket: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
mux.HandleFunc("/whoami", func(w http.ResponseWriter, r *http.Request) {
c := plumsvc.CallerOf(r) // X-Plum-* headers, injected by the box
if !c.Has("user:profile") {
http.Error(w, "need user:profile", http.StatusForbidden)
return
}
p, err := ctl.User(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
_, _ = w.Write([]byte(c.UserID + " " + p.DisplayName))
})
log.Printf("listening on %s", sock)
if err := (&http.Server{Handler: mux}).Serve(ln); err != nil {
log.Fatalf("serve: %v", err)
}
}
go get github.com/plum-networks/plum-sdk/plumsvc — the module is stdlib-only.
Who is calling: the injected headers
The box strips every inbound X-Plum-*, X-Forwarded-*, Forwarded, X-Real-Ip and Cookie
header, then sets its own. They cannot be forged.
| Header | Value |
|---|---|
X-Plum-User-Id |
the authenticated user |
X-Plum-Username |
their username |
X-Plum-App-Id |
your app id |
X-Plum-Perms |
JSON array of your app's effective permissions, sorted |
X-Plum-Entitlements |
comma-separated active SKUs (always present, often empty) |
X-Plum-Entitlements-Stale |
1 only when the receipts are older than 48 hours |
plumsvc.CallerOf(r) parses all of these into a Caller{UserID, Username, AppID, Perms, Mount}
with a Has(perm) helper.
The control socket
PLUM_CTL_SOCKET is a unix socket owned by your uid, mode 0600 — ownership is the authentication,
there is no token. It serves exactly seven routes:
| Route | Permission | What |
|---|---|---|
POST /files/publish |
files:write |
move a finished file out of your data dir into the user's storage ({"root":"files"\|"downloads","src","dest"}); a rename, so gigabytes cost nothing |
GET /files/list?root=&path= |
files:read |
list the user's files ({"entries":[{name,path,is_dir,size,modified}]}) |
GET /files/read?root=&path= |
files:read |
stream one file's bytes |
GET /user |
user:profile |
{"id","username","display_name","locale","timezone"} |
POST /notify |
— | raise a notification for the user ({"kind","title","body"}; title ≤ 200, body ≤ 2000) |
POST /events |
— | publish an app event ({"payload": …}); the box tags it apps:<your id> and every subscriber to /api/events sees it |
GET /entitlement |
— | {"skus":[{sku,kind,expires_at,active}],"refreshed_at","stale"} |
Errors come back as {"error": "<message>"}. A box that does not implement a route answers 501,
which plumsvc turns into ErrNotImplemented.
With plumsvc:
ctl.Publish(plumsvc.Downloads, "staging/report.pdf", "report.pdf") // files:write
entries, _ := ctl.Files(ctx, plumsvc.Files, "Documents") // files:read
rc, _ := ctl.Read(ctx, plumsvc.Files, "Documents/report.pdf") // files:read
ctl.Notify(ctx, "done", "Export finished", "report.pdf is in Downloads")
ctl.PublishEvent(ctx, map[string]any{"type": "progress", "pct": 42})
skus, _ := ctl.Entitlements(ctx)
Never move big files through the browser: write them into PLUM_APP_DATA_DIR and Publish them.
Being called
/apps/<id>/svc/<path> reaches your socket with the path after /svc intact and every HTTP
method allowed. Three kinds of caller are accepted:
- A browser session — the panel calling
plum.service.fetch('/list'). - A personal access token —
Authorization: Bearer plum_pat_…, which must carry the exact scopeservice:call:<your app id>.read,writeandadmindo not imply it. - Basic auth —
Authorization: Basic base64(<username>:plum_pat_…), for tools that only speak Basic. The username must be the token's owner.
Failures from the proxy, before your code runs:
| Status | Body | Meaning |
|---|---|---|
| 401 | {"error":"unauthorized"} |
no credentials (with WWW-Authenticate: Bearer …, Basic …) |
| 403 | {"error":"token lacks service:call:<id>"} |
wrong scope |
| 403 | {"error":"app lacks service:call permission"} |
the manifest does not declare it |
| 404 | {"error":"app not installed"} |
no such app for this user |
| 503 | {"error":"app backend unavailable"} |
your process is not up |
Watching it run
plum-dev status dev.jan.notes # state, restarts, ready, limits, memory_current, oom_kills
plum-dev logs dev.jan.notes -f # stdout/stderr, live
plum-dev restart dev.jan.notes
status reports state as starting, ready, crashed, give_up, start_timeout or
stopped, and reports the limits actually enforced (with a reason when they are not — an older
box, or PLUMBOX_APP_CGROUPS=0). Logs are a per-process ring buffer; GET /api/apps/{id}/logs
returns a snapshot and ?follow=1 streams it as SSE. A token needs the apps:dev scope for logs.
Local development
go build -tags dev -o ./svc-dev ./server && ./svc-dev # listens on TCP
plum-dev serve --service http://127.0.0.1:8080
plum-dev serve proxies /apps/<id>/svc/* to your local process and injects the same identity
headers (with X-Plum-User-Id: dev-user), so the panel and the backend can be developed together
with no box. The server-go template ships the //go:build dev file that makes this possible.
When you want the real sandbox — real uid, real cgroups, real control socket — use the
emulator or push to your own box.