| 1 | # Calls |
| 2 | |
| 3 | Calls is video calling for one team. A member makes a meeting, now or |
| 4 | for later, and sends one link; anyone who follows it sets up their |
| 5 | camera and microphone on a holding screen, waits to be let in if they |
| 6 | are not on the team, and talks in a room of up to four, with chat |
| 7 | beside the call and a screen to share. An agent can also create, |
| 8 | list, relink and end meetings over an API. Every screen works with |
| 9 | JavaScript disabled except browser media participation — the camera |
| 10 | preview and join button on the holding screen, and the call itself in |
| 11 | the room — and, once a call exists, the room's two other script-only |
| 12 | parts: the chat panel and the live admission queue with its Admit |
| 13 | controls. Both pages still render their title, start time, standing, |
| 14 | floor notice, the paragraph that says what needs script, and a |
| 15 | member's navigation with scripts off (spec §9). |
| 16 | |
| 17 | ICE servers come from the CARLOS platform relay, delivered as |
| 18 | `CARLOS_RELAY` and minted fresh for each call at `GET /m/{sqid}/ice`; |
| 19 | Calls names no STUN or TURN server of its own (suite ruling 6). With no |
| 20 | relay configured, calls are direct only: two browsers connect when ICE |
| 21 | can find a working path straight between them, which the holding |
| 22 | screen states plainly above the join button: "This team's Calls has no |
| 23 | relay yet. Calls usually connect only between people on the same |
| 24 | network." Enabling or changing the relay restarts the instance. When |
| 25 | the relay is in the path it forwards DTLS-SRTP packets encrypted |
| 26 | between the browsers, so it carries bytes it cannot read; Calls' own |
| 27 | process never receives a media packet at all. With the relay on, a |
| 28 | call that still fails says "could not connect, even through the |
| 29 | relay" and offers retry; it does not diagnose the network. One known |
| 30 | cause is a network that allows only TCP 443, because the relay's TLS |
| 31 | listener is deferred by the platform — a possibility, not a verdict |
| 32 | (spec §0.1, §5.2, §5.5, §5.6, §7.3, §15.13). |
| 33 | |
| 34 | ## Sign-in |
| 35 | |
| 36 | Sign-in is a magic link to an address on the team's allowlist |
| 37 | (`CALLS_ALLOWED_ADDRESSES`). Following a meeting link gives an outsider |
| 38 | a guest grant at sign-in — never a way into a room without a name — |
| 39 | and the allowlist otherwise gates sign-in exactly as it does for a |
| 40 | member (spec §3, §7.1). An operator can also enable Home sign-in with |
| 41 | all four of `CALLS_HOME_ISSUER`, `CALLS_HOME_CONSUMER_ID`, |
| 42 | `CALLS_HOME_KEYS_BASE64` and `CALLS_HOME_DEK_BASE64` — never a partial |
| 43 | set — which links an existing member after a fresh, independent local |
| 44 | sign-in and explicit confirmation; it never creates a person or grants |
| 45 | membership on its own (spec §7.1.1). Because a member's address can be |
| 46 | rate-limited on purpose and gets a different answer from an unknown |
| 47 | one, whether a given address is on the team is not a secret the |
| 48 | sign-in form can keep; that residue is stated rather than claimed away |
| 49 | (spec §3.2). |
| 50 | |
| 51 | ## The room |
| 52 | |
| 53 | The room is a peer-to-peer mesh of up to four admitted participants, |
| 54 | negotiated with the W3C perfect-negotiation pattern over an SSE stream |
| 55 | and a POST signal per peer. A guest waits in a queue until an admitted |
| 56 | member lets them in; removing a participant and ending a meeting each |
| 57 | go through their own confirm page — there is no in-room control that |
| 58 | skips one (spec §3.5). A member can retrieve the current invitation |
| 59 | again from the holding screen or the room with Share (`POST |
| 60 | /m/{sqid}/share`). On a meeting whose only link predates the |
| 61 | 2026-09-12 sharing update, the first Share mints a new recoverable |
| 62 | invitation alongside the old, still-valid one rather than opening it; |
| 63 | every Share after that retrieves the same recoverable one. Replacing a |
| 64 | link revokes every live one. Chat is fanned out live to admitted peers |
| 65 | and never stored (spec §6). Screen share adds a second tile per sharer |
| 66 | (spec §5.4). The room lives in one process's memory, so every deploy |
| 67 | drops every live call (spec §15.11). |
| 68 | |
| 69 | ## Agents |
| 70 | |
| 71 | A member mints an agent's bearer token from Settings; it is shown once |
| 72 | and the row after it keeps only its hash. The token works on |
| 73 | `/api/v1` only — never a cookie there, and never a bearer on an HTML |
| 74 | route — for five operations: list meetings, create one, replace its |
| 75 | link, end it, and read one meeting's detail. It cannot join a call |
| 76 | (spec §8). An agent's reach is re-derived from its operator's current |
| 77 | standing on every request, so an operator who leaves the team takes |
| 78 | their agent's reach with them at once, with no separate revocation |
| 79 | step. Requests are limited per agent to one a second sustained, with a |
| 80 | burst of 60 (spec §8, §15.17). |
| 81 | |
| 82 | ## Native apps |
| 83 | |
| 84 | The SwiftUI Mac and iPhone apps use this same server and a shared Go |
| 85 | core (`callscore/`, `protocol/`); there are no embedded webviews. Build |
| 86 | the Mac app with `make native-mac`. See `native/README.md` and |
| 87 | `native/ios/README.md` for setup, native sign-in and the native gates. |
| 88 | |
| 89 | ## Building and gates |
| 90 | |
| 91 | `CGO_ENABLED=0 make ci` (vet, gofmt, tests, migration check) is the |
| 92 | one gate a runner and a push share; `ci` itself does not set the |
| 93 | variable, so the runner and every push must. Three things ship or run |
| 94 | under `CGO_ENABLED=1`, each isolated from the server: `make race` |
| 95 | builds a server test binary, never a release, for any change to |
| 96 | `internal/calls/hub` or the signalling stream; `make native-test`'s own |
| 97 | `callscore` race sub-step does the same for the shared Go core; and the |
| 98 | native Mac and iPhone/iPad bindings under `native/` bridge Go to |
| 99 | Objective-C with real cgo, isolated in `native/go.mod` and built with |
| 100 | `make native-mac`. `make browser` drives a real call across two real |
| 101 | Chromium contexts, 31 scenarios in about nine minutes; it needs the |
| 102 | Claude Code bash sandbox off, because the sandbox blocks UDP and every |
| 103 | call then fails with `frames` stuck at 0. `make release VERSION=<sha>` |
| 104 | stamps that version into `/api/version`, so a deploy can be checked |
| 105 | against what it is meant to be running. |
| 106 | |
| 107 | ## Deploying |
| 108 | |
| 109 | Calls deploys as a CARLOS unit route. Build the artifact first — `make |
| 110 | release VERSION=<sha>` produces `releases/calls-linux-arm64`, stamped |
| 111 | with the version `/api/version` will report (`strings -a |
| 112 | releases/calls-linux-arm64 | grep -cx <sha>` confirms the stamp) — then |
| 113 | deploy it: `carlos deploy --app calls --account oficina --channel edge |
| 114 | --version <sha> --target linux-arm64 releases/calls-linux-arm64`, |
| 115 | which waits until `/api/version` reports it — but only once a build |
| 116 | probe is configured for the app: `carlos apps build-probe --app calls |
| 117 | --account oficina /api/version`, set on the flagship app since |
| 118 | 2026-09-09. A new app or account needs that command run once before |
| 119 | the wait means anything; without it `carlos deploy` watches |
| 120 | `X-Carlos-Version` instead, which can report the new version adopted |
| 121 | before the running process has actually changed. `--account` is a |
| 122 | global flag; `carlos ship` uploads only, while `carlos deploy` is ship, |
| 123 | promote and wait (its own help text). Building for your own |
| 124 | architecture and shipping that uploads fine and fails to exec on the |
| 125 | instance. |
| 126 | |
| 127 | These are the settings a deploy needs (`config.go`, `standing.go`). |
| 128 | Five refuse startup when empty outside `-env development`: |
| 129 | `CALLS_ORIGIN` (the external origin, scheme included); |
| 130 | `CALLS_INSTANCE_KEY`; `CALLS_DEK` (base64 of exactly 32 decoded bytes, |
| 131 | not 32 base64 characters); `CALLS_ALLOWED_ADDRESSES` (comma-separated, |
| 132 | non-empty — this is the team); and mail, `CALLS_SMTP_*` or the |
| 133 | platform's `CARLOS_SMTP_*`. Two are optional, each with its own |
| 134 | default rather than a refusal: `CALLS_ZONE` (an IANA zone, defaulting |
| 135 | to UTC when unset) and `CARLOS_RELAY` (empty means no relay, §5.5 — |
| 136 | only a *malformed* value refuses startup), delivered by `carlos relay |
| 137 | enable --app calls` rather than set by hand. Secrets — |
| 138 | `CALLS_INSTANCE_KEY`, `CALLS_DEK`, `CALLS_SMTP_PASS` and |
| 139 | `CALLS_APNS_KEY` — go through `carlos secrets`; the rest go through |
| 140 | `carlos env`. |
| 141 | |
| 142 | Apple push is a sixth group, and all of it or none of it: |
| 143 | `CALLS_APNS_KEY` (the `.p8` as Apple issues it, PEM and multi-line), |
| 144 | `CALLS_APNS_KEY_ID`, `CALLS_APNS_TEAM_ID`, `CALLS_APNS_TOPIC_IOS` and |
| 145 | `CALLS_APNS_TOPIC_MACOS`, with `CALLS_APNS_SANDBOX` for a development |
| 146 | build's tokens. Unset, the server sends no push and says so nowhere — |
| 147 | a knock still reaches every member in the room over their own stream, |
| 148 | which is where this app was before push existed. Partly set, startup |
| 149 | refuses: half a push key is a mistake to catch at boot rather than in a |
| 150 | goroutine on the first knock. Mail reads |
| 151 | `CALLS_SMTP_*` with `CARLOS_SMTP_*` as a per-variable fallback; the |
| 152 | platform supplies a sender too |
| 153 | (`CARLOS_SMTP_FROM`), which `CALLS_SMTP_FROM` can optionally override, |
| 154 | so `carlos email enable` alone is enough to send mail. `carlos relay |
| 155 | enable --app calls` turns on the platform relay. Every deploy, and |
| 156 | every relay change, restarts the instance and drops every live call — |
| 157 | no drain exists, so deploy outside meeting hours. Migrations apply |
| 158 | once each at boot (`msg=migrate` in the log) and are never re-run; a |
| 159 | database that already applied a migration file under an earlier name |
| 160 | fails at boot rather than silently diverging. The deploy wait can time |
| 161 | out before the hand-over while the instance then serves the new |
| 162 | version a minute later — check `/api/version` before retrying. |
| 163 | |
| 164 | Canary a build on its own host: `carlos canary --app calls --name |
| 165 | slice2` composed `slice2.calls.bds.oncarlos.com`, and the console |
| 166 | refused it ("invalid host, an instance host is one DNS label followed |
| 167 | by .bds.oncarlos.com") — as recorded on 2026-09-10 in platform |
| 168 | discussion #124, open as of that record. The recipe actually used: |
| 169 | `carlos instances create --app calls --account oficina --host |
| 170 | slice2-calls.bds.oncarlos.com --channel canary/slice2 --environment |
| 171 | slice2`. The `slice2` environment bundle must already exist, created |
| 172 | separately, carrying `CALLS_ORIGIN` for the canary host; without it |
| 173 | sign-in refuses on the canary while GETs look healthy. Then `carlos |
| 174 | deploy --account oficina --app calls --channel canary/slice2 --host |
| 175 | slice2-calls.bds.oncarlos.com --version <sha> |
| 176 | releases/calls-linux-arm64`. |
| 177 | |
| 178 | ## Where things are written down |
| 179 | |
| 180 | The design lives in `docs/superpowers/specs/2026-09-07-meet-design.md`, |
| 181 | the slice plans in `docs/superpowers/plans/`, and each slice's review |
| 182 | in `docs/superpowers/reviews/`. `PRODUCT.md` covers what Calls is for |
| 183 | and what it deliberately leaves out; `AGENTS.md` covers the |
| 184 | conventions the code is held to. Spec §15.18 is the disposition of |
| 185 | record for what slice 6 delivered, deferred (the acknowledgements |
| 186 | page) and declined (the Estilo theme pin, the production promotion). |
| 187 | |
| 188 | ### Meeting access settings |
| 189 | |
| 190 | Team members can set **Meeting defaults** in `/settings` and change either |
| 191 | option for an individual meeting from its **Settings** link. New meetings |
| 192 | permit guests and have no waiting room unless the team changes those defaults. |
| 193 | Defaults apply to every creation path, including agents. Existing meetings keep |
| 194 | their original sign-in and waiting-room requirements during migration. |
| 195 | |
| 196 | When sign-in is off, an invitation holder can enter a display name to join as |
| 197 | a guest. Guest sessions cannot create meetings, change settings, or admit other |
| 198 | guests. With the waiting room on, a team member admits guests; with it off, |
| 199 | guests enter directly, subject to the same call capacity limit. Changes apply |
| 200 | at entry; admitted participants keep their participation. Turning the waiting room off |
| 201 | admits queued guests up to the call capacity. |
| 202 | |
| 203 | Invitation and browser holding pages offer **Join in the app**, using the |
| 204 | `oficina-calls://join?url=…` URL scheme registered by the Mac and iOS bundles. |
| 205 | On the invitation page (nobody signed in yet) the link carries the invitation, |
| 206 | and the app runs its own browser sign-in for it, guests included. On the |
| 207 | holding page (signed in, as a member or a guest) it goes through |
| 208 | `/m/{sqid}/app` first, which makes a five-minute, one-use *offer* bound to this |
| 209 | browser session and opens the app with it (`&offer=…`); the app's ordinary |
| 210 | native sign-in request names the offer, and the page the person is on turns |
| 211 | into the confirmation for it, with the app's code — so the app gets this |
| 212 | person's own standing in this meeting without a second sign-in, in whatever |
| 213 | browser they happen to be using. An app already signed in as someone who can |
| 214 | join the meeting uses its own session and needs no browser. Browser joining |
| 215 | remains available when the app is not installed. |
| 216 | |