Companion apps

Last updated 2026-09-21

A companion app is your own app — on iOS, Android, desktop or the web — that talks to a user's box as a registered OAuth client. Nothing of yours is installed on the box, except the service .plu you may also ship alongside it.

An app that only wants the box as storage, with no box-side code at all, is listed in the store as a compatible app: same registration, same scopes, no .plu.

1. Register the client

A client id is <app_id>:<label>, where the app id is yours and the label is [a-z0-9-]{1,32} — e.g. dev.jan.notes:ios. Register it in the console under Clients on the app page, or declare it in your manifest's clients[] and let the store pick it up on upload. A box refuses an unregistered client id outright: no registration, no consent screen.

Redirect URI rules, enforced on registration and again at authorization:

  • a custom scheme — notes://auth/callback;
  • http loopback — 127.0.0.1, ::1 or localhost, and the box matches any port, so you do not have to register the ephemeral one (RFC 8252 §7.3);
  • https is refused — by the box when it validates the request, and by the store for any client declared in a manifest. (The store has an admin-only allow_https_redirects flag, off by default, for future universal-link support; the shipped box does not honour https redirects either way, so do not build on it.)

Everything else must match the registered string exactly.

2. Scopes

One vocabulary, shared with manifest permissions:

Scope Grants
files:read read the user's files
files:write write them (implies read)
user:profile the signed-in user's profile
service:call:<app_id> call that app's service at /apps/<app_id>/svc/*

read and write are accepted aliases (write expands to files:read files:write). admin is not delegatable — a box rejects it in an OAuth request. A request may only ask for scopes inside the client's registered scopes_allowed, and service:call: may only name your own app.

3. The authorization flow

Authorization Code with PKCE (S256 — the box accepts no other method, and there are no refresh tokens).

  1. Build a verifier (43–128 characters), a challenge base64url(SHA-256(verifier)) and a random state.
  2. Send the user to the portal, which finds their box and hands the request to it: https://plumbox.me/authorize?response_type=code&client_id=…&redirect_uri=…&scope=…&code_challenge=…&code_challenge_method=S256&state=…. The box renders the consent screen at its own GET /authorize using the registered display name and icon — anything you pass in the query is ignored. A signed-out or stale session is bounced back to the portal to log in; an API-token session may not approve an app at all.
  3. The user approves; the box redirects to your redirect_uri with code and state. Codes are one-time and live 5 minutes.
  4. Exchange the code at the box, not the portal:
POST https://<box>/api/oauth/token
Content-Type: application/json

{"grant_type":"authorization_code","code":"…","code_verifier":"…",
 "client_id":"dev.jan.notes:ios","redirect_uri":"notes://auth/callback"}
{"access_token":"plum_pat_…","token_type":"bearer","scope":"files:read files:write"}

The access token is a personal access token scoped to what the user approved; it does not expire, and the user can revoke it in the box's token settings. Store it in the platform keystore.

Errors come back as {"error":"…","error_description":"…"} with unauthorized_client, invalid_scope, invalid_redirect_uri, invalid_grant, invalid_request, unsupported_grant_type, recent_authentication_required or server_error.

4. Calling the box

import { PlumClient, beginAuthorization, exchangeCode } from "@plumbox/client";

const req = await beginAuthorization({
  clientId: "dev.jan.notes:cli",
  redirectUri: "http://127.0.0.1:53682/callback",
  scopes: ["files:read", "files:write", "service:call:dev.jan.notes"],
});
open(req.url);                                   // browser / Custom Tabs / ASWebAuthenticationSession
// …receive the callback, check `state` yourself…
const { accessToken } = await exchangeCode({
  baseUrl: "https://pb-1234.plumbox.me",
  code, codeVerifier: req.codeVerifier,
  clientId: "dev.jan.notes:cli",
  redirectUri: "http://127.0.0.1:53682/callback",
});

const box = new PlumClient({ baseUrl: "https://pb-1234.plumbox.me", token: accessToken });
const me = await box.auth.me();                          // GET /api/auth/me
const page = await box.drive.list("Documents");          // GET /api/drive/list
const ent = await box.apps.entitlement("dev.jan.notes"); // GET /api/apps/{id}/entitlement

@plumbox/client 0.2.0 also has parseCallback(url), drive.upload/download/mkdir/move/ remove/trash/versions, auth.createToken/listTokens/revokeToken/logout, and apps.status/ensureServiceInstalled/refreshEntitlements. It is ESM+CJS, Node 18+, zero runtime dependencies. Install it with npm i @plumbox/client.

5. Native facades

The Plum apps use two in-repo facades that mirror this flow exactly:

  • Android — im.plum.plumconnect: PlumConnect.discover/unlock/connect, BoxClient (login, verifyTotp, me, createToken, logout, reachability, oauth), and OAuthClient (begin, parseCallback, exchange). It is deliberately UI-free: your app opens the authorization URL itself.
  • iOS — PlumConnect, BoxClient and OAuthFlow under PlumBoxApp/Core/Connect. OAuthFlow additionally has authorize(...), which drives ASWebAuthenticationSession and checks state.

Neither is distributed yet — there is no Maven coordinate and no Swift package, and the iOS types are internal to the app target. Extracting them (im.plum:plumconnect, a PlumConnect Swift package) is planned. Until then, use @plumbox/client or the HTTP endpoints above; the wire format is identical on all three.

6. Finding the user's box

Simplest and most reliable: ask the user for their box address (https://pb-1234.plumbox.me, or a LAN address). @plumbox/client also ships a relay lookup, and @plumbox/oprf resolves boxes from an email and password without the relay learning either — that path is what the Plum apps use.

7. Entitlements

GET /api/apps/dev.jan.notes/entitlement
Authorization: Bearer plum_pat_…
{"skus":[{"sku":"pro","kind":"one_time","expires_at":"","active":true}],
 "refreshed_at":"2026-09-20T22:14:03Z","stale":false}

A token may read this with service:call:<app_id> (or a broader read scope). stale means the box has not reached the store in 48 hours; keep working and say so quietly. See Publishing for how SKUs and grants are set up.

8. Get listed

  • An app with a .plu: publish it, and the clients you registered ride along.
  • An app with no .plu: register a compatible app with its id, display name and a link, then add its clients. Boxes show these on their own shelf (GET /api/apps/store/compatible), separate from installable apps.