Apps you build
Small web apps your organization writes for itself, running on your Peractor host and opening on every device.
An app is a small web page your organization builds for itself — a team chat, a retro board, a poll, a dashboard. Its code lives in a git repository, an admin deploys it, and everyone it is shared with opens it from the Apps list on every device.
Nobody signs in to it. Whoever opens it is already themselves, and the app is told who they are.
Three facts shape everything else on this page:
- It runs on your Peractor host, in its own container, on its own web origin. It is not a hosted third-party service; it is yours, built from your repository.
- It can reach nothing. The container has no route to the internet or to your network. The only thing that can talk to it is Peractor, forwarding requests from the people who opened it. An app cannot leak what it is told, so it can be trusted with who is in the room.
- Identity comes from Peractor, not from the app. Every forwarded request carries the member behind it in headers the browser cannot forge. The app never sees a password, a session token, or an email address.
Declare one
From Apps, an admin picks New app and fills a short form. The app belongs to the organization, not to any one project.
| Field | Meaning |
|---|---|
name | What everyone sees in their Apps list. Renaming is safe. |
description | Optional. One short line saying what it is for. |
icon, color | Optional, and rolled at random for a new app so a list is scannable. |
project | The project whose repository holds this app's code, when you borrow one. Deleting that project leaves the app alone, running whatever it last built. |
repo | The other answer: a repository the app names itself, cloned with your organization's git credentials. For a team whose apps live in one shared repository. |
path | The folder inside that repository
that builds the app, like apps/chat. The app must not need
anything outside it. |
build | Optional. Runs once per deploy,
inside the folder, with network. npm ci is typical. |
start | Starts the app's server. It must
listen on $PORT within a minute. A static app can use
serve -s . -l $PORT. |
data | The directory (default
/data) whose contents survive every redeploy and rollback.
Everything else is replaced by the next deploy. |
visibility | everyone (the
default), admins, or a listed set of members. Admins
always see every app. |
Writing the app
The declaration says what the app is; the code is a normal coding task in whichever repository builds it. Write it by hand, or file a task describing the app and point it at this guide. When the code is on the branch, press Deploy.
The contract
This section is for whoever writes the app — a person or an agent. An
app is an ordinary web server plus four rules: listen on
$PORT, take identity from the request headers, keep durable
state under $PERACTOR_DATA_DIR, and expect no network.
Environment
The container's environment is exactly this. No secret of any kind reaches an app.
| Variable | Value |
|---|---|
PORT | The port to listen on, on all
interfaces (0.0.0.0, not just localhost). |
PERACTOR_DATA_DIR | The data directory, mounted. Files here survive redeploys; files anywhere else do not. |
PERACTOR_APP_ID, PERACTOR_APP_KEY,
PERACTOR_ORG_ID | Who the app is. |
HOME=/tmp,
NODE_ENV=production | As every Peractor container gets. |
Who is asking
Peractor ends the member's session itself and stamps these headers on every request and WebSocket upgrade it forwards — after stripping whatever the browser sent under the same names, so they cannot be forged:
| Header | Value |
|---|---|
x-peractor-member-id | The member's stable id. Use it as the author key for anything you store. |
x-peractor-member-name | Display name, percent-encoded. Decode it before showing it. |
x-peractor-member-image | Avatar URL, or empty. |
x-peractor-member-role | admin
or member. |
x-peractor-org-id,
x-peractor-org-name | The organization. |
x-peractor-app-id | The declaration the request reached. |
x-peractor-platform,
x-peractor-theme,
x-peractor-locale | What the surface said when the app was opened. |
The one security rule: identity comes from these headers and nowhere else. Never take a user id or name from the request body, the query string, or a cookie — a page could send anything there. There is deliberately no email header: an app the organization installed has no business with addresses.
The frontend SDK
The page includes one script, first in the head:
<script src="/_peractor/sdk.js"></script>
It is served per session and blocks, so by the first line of your own
code window.peractor is there:
| Member | What it is |
|---|---|
peractor.me | The person looking at the page: id, name, image, role. |
peractor.org,
peractor.app | Id and name of each. |
peractor.theme | Scheme, platform, and locale. |
peractor.members() | A promise of the organization's members, for resolving stored ids to names. |
peractor.open(url) | Ask the surface to open an external link in the system browser. Plain links off the app's origin are caught and sent there too; nothing else can navigate away. |
peractor.on("sessionEnded", fn) | The app session expired. Show a sentence — the surface re-enters on the next open. |
Light and dark
Peractor tells the app which way to look, three times, so you can read
it wherever you happen to be drawing: as CSS custom properties on the
root element, as peractor.theme.scheme in JavaScript, and as
a header on every request.
Style with the variables and there is nothing else to do — no media query, no class to toggle. An app that hardcodes its own palette will be a white page on a dark phone, which is the one mistake this section exists to prevent.
body {
margin: 0;
background: var(--p-bg);
color: var(--p-fg);
font: 14px var(--p-font);
}
.card {
background: var(--p-surface);
border: 1px solid var(--p-border);
border-radius: var(--p-radius);
}
button.primary {
height: var(--p-control-h);
padding: var(--p-control-pad);
background: var(--p-accent);
color: var(--p-btn-fg);
border: 0;
border-radius: var(--p-radius);
}
The full set, all defined in both schemes:
| Group | Variables |
|---|---|
| Surfaces | --p-bg,
--p-surface, --p-chip,
--p-hover, --p-border,
--p-sash |
| Text | --p-fg, --p-fg-muted,
--p-fg-faint |
| Accents | --p-accent, --p-blue,
--p-green, --p-amber, --p-red,
--p-merge |
| Controls | --p-control-h,
--p-control-font, --p-control-pad,
--p-input-bg, --p-input-fg,
--p-input-border, --p-focus |
| Buttons and menus | --p-btn-fg,
--p-btn-hover, --p-btn-2-bg,
--p-btn-2-fg, --p-btn-2-hover,
--p-menu-bg, --p-menu-sel-bg,
--p-menu-sel-fg |
| Type and shape | --p-font,
--p-font-mono, --p-radius |
| Diffs | --p-diff-add,
--p-diff-del |
The scheme is settled when the app opens and does not change under a running page. Read it once at startup; there is no theme event to listen for.
Realtime
WebSocket upgrades are forwarded like any request, with the same identity headers, so a chat's server can hold sockets and broadcast.
Two habits make it robust: reconnect with backoff, because the app is restarted by deploys and stopped when idle; and treat the socket as a notifier while the truth lives in the data directory, so a reconnect can re-fetch what it missed.
No network
At runtime the container can reach nothing — no CDN, no package registry, no API on the internet, no other service on your host. The build step has network, so vendor everything then. Every script, stylesheet, font, and image the page uses must be served by the app itself.
A minimal server
Everything above, in the smallest app that exercises it — Node's own http and sqlite, both built in, so there is no build step:
const http = require("node:http");
const { DatabaseSync } = require("node:sqlite");
const fs = require("node:fs");
const db = new DatabaseSync(process.env.PERACTOR_DATA_DIR + "/app.db");
db.exec("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, " +
"member_id TEXT, member_name TEXT, text TEXT, at TEXT)");
function member(req) {
return {
id: req.headers["x-peractor-member-id"],
name: decodeURIComponent(req.headers["x-peractor-member-name"] || ""),
};
}
http.createServer((req, res) => {
if (req.url === "/notes" && req.method === "GET") {
const rows = db.prepare("SELECT * FROM notes ORDER BY id DESC LIMIT 50").all();
res.setHeader("content-type", "application/json");
return res.end(JSON.stringify(rows));
}
if (req.url === "/notes" && req.method === "POST") {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
const who = member(req); // identity from the headers, never the body
db.prepare("INSERT INTO notes (member_id, member_name, text, at) VALUES (?, ?, ?, ?)")
.run(who.id, who.name, String(JSON.parse(body).text || ""), new Date().toISOString());
res.end("{}");
});
return;
}
res.setHeader("content-type", "text/html");
res.end(fs.readFileSync("index.html"));
}).listen(process.env.PORT, "0.0.0.0");
A purely static app needs no server file at all — just
start: serve -s . -l $PORT.
Deploy, roll back, operate
Deploy, on the app's own screen, opens a sheet: type the branch to build — or pick it from the repository's own branches — and press Deploy. It builds the folder and switches the app over when the build is ready. People get the new code on their next page load; what the app stored is untouched.
Deployments is the screen behind that: every build, which one is serving, and what each build's log said. Opening one offers Make live — an instant rollback, because the last three builds keep their code, so nothing is rebuilt and nobody is signed out. Going back changes the code, not the data.
Stop the app stops it now; the next person to open it starts it again, a few seconds' wait. Nothing it stored is touched.
An app is started when somebody opens it and stopped after ten idle minutes, so the first open after a sleep takes a few seconds. Admins can read the running container's log, or the last crash's, from the same screen — it tails live and follows the newest line until you scroll away from it.
Two removals, deliberately different. Deleting the declaration stops the app being offered, but what it stored stays and comes back if the declaration is recreated under the same id. Remove app, on the app's own screen, also deletes every build and the storage volume. The confirmation names what goes, and there is no undo.
Default limits
| Per app | Default |
|---|---|
| CPU and memory | A quarter core, 256 MB |
| Storage | 512 MB |
| Build | 1 GB, 10 minutes |
| Deploys | 20 a day |
| Per host or organization | |
| Running at once | 3 |
| Declared per organization | 10 |
Self-hosters size these alongside the run caps in the compose file. An app's data is a volume on the host's disk, outside the database — back it up like any volume.
Who can open it
Every member sees the apps shared with them in Apps; visibility narrows an app to admins or to a listed set. The check is enforced on every request, not just on the list. Only admins create, edit, deploy, roll back, or remove apps.
Everything works from anywhere: an app is the organization's, so it is listed, edited, and operated from any project's scope — and from an organization with no projects at all.
Two edges worth knowing
- Theme is fixed for the life of an open. Switching light and dark re-opens the app.
- Links out open in the system browser. An app cannot navigate its own view anywhere but itself, and it cannot open popups.
Next: Projects & repositories · Self-hosting