Running a beam host
Running a beam host
The beam host is the one public thing in the picture. Every installation dials out to it and every browser comes in through it, so it is the only component with an address, a certificate and an attack surface. It is also small: one compiled binary, no database, and nothing in memory that matters past a restart.
That asymmetry is what makes it operable. Plan for the host to be restarted often and to lose nothing; plan for the installations behind it to be restarted rarely and to lose everything if their volume goes.
Sizing it
A tunnel is a WebSocket and a few kilobytes of bookkeeping. Forwarded traffic is streamed rather than buffered, with a per-channel window, so the memory a busy slot costs is bounded by that window and not by the size of what is moving through it.
| Slots | Sensible shape |
|---|---|
| 1-5 | The smallest VPS you can rent. 1 vCPU, 512 MB |
| 5-25 | 1-2 vCPU, 1 GB |
| More, or with the dataset on | 2 vCPU, 2 GB, and put blobs on object storage |
Bandwidth is what actually binds. Every byte a remote browser sees crosses the host twice, and a session watching a build scroll is a real stream. If the host is metered, that is the number to watch.
Disk is only the fs storage backend: shared logbook buckets, sync blobs, and the opt-in dataset. Point BEAM_STORAGE at S3 or an HTTP filer and the host needs no volume at all.
The proxy in front
The host speaks plain HTTP and expects TLS to be terminated ahead of it. Three things matter and none of them are defaults everywhere:
- Pass the WebSocket upgrade through. The tunnel is one, and so is every terminal and database socket inside the forwarded app.
- Set
X-Forwarded-Proto. Without it the host buildsws://URLs behind anhttps://page and the browser refuses them as mixed content. - Turn response buffering off. Event streams are held until they finish otherwise, which looks exactly like an agent that has stopped responding.
Caddy needs none of that said:
beam.example.com {
reverse_proxy 127.0.0.1:8787
}nginx needs all of it:
location / {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_buffering off;
proxy_read_timeout 3600s;
}The read timeout is not optional. An idle tunnel exchanges a ping every twenty seconds, so it survives a short one, but a forwarded agent session can sit silent for minutes while a model thinks.
Slots and the three kinds of secret
Confusing these is the most common way to get this wrong, so:
Drawing the diagram…
| Secret | Holder | Opens |
|---|---|---|
BEAM_TOKEN | You, the operator | Any slot, the slot listing, the dataset routes |
A BEAM_SLOTS entry | One installation | That slot's tunnel, and a sync tenant slot:<name> |
| A minted slot token | One installation | The same as a BEAM_SLOTS entry, issued by the running host rather than its environment |
| An installation's own login | Whoever signs in to it | That installation, past its own auth gate |
Issue one slot token per installation, your own desktop included, and keep BEAM_TOKEN in your secret store and, if you use it, the Beam fleet vApp's binding:
BEAM_TOKEN=$(openssl rand -hex 32)
BEAM_SLOTS="kdlab=$(openssl rand -hex 32),studio=$(openssl rand -hex 32)"A slot name is lowercase letters, digits and hyphens, at most 31 characters, and an installation naming none lands in default.
The tunnel token rides in the dial's query string, because a WebSocket client cannot set an Authorization header. It therefore lands in the proxy's own access log. Either keep that log private or drop the query from it.
Minting a slot while the host runs
BEAM_SLOTS is an environment edit and a restart per machine. The running host can also mint a slot itself, with BEAM_TOKEN, and the token it answers is shown this once:
curl -s -X POST -H "Authorization: Bearer $BEAM_TOKEN" \
-H 'content-type: application/json' -d '{"slot":"studio"}' \
https://beam.example.com/__beam/slots
# {"slot":"studio","token":"<64 hex characters>","createdAt":"..."}The host keeps only a hash of it, in fleet/slots.json, so there is nothing to read back later. A minted slot is exactly what a BEAM_SLOTS entry is: that slot's tunnel and the sync tenant slot:<name>. A name BEAM_SLOTS holds cannot be minted, invited or revoked here; the host answers 409 SLOT_FROM_ENVIRONMENT.
Handing a token to the person at the other machine is the part that goes wrong, so there is a code instead. An invite names the slot and answers a code that works once and expires, in thirty minutes unless you say otherwise, a day at most:
curl -s -X POST -H "Authorization: Bearer $BEAM_TOKEN" \
-H 'content-type: application/json' -d '{"slot":"studio","ttlMinutes":60}' \
https://beam.example.com/__beam/enroll/invites
# {"slot":"studio","code":"abcde-fghjk-mnpqr-stvwx","expiresAt":"..."}On the other machine, Settings, Beam, Join a host with a code takes the host's address and that code, and the installation is on the slot with a token of its own that nobody had to copy. A container does the same at its first start from MECHABEDO_BEAM_ENROLL beside MECHABEDO_BEAM_URL. The code is read in any case, with hyphens or spaces; a wrong one is 404, a stale one 410, and guessing is bounded by the same per-address allowance as bucket claims. In mechabedo, an agent on the operator's machine mints an invite with beam_enroll_invite, behind a consent card.
Revoking a minted slot is DELETE /__beam/slots/studio. A tunnel on it is closed with a code the installation reads as revoked, after which it stops re-dialing until its beam settings change, and its token opens nothing.
Rotating a slot token
There is no revocation list; the token is the credential.
A minted slot rotates by minting it again: the new token replaces the old one at once, a tunnel on the old one drops at its next dial, and you put the new token into that installation. Revoking it is the other way to end the old token.
An environment slot rotates as it always did:
- Put the new token in
BEAM_SLOTSbeside the old one under a second name, or accept a few seconds of downtime and replace it in place. - Restart the host. Existing tunnels drop and re-dial.
- Update the installation, which re-dials with the new token.
Either way the slot's sync tenant is derived from its token, so a rotation starts the slot's sync archive over; machines that must keep one archive across a rotation share a logbook. An installation whose token stopped working reports 401 on the dial and retries with backoff. It does not fall back to any other credential.
The operator view
GET /__beam/slots with BEAM_TOKEN is the operator view:
curl -s -H "Authorization: Bearer $BEAM_TOKEN" \
https://beam.example.com/__beam/slots | jq{
"slots": [
{
"slot": "kdlab",
"device": "lab-box",
"since": "2026-09-12T09:04:11.882Z",
"channels": 2,
"agent": {
"version": "0.1.0",
"mode": "serve",
"vendor": true,
"os": "linux",
"arch": "aarch64",
"cpus": 8,
"memoryBytes": 8589934592,
"container": "docker"
},
"vitals": { "uptimeSeconds": 82400, "freeMemoryBytes": 3221225472, "sessions": 2 }
}
],
"configured": ["default", "kdlab", "studio"],
"minted": [
{ "slot": "lounge", "createdAt": "2026-10-20T08:00:00.000Z", "lastUsedAt": "2026-10-20T09:12:00.000Z", "enrolledBy": { "installationId": "7f3a9c1e", "device": "lounge-mini" } }
],
"invites": [{ "slot": "attic", "expiresAt": "2026-10-20T10:30:00.000Z" }],
"lastSeen": {
"studio": {
"device": "studio-box",
"agent": { "version": "0.1.0", "os": "darwin", "arch": "aarch64" },
"connectedAt": "2026-10-02T07:58:40.120Z",
"disconnectedAt": "2026-10-03T18:12:05.004Z",
"closeReason": "1000 shutting down"
}
}
}agent comes from the handshake and is fixed for a connection. vitals comes from the heartbeat the installation answers every twenty seconds, so it is never more than that stale. Both are absent for an installation older than they are, which reads as unknown rather than as a fault.
configured is every slot BEAM_SLOTS names, and minted every slot the host issued while running, with who enrolled on it and when its token was last presented; invites is the enrollment codes still open, without the codes. Together they are how you tell a slot that is down from one that was never issued. lastSeen is, for each of those slots that is down, the machine that was last on it and when and why it left. A slot that was connected when the host stopped reads host restarted.
GET /__beam/audit?limit=200 answers what happened, newest first: tunnels connecting, leaving, taking a slot over and being refused, shared logbook creation, invites, joins, removals and hand-overs, setup invites filed, claimed and revoked, and slots minted, invited, enrolled and revoked. Each event carries a sequence number that only grows, and ?since=<seq> answers only what came after one. The host keeps the newest 2000.
/__beam/peers, which any tunnel credential may read, tells a slot token each other machine's slot, name, version and session count, and so does the slot list /__beam/health gives it. The full agent and vitals blocks are for BEAM_TOKEN or a fleet admin.
The same information is in the app, under Settings, Beam, Instances on this host and in the status bar's beam item, for whichever installations this machine can see.
With more than one host, or to watch one without curl, import the Beam fleet vApp: it reads this route, the health, the marketplace and the dataset of each host you give it, and publishes the dataset on request, with the operator tokens bound so they reach only those routes (Administering several beam hosts).
Watching it
GET /__beam/health answers without a credential and is what an uptime check should use:
{ "status": "ok", "live": 3, "storage": "fs /data", "analytics": true, "mlflow": null }live is how many slots are carrying something. It names nothing: a public endpoint that handed out machine names and version strings would be a list of targets, so the slot detail needs a tunnel credential and appears on the same route only for one.
Four things are worth an alert, in the order they will actually page you:
| Signal | Source | Meaning |
|---|---|---|
live below what you issued | /__beam/health | An installation is down or cannot reach the host |
channels stuck above zero on an idle slot | /__beam/slots | A leaked channel. Restarting that installation clears it |
tunnel.refusal | /__beam/audit, host log | Two machines want one slot. Expected during a migration, not otherwise |
A slot's vitals.uptimeSeconds resetting repeatedly | /__beam/slots | That installation is crash-looping. Its own log says why |
The host's log is one line per audit event, Audit <seq> <type> (<who>) <detail>: a slot connected, reclaimed after a reconnect, taken over, refused or left, and the logbook and invite events beside them. There is deliberately no line per forwarded request.
Backup
Drawing the diagram…
The host holds three things, and only on the fs backend does it hold them on its own disk:
- Shared logbook buckets, encrypted, and expiring on their own TTL. Losing them costs nothing that was not a copy.
- Sync blobs and tombstones, encrypted, one per tenant. These are how several installations converge on one archive; losing them means the next sync pass re-uploads from whichever installation still has the sessions.
- The opt-in dataset, plaintext. This is the only thing here that exists nowhere else.
- The marketplace index, when a Forgejo is configured: which slot published which repository, and whether it is a vApp or a skill. The repositories themselves are on Forgejo, and back up with it.
- The mobile app's push key and subscriptions,
push/vapid.jsonandpush/subscriptions.json. Losing them costs one visit: each phone subscribes again the next time its app opens. - What the host remembers about the fleet,
fleet/last-seen.jsonandfleet/audit.json, beside the shared logbook and invite registries,logbooks/index.jsonandinvites/index.json. A shared logbook's sessions live undertenants/lb-<id>/and its memory records undertenants/lb-<id>/memory/, encrypted like every other sync blob.
Each store keeps a small JSON index beside its blobs, and that index is the manifest. Nothing rebuilds one by scanning, so an index lost without a backup orphans the bodies behind it. Back the index objects up together with the bodies, in the same pass.
# fs backend
systemctl stop beam-host
tar -C /var/lib/beam-host -czf "beam-$(date -u +%Y%m%dT%H%M%SZ).tgz" .
systemctl start beam-hostAn S3 or filer backend is backed up by whatever backs up that bucket. Include the prefix, not just the blob keys.
Nothing else on the host is state. The configuration is environment, so the restore procedure is: bring up a host with the same environment, put the blobs and index back, start it. Installations re-dial by themselves.
The marketplace
Give the host a Forgejo, version 16 or later, and it runs the marketplace, where installations share vApps and skills:
BEAM_FORGEJO_URL=https://forgejo.example.org
BEAM_FORGEJO_TOKEN=... # a site administrator's token
BEAM_FORGEJO_ORG=mechabedo-vappsThe token needs the admin, organization, repository and user scopes, and it never leaves the host. The host creates the organization private if it is missing, a read-only marketplace team in it, and a restricted user vslot-<slot> per installation the first time that installation publishes or installs. Those users see only what they are given, and every credential an installation receives is one of their tokens, scoped to a single repository. You can watch all of it in Forgejo's own admin pages.
A vApp's repository is named <slot>--<vApp id> and a skill's <slot>--skill--<name>, and each carries the topic mechabedo-vapp or mechabedo-skill, so you can filter them in Forgejo. Installations of an older build keep working against a newer host.
Bounding what it holds
A sync blob has no TTL by design: two installations converge on whatever the host holds, so expiry would mean one of them re-uploading forever. Set BEAM_MAX_TENANT_BYTES when the store is not effectively unlimited. Over the ceiling an upload answers 507, and that installation's sync pass counts the one session as failed and carries on, which is the handling every other per-session failure gets.
BEAM_MAX_BUCKET_BYTES, BEAM_MAX_SYNC_SESSION_BYTES and BEAM_MAX_DATASET_SESSION_BYTES bound a single upload; BEAM_BUCKET_TTL_HOURS caps what a shared bucket may ask for.
The front door
GET /__beam/ is a page, not an API: which slot this browser is pinned to, whether anything is on it, a way in, and a picker for another slot. A navigation that cannot be forwarded gets the same page with a 503 and goes in by itself once the installation reconnects, so a bookmark to the host stays usable while the machine behind it is asleep.
BEAM_SLOT_DIRECTORY=on lets that page list the live slots and the machines on them. Turn it on for a host that is yours alone and leave it off for one several people reach: it is unauthenticated, and a machine name is not a stranger's business.
The mobile app
The host is also where a phone's mobile app starts: its launcher at /__beam/sidecar/, the app's manifest and worker, and the relay that delivers its notifications. Nothing needs configuring. The push key is generated into the store on first start. An iPhone's home-screen app opens signed in only from a host release that hands it the sign-in through its manifest; an older host still works, and the app asks for a pairing link instead.
| Variable | Default | What |
|---|---|---|
BEAM_PUSH_SUBJECT | BEAM_PUBLIC_URL, else the project's site | The contact push services may use, a mailto: or https: URL. Set it when the host has no public URL configured |
BEAM_PUSH_SERVICES | unset | Extra push service host suffixes a phone may subscribe through, for a self-hosted one. FCM, Mozilla, Apple and WNS are always allowed |
The host encrypts each notification for the phone, so it reads them: a session's name, a line of its reply, a question. The proxy in front needs nothing new; the phone reaches the host over the same HTTPS.
Bedos
BEAM_ROOMS=on makes the host the home of the fleet's Bedos: the threads people and Bedos talk in, and the page they do it on. It stores every thread in the blob store you already back up, under streams/ with its state in bedos/state.json, and needs nothing else. People read and write with their fleet sign-in, so turn that on too; each machine answers once its owner switches Bedos on.
The page is built into the image, from the mechabedo checkout. Build the image there with mise run beam:image (it runs Dockerfile.beam, which builds the page and then the host, so one never ships without the other), and push or load it as you do now. If you build from a copy of beam-host/ instead, run mise run beam:client in the checkout before copying: the page lands in beam-host/client/, which is build output and not tracked, so a copy of tracked files alone misses it. That image's build stops with a message saying so rather than shipping a host without its page; --build-arg ALLOW_MISSING_BEDOS_PAGE=1 builds one on purpose, for a host that does not carry Bedos.
| Variable | Default | What |
|---|---|---|
BEAM_ROOMS | off | Bedos. Off, their routes answer 404 |
BEAM_ROOMS_RETENTION_DAYS | 90 | How long a thread is kept after its last message |
BEAM_ROOMS_AGENT_CHAIN | 4 | Handoffs between Bedos in a row before they stop handing work to each other |
The history is plaintext to whoever operates the host. Credentials are masked before anything is stored, with the same rules the logbook export uses.
Fleet sign-in
With an OIDC provider configured, the host signs people in and vouches for them to the installations whose owners allow it. Register one client at the provider per host, with https://beam.example.com/__beam/auth/callback as its redirect URI and a groups claim in the id token.
| Variable | Default | Meaning |
|---|---|---|
BEAM_OIDC_ISSUER, BEAM_OIDC_CLIENT_ID | unset | The provider and this host's client. Both set turn sign-in on |
BEAM_OIDC_CLIENT_SECRET | unset | Set for a confidential client. PKCE is used either way |
BEAM_PUBLIC_URL | unset | The host's public origin. Required with sign-in on; the host refuses to start without it |
BEAM_OIDC_SCOPES | openid profile email groups | What the host asks for |
BEAM_OIDC_ADMIN_GROUPS | unset | Comma list. Any of them makes an admin. Unset, nobody is, and the host says so at startup |
BEAM_OIDC_MEMBER_GROUPS | unset | Comma list of the other groups that may sign in; * admits any account. Unset, only admins can |
BEAM_FLEET_SECRET | generated | What the host's session and assertion keys derive from, at least 32 characters. Unset, one is generated into BEAM_DATA_DIR/fleet-secret |
Give a host without a volume a BEAM_FLEET_SECRET, or every restart signs the fleet out. Rotating it signs everyone out on purpose. It is not BEAM_TOKEN and must not be: installations hold that one.
| Route | Does |
|---|---|
GET /__beam/auth/login?next= | Sends the browser to the provider |
GET /__beam/auth/callback | Where the provider sends it back; sets the host session, twelve hours |
POST /__beam/auth/logout | Ends the host session |
GET /__beam/me | The signed-in member, or 401 |
GET /__beam/open, /__beam/open/<slot> | Opens that slot's installation signed in, with a two-minute single-use assertion |
GET /__beam/.well-known/jwks.json | The public key installations verify that assertion with |
Set BEAM_OIDC_ADMIN_GROUPS to the group you mean. It had a default of admins once, which made whoever a provider put in a group by that name the fleet's operator.
With sign-in off, login, callback, open and the JWKS answer 404. A fleet admin may also read /__beam/slots and /__beam/audit without BEAM_TOKEN. The host keeps its own cookies out of every request it forwards, so no installation ever sees a member's host session, and drops the x-mechabedo-beam and x-beam-peer-from headers a browser might send, which only an installation or the host may set.
Every slot is served from the same origin, so one installation's pages could act for a member on another. The host says so at startup when sign-in runs beside BEAM_SLOTS; turn it on only where the installations' owners trust each other.
When it does not work
| Symptom | Usually |
|---|---|
| 401 on the tunnel dial | The token is not that slot's, is not in BEAM_SLOTS or minted at all, or the slot was revoked or minted again |
| The host refuses to start | No BEAM_TOKEN, or the storage backend did not answer its boot probe |
BEAM_PUBLIC_URL is required at startup | Fleet sign-in is configured and the host's public origin is not set |
Sign-in refused after the provider | The account is in none of the admin or member groups, or the id token lacks a groups claim |
nothing connected on slot 'x' | The slot name is right and nothing is dialing. The installation's log says why |
ws:// refused as mixed content | The proxy is not setting X-Forwarded-Proto |
| Streams arrive all at once at the end | The proxy is buffering responses |
| A forwarded request dies at 60 s | The proxy's read timeout. Raise it |
too many open channels | One slot is saturated. It clears in a second or two; if it does not, that installation is leaking channels |
| 507 on a sync upload | BEAM_MAX_TENANT_BYTES reached for that tenant |
Next
- Administering several beam hosts - the operator view as a vApp, across hosts
- Running mechabedo as a server - the thing on the other end of a slot
- Upgrading - the two together, in the right order
- Beam - connecting an installation in the first place