# Calls

Calls is video calling for one team. A member makes a meeting, now or
for later, and sends one link; anyone who follows it sets up their
camera and microphone on a holding screen, waits to be let in if they
are not on the team, and talks in a room of up to four, with chat
beside the call and a screen to share. An agent can also create,
list, relink and end meetings over an API. Every screen works with
JavaScript disabled except browser media participation — the camera
preview and join button on the holding screen, and the call itself in
the room — and, once a call exists, the room's two other script-only
parts: the chat panel and the live admission queue with its Admit
controls. Both pages still render their title, start time, standing,
floor notice, the paragraph that says what needs script, and a
member's navigation with scripts off (spec §9).

ICE servers come from the CARLOS platform relay, delivered as
`CARLOS_RELAY` and minted fresh for each call at `GET /m/{sqid}/ice`;
Calls names no STUN or TURN server of its own (suite ruling 6). With no
relay configured, calls are direct only: two browsers connect when ICE
can find a working path straight between them, which the holding
screen states plainly above the join button: "This team's Calls has no
relay yet. Calls usually connect only between people on the same
network." Enabling or changing the relay restarts the instance. When
the relay is in the path it forwards DTLS-SRTP packets encrypted
between the browsers, so it carries bytes it cannot read; Calls' own
process never receives a media packet at all. With the relay on, a
call that still fails says "could not connect, even through the
relay" and offers retry; it does not diagnose the network. One known
cause is a network that allows only TCP 443, because the relay's TLS
listener is deferred by the platform — a possibility, not a verdict
(spec §0.1, §5.2, §5.5, §5.6, §7.3, §15.13).

## Sign-in

Sign-in is a magic link to an address on the team's allowlist
(`CALLS_ALLOWED_ADDRESSES`). Following a meeting link gives an outsider
a guest grant at sign-in — never a way into a room without a name —
and the allowlist otherwise gates sign-in exactly as it does for a
member (spec §3, §7.1). An operator can also enable Home sign-in with
all four of `CALLS_HOME_ISSUER`, `CALLS_HOME_CONSUMER_ID`,
`CALLS_HOME_KEYS_BASE64` and `CALLS_HOME_DEK_BASE64` — never a partial
set — which links an existing member after a fresh, independent local
sign-in and explicit confirmation; it never creates a person or grants
membership on its own (spec §7.1.1). Because a member's address can be
rate-limited on purpose and gets a different answer from an unknown
one, whether a given address is on the team is not a secret the
sign-in form can keep; that residue is stated rather than claimed away
(spec §3.2).

## The room

The room is a peer-to-peer mesh of up to four admitted participants,
negotiated with the W3C perfect-negotiation pattern over an SSE stream
and a POST signal per peer. A guest waits in a queue until an admitted
member lets them in; removing a participant and ending a meeting each
go through their own confirm page — there is no in-room control that
skips one (spec §3.5). A member can retrieve the current invitation
again from the holding screen or the room with Share (`POST
/m/{sqid}/share`). On a meeting whose only link predates the
2026-09-12 sharing update, the first Share mints a new recoverable
invitation alongside the old, still-valid one rather than opening it;
every Share after that retrieves the same recoverable one. Replacing a
link revokes every live one. Chat is fanned out live to admitted peers
and never stored (spec §6). Screen share adds a second tile per sharer
(spec §5.4). The room lives in one process's memory, so every deploy
drops every live call (spec §15.11).

## Agents

A member mints an agent's bearer token from Settings; it is shown once
and the row after it keeps only its hash. The token works on
`/api/v1` only — never a cookie there, and never a bearer on an HTML
route — for five operations: list meetings, create one, replace its
link, end it, and read one meeting's detail. It cannot join a call
(spec §8). An agent's reach is re-derived from its operator's current
standing on every request, so an operator who leaves the team takes
their agent's reach with them at once, with no separate revocation
step. Requests are limited per agent to one a second sustained, with a
burst of 60 (spec §8, §15.17).

## Native apps

The SwiftUI Mac and iPhone apps use this same server and a shared Go
core (`callscore/`, `protocol/`); there are no embedded webviews. Build
the Mac app with `make native-mac`. See `native/README.md` and
`native/ios/README.md` for setup, native sign-in and the native gates.

## Building and gates

`CGO_ENABLED=0 make ci` (vet, gofmt, tests, migration check) is the
one gate a runner and a push share; `ci` itself does not set the
variable, so the runner and every push must. Three things ship or run
under `CGO_ENABLED=1`, each isolated from the server: `make race`
builds a server test binary, never a release, for any change to
`internal/calls/hub` or the signalling stream; `make native-test`'s own
`callscore` race sub-step does the same for the shared Go core; and the
native Mac and iPhone/iPad bindings under `native/` bridge Go to
Objective-C with real cgo, isolated in `native/go.mod` and built with
`make native-mac`. `make browser` drives a real call across two real
Chromium contexts, 31 scenarios in about nine minutes; it needs the
Claude Code bash sandbox off, because the sandbox blocks UDP and every
call then fails with `frames` stuck at 0. `make release VERSION=<sha>`
stamps that version into `/api/version`, so a deploy can be checked
against what it is meant to be running.

## Deploying

Calls deploys as a CARLOS unit route. Build the artifact first — `make
release VERSION=<sha>` produces `releases/calls-linux-arm64`, stamped
with the version `/api/version` will report (`strings -a
releases/calls-linux-arm64 | grep -cx <sha>` confirms the stamp) — then
deploy it: `carlos deploy --app calls --account oficina --channel edge
--version <sha> --target linux-arm64 releases/calls-linux-arm64`,
which waits until `/api/version` reports it — but only once a build
probe is configured for the app: `carlos apps build-probe --app calls
--account oficina /api/version`, set on the flagship app since
2026-09-09. A new app or account needs that command run once before
the wait means anything; without it `carlos deploy` watches
`X-Carlos-Version` instead, which can report the new version adopted
before the running process has actually changed. `--account` is a
global flag; `carlos ship` uploads only, while `carlos deploy` is ship,
promote and wait (its own help text). Building for your own
architecture and shipping that uploads fine and fails to exec on the
instance.

These are the settings a deploy needs (`config.go`, `standing.go`).
Five refuse startup when empty outside `-env development`:
`CALLS_ORIGIN` (the external origin, scheme included);
`CALLS_INSTANCE_KEY`; `CALLS_DEK` (base64 of exactly 32 decoded bytes,
not 32 base64 characters); `CALLS_ALLOWED_ADDRESSES` (comma-separated,
non-empty — this is the team); and mail, `CALLS_SMTP_*` or the
platform's `CARLOS_SMTP_*`. Two are optional, each with its own
default rather than a refusal: `CALLS_ZONE` (an IANA zone, defaulting
to UTC when unset) and `CARLOS_RELAY` (empty means no relay, §5.5 —
only a *malformed* value refuses startup), delivered by `carlos relay
enable --app calls` rather than set by hand. Secrets —
`CALLS_INSTANCE_KEY`, `CALLS_DEK`, `CALLS_SMTP_PASS` and
`CALLS_APNS_KEY` — go through `carlos secrets`; the rest go through
`carlos env`.

Apple push is a sixth group, and all of it or none of it:
`CALLS_APNS_KEY` (the `.p8` as Apple issues it, PEM and multi-line),
`CALLS_APNS_KEY_ID`, `CALLS_APNS_TEAM_ID`, `CALLS_APNS_TOPIC_IOS` and
`CALLS_APNS_TOPIC_MACOS`, with `CALLS_APNS_SANDBOX` for a development
build's tokens. Unset, the server sends no push and says so nowhere —
a knock still reaches every member in the room over their own stream,
which is where this app was before push existed. Partly set, startup
refuses: half a push key is a mistake to catch at boot rather than in a
goroutine on the first knock. Mail reads
`CALLS_SMTP_*` with `CARLOS_SMTP_*` as a per-variable fallback; the
platform supplies a sender too
(`CARLOS_SMTP_FROM`), which `CALLS_SMTP_FROM` can optionally override,
so `carlos email enable` alone is enough to send mail. `carlos relay
enable --app calls` turns on the platform relay. Every deploy, and
every relay change, restarts the instance and drops every live call —
no drain exists, so deploy outside meeting hours. Migrations apply
once each at boot (`msg=migrate` in the log) and are never re-run; a
database that already applied a migration file under an earlier name
fails at boot rather than silently diverging. The deploy wait can time
out before the hand-over while the instance then serves the new
version a minute later — check `/api/version` before retrying.

Canary a build on its own host: `carlos canary --app calls --name
slice2` composed `slice2.calls.bds.oncarlos.com`, and the console
refused it ("invalid host, an instance host is one DNS label followed
by .bds.oncarlos.com") — as recorded on 2026-09-10 in platform
discussion #124, open as of that record. The recipe actually used:
`carlos instances create --app calls --account oficina --host
slice2-calls.bds.oncarlos.com --channel canary/slice2 --environment
slice2`. The `slice2` environment bundle must already exist, created
separately, carrying `CALLS_ORIGIN` for the canary host; without it
sign-in refuses on the canary while GETs look healthy. Then `carlos
deploy --account oficina --app calls --channel canary/slice2 --host
slice2-calls.bds.oncarlos.com --version <sha>
releases/calls-linux-arm64`.

## Where things are written down

The design lives in `docs/superpowers/specs/2026-09-07-meet-design.md`,
the slice plans in `docs/superpowers/plans/`, and each slice's review
in `docs/superpowers/reviews/`. `PRODUCT.md` covers what Calls is for
and what it deliberately leaves out; `AGENTS.md` covers the
conventions the code is held to. Spec §15.18 is the disposition of
record for what slice 6 delivered, deferred (the acknowledgements
page) and declined (the Estilo theme pin, the production promotion).

### Meeting access settings

Team members can set **Meeting defaults** in `/settings` and change either
option for an individual meeting from its **Settings** link. New meetings
permit guests and have no waiting room unless the team changes those defaults.
Defaults apply to every creation path, including agents. Existing meetings keep
their original sign-in and waiting-room requirements during migration.

When sign-in is off, an invitation holder can enter a display name to join as
a guest. Guest sessions cannot create meetings, change settings, or admit other
guests. With the waiting room on, a team member admits guests; with it off,
guests enter directly, subject to the same call capacity limit. Changes apply
at entry; admitted participants keep their participation. Turning the waiting room off
admits queued guests up to the call capacity.

Invitation and browser holding pages offer **Join in the app**, using the
`oficina-calls://join?url=…` URL scheme registered by the Mac and iOS bundles.
On the invitation page (nobody signed in yet) the link carries the invitation,
and the app runs its own browser sign-in for it, guests included. On the
holding page (signed in, as a member or a guest) it goes through
`/m/{sqid}/app` first, which makes a five-minute, one-use *offer* bound to this
browser session and opens the app with it (`&offer=…`); the app's ordinary
native sign-in request names the offer, and the page the person is on turns
into the confirmation for it, with the app's code — so the app gets this
person's own standing in this meeting without a second sign-in, in whatever
browser they happen to be using. An app already signed in as someone who can
join the meeting uses its own session and needs no browser. Browser joining
remains available when the app is not installed.
