# Protocol

How Ovenlight and the connector talk. Everything runs over the tailnet,
through the phone's embedded node, to an app's node:

- **Port 443**: the app, plus a few paths the connector answers itself.
- **Port 8443**: the owner's admin API.

Both ports check that `Host` is the node's own MagicDNS name and answer 421 otherwise
(on 8443, only after the owner check).

## Compatibility

Phones and connectors update on their own schedules. A new Ovenlight meets connectors
that are already out there, and a guest's phone may be older than the owner's
connector. Each side has to keep working with the other's older builds, in both
directions. So:

- Add fields; never rename or remove them. Readers ignore fields they don't know. The
  phone decodes each list one record at a time and requires only a few fields of each:
  an app's `slug` and `name`, an invite's `id`, `app`, `state` and `expires`, a guest's
  `deviceId`, `person`, `app` and `claimedAt`, and a feedback item's `id`, `app` and `at`.
  A record it can't read costs only that record.
- Every answer is a view type, never a stored record (most views are in
  `connector/wire.go`), so key IDs, key hashes and other internal fields never go on the
  wire.
- The files in `connector/testdata/wire/` are the contract: the connector's tests compare
  its answers with them, and Ovenlight's tests decode every one. They cover `apps`,
  `guests`, `invite-created`, `invite-canceled`, `guest-removed`,
  `guest-removed-with-errors`, `feedback`, `feedback-sent`, `claim`,
  `whoami-owner`, `whoami-guest`, `site-manifest` (`/.well-known/ovenlight.json`) and an
  `error-<code>` file for each code the phone reads.
  `go -C connector test -run TestWireFiles -update` rewrites them, but refuses to drop a
  key a file already has unless `-allow-drop` is added, which is only for a key no
  shipped build of Ovenlight needs.
- The phone never acts on an error's text, only on its status and
  [error code](#errors). An answer without a code it knows is an unknown failure, which
  never ends access or deletes anything.
- Put new owner-facing behavior behind the connector's version
  (`Ovenlight-Connector-Version`), so the phone can tell whether a connector has it.
  Guests never get that header, so a guest-facing change needs another signal, such as
  a new field.

Connector releases keep working with each other's files the same way. After an update, a
coding agent's `ovenlight mcp` runs the old binary until the agent restarts it, and a
rollback runs the old binary on files the new one wrote. So the control socket and every
file in the config and state folders follow these rules too: add fields and commands, and
never rename, move or reinterpret one. An older binary drops the fields it doesn't know
when it rewrites a file, so data whose loss matters goes in a file of its own, as
`commands.json` sits beside `config.json` and `updates-off` beside `update.json`.
`ovenlight version` prints exactly the bare version, since every installed updater checks
a new binary with it.

## Errors

Every error the connector makes carries a code in the `Ovenlight-Error` header. JSON
errors also carry it in the body:

```json
{"error": "not invited", "code": "not_invited"}
```

The refusals a browser may show as a page ([Other refusals](#other-refusals)) are plain
text with the header only.

| Code | Meaning |
| --- | --- |
| `not_invited` | a guest device with no claim on this app, or a claim with an unknown, expired, canceled or removed invite |
| `used_elsewhere` | the invite was already used by another device (a review invite, by 10) |
| `other_person` | the device belongs to another guest |
| `owner_only` | the admin API refused someone other than the owner; on port 443, a caller who is neither the owner nor a guest of this app |
| `unknown_caller` | the node doesn't know the calling device yet; worth another try |
| `not_found` | no such route, invite, guest or screenshot, or a guest name that matches several people |
| `bad_request` | a malformed body, the wrong method, the wrong `Host`, a missing `X-Ovenlight-Request`, a cross-site request, a web page's post to feedback, an upgrade other than WebSocket, or a refused invite, a failed Tailscale API call included |
| `rate_limited` | feedback over its rate limit |
| `too_large` | a feedback note or upload over its size limit |
| `unavailable` | the app isn't answering on its computer (a 502), or the connector can't serve the request now |
| `internal` | the connector failed |

A newer connector may send a code the phone doesn't know; the phone then reads the
answer as it would without one.

## Port 443: connector paths

Anything under `/__ovenlight/` is the connector's and never reaches the app; an unknown
path there is 404 `not_found`, in plain text.

### `GET /.well-known/ovenlight.json`

Anyone the connector admits (the owner, a guest who claimed an invite) may read it. It
describes the app for the launcher:

```json
{"name": "Interview Coach", "slug": "interview-coach", "icon": "/icon-512.png", "themeColor": "#1b1b1f", "version": 1}
```

`icon` (a path on the app's own origin) and `themeColor` may be `null`. The phone
ignores an answer with `version` below 1 or an empty `name`. How the connector fills it
in: [app-contract.md](app-contract.md#discovery-name-icon-and-color).

### `POST /__ovenlight/claim`

A guest device binds itself to an invite. Body: `{"key": "<the invite's auth key>"}`.
The connector matches the key's SHA-256 against its open invites. The claim may arrive at
any of the connector's shareable app nodes:

- At the invite's own app, the calling device must already carry the app's guest tag:
  either it joined with that key, or the owner added the tag to a device the person
  already has.
- At another app, the device must already be a guest there, with a claim on that app.
  The invite is checked and claimed as at its own app. The connector then adds the
  invite's app's guest tag to the device, and the app's node appears among its peers
  once that reaches the network.

A device belongs to one person, so it may claim only that person's invites. One
exception settles who an invite is for when it's accepted, at whichever app: an invite
for someone who has never had a device (not even one since removed) becomes the
device's person's, and the device gets the app under that person's name. Only that
invite moves. The person invited keeps their other invites, each of which settles the
same way when claimed, and drops out of the owner's lists once they have no invites
left. Such an invite gives a device that already has its app nothing: the answer is 200
with the device's own claim, and the invite stays open. A review invite never settles.

Claiming again from the same device is harmless. A review invite admits up to 10
devices, and every other invite one.

| Status | Code | Error text | Meaning |
| --- | --- | --- | --- |
| 200 | | | claimed: `{"name", "userId", "app", "appName", "owner"}`, naming the invite's app |
| 400 | `bad_request` | | no key in the body |
| 403 | `not_invited` | not invited | unknown, expired, canceled or removed, or claimed through an app the device doesn't hold |
| 403 | `used_elsewhere` | this invite was already used by another device | |
| 403 | `other_person` | not invited: this device already belongs to another guest | the invite is for someone else |
| 405 | `bad_request` | POST only | not a POST |
| 500 | `internal` | the connector couldn't record the claim; try again in a moment | always this text, whatever failed |

The owner can't claim (404). A claim is accepted up to an hour after the invite's key
expires, for a device that joined just before.

### `GET /__ovenlight/whoami`

```json
{"role": "owner", "user": "Riley", "userId": "riley@example.com", "app": "interview-coach", "appName": "Interview Coach", "owner": "Riley"}
```

`role` is `owner` or `guest`; `owner` is the label the owner chose. A device that still
carries the app's guest tag but has no claim on it (it never claimed, or the owner
removed it) gets 403 `not_invited`, "You're not invited to this app." The phone probes
this path to learn that a share has ended. Once the device loses the tag, the policy
stops it reaching the node at all. A tagged device without this app's guest tag that
does reach it gets 403 `owner_only`, "This app is private to its owner."

### `POST /__ovenlight/feedback`

Owner and guests, from Ovenlight itself. Body:

```json
{"note": "The timer skips a second", "pageUrl": "https://interview-coach.example.ts.net/q/3", "screenshotPngBase64": "..."}
```

A note, a screenshot or both. Answers `{"id"}` on success, 400 (`bad_request`, or
`too_large` over a size limit) for a bad body, 405 `bad_request` for a method other
than POST, 403 `bad_request` for a request with `Origin` or `Sec-Fetch-Site`, which a web
page sends, 429 (`rate_limited`) over the rate limit or while the same person's previous
upload is still running, and 500 `internal` when the screenshot can't be stored. There
is no 507: a full inbox makes room instead. Limits: [security.md](security.md#feedback).

### Other refusals

Before any of the above, the connector answers with plain text and the code in
`Ovenlight-Error`:

| Status | Code | Text | When |
| --- | --- | --- | --- |
| 421 | `bad_request` | Use this app's tailnet name. | `Host` isn't the node's name |
| 403 | `bad_request` | Cross-site requests are refused. | see [app-contract.md](app-contract.md#cross-site-requests) |
| 403 | `unknown_caller` | Unknown caller. | WhoIs doesn't know the device yet |
| 403 | `not_invited` | You're not invited to this app. | a device with the app's guest tag and no claim |
| 403 | `owner_only` | This app is private to its owner. | anyone else |

Past them, an upgrade other than WebSocket gets 400 `bad_request`, also in plain text.

## Port 8443: the admin API

JSON, for the owner's Ovenlight only: an untagged device of the owner's login (the
recorded one, or else the user who owns the app's node), by WhoIs. Anyone else gets 403
`owner_only`, "the admin API is for the owner only", and the tailnet policy closes the
port to guests as well. Requests that change something need the header
`X-Ovenlight-Request: 1`, which a web page can't add to a cross-site request without a
CORS preflight the API never grants.

| Route | Does |
| --- | --- |
| `GET /v1/apps` | this connector's apps: `slug`, `name`, `url`, `state`, `online`, `shareable`, `guests` |
| `POST /v1/invites` | `{"to": "Sam", "app": "<slug>"}` for someone new; for someone already shared with, `{"person": "<ID>", "app": "<slug>"}`, or `{"to": "Sam", "existing": true, "app": "<slug>"}` by name |
| `DELETE /v1/invites/{id}` | cancel an open invite: an unused one, or a review invite with places left (devices that claimed it keep the app) |
| `GET /v1/guests` | `guests` (removed ones too, with `removedAt`; `lastOpened`, the day the device last opened the app, as `2026-10-13` on the computer's calendar, from `openedSince` on), `invites`, `unclaimed`, `openedSince`, `taggedDevices` (`apps`, `guests` and every tagged device in the tailnet, `tailnet`, as of `lastSync`), `lastSync`, `syncError`; `ovenlight guests --json` prints the same |
| `DELETE /v1/guests/{ref}` | a device ID removes that one device; an invite ID removes the devices that claimed it (several, for a review invite) and cancels the invite if it is still open; a person ID or name removes every device and open invite of that person. `?app=<slug>` limits it to one app; `?by=person` reads `ref` only as a person ID |
| `GET /v1/feedback` | the inbox, newest first; `?app=<slug>`, `?limit=<n>`. `ovenlight feedback --json`, the MCP server's `feedback_list`, and `status` (MCP and `--json`, `sharing.latestFeedback`) use the same view, but fence what guests wrote and every `pageUrl` in `<untrusted-...>` tags. This API sends them unfenced |
| `GET /v1/feedback/{id}/screenshot` | the PNG |

Open invites are the `invites` of `GET /v1/guests`. A review invite stays among them,
`state` `sent`, while it has places left, even once devices have claimed it. Any other
route or method gets a JSON 404 `not_found`.

`POST /v1/invites` answers `{"invite": {...}, "link", "appLink", "appName", "owner",
"devices", "tagErrors", "message"}`: `link` is the universal link (an App Link on
Android) and `appLink` the same invite as `ovenlight://join?...`. `message` is the text
to send, with the link in it, as `ovenlight share` prints it. All three carry the key
and are shown once.
`message` is the only invite wording the phone sends: it composes none of its own, and
without `message` it sends just the link.
`devices` names the person's devices that can now reach the app, and `tagErrors` those
that couldn't be tagged yet. Review invites can only be made in a terminal.

## The version header

The connector sends its build in `Ovenlight-Connector-Version` on admin API answers and
`whoami` answers to the owner. Guests never see it. `ovenlight status --json` reports it
as `daemonVersion` (its `version` is the command's own binary). The value is a release's
version (such as `1.0.0`), else the module version, else `devel-<commit>`, with
`-modified` when the tree had changes. The phone doesn't read it yet.

## Invite links

`ovenlight share` and `POST /v1/invites` produce the same invite in two forms:

```
https://ovenlight.app/join#v=1&control=&key=&owner=&app=&invite=&host=&name=&to=
ovenlight://join?v=1&control=&key=&owner=&app=&invite=&host=&name=&to=
```

The first is the one to send. Its fields ride in the fragment, which browsers never send
to a server, so the key never reaches the website. It opens Ovenlight when it's
installed; otherwise the page offers a way to get the app and, to a guest who has it,
Open in Ovenlight on Android (the second form) or Copy Invite on iPhone, which copies the
first to paste under Join with Invite. `ovenlight share` prints only the first, since any
app on the phone may claim the `ovenlight://` scheme. `POST /v1/invites` answers both.
On Android, Open in Ovenlight hands the fields over as
`intent://join#<fields>#Intent;scheme=ovenlight;package=com.snowyghost.ovenlight;end`,
which reaches Ovenlight as `ovenlight://join#<fields>`. Naming the package keeps any other
app from taking the invite, and the fragment keeps the key out of the system log, which on
Android 10 to 12 records a custom-scheme link's query. Ovenlight on Android takes the
fields from either part.
The fields are form-encoded (`+` is a space) and may come in any order: the connector
sorts them by name.

| Field | Required | Meaning |
| --- | --- | --- |
| `v` | yes | `1` |
| `control` | yes | the owner's control server; must be `https` (debug builds also take `http` on loopback) |
| `key` | yes | an auth key tagged `tag:ovenlight-guest-<slug>`, single use (reusable for a review invite); ASCII, no whitespace, up to 256 characters |
| `app` | yes | the app's slug; letters, digits, `-` and `_`, up to 64 characters |
| `invite` | yes | the invite's ID on the connector; letters, digits and `-`, up to 64 characters |
| `host` | yes | the app node's MagicDNS name, a plain DNS name |
| `owner` | no | how the owner calls themself |
| `name` | no | the app's name |
| `to` | no | the name the owner gave the person invited |

The phone trims labels (`owner`, `name`, `to`), drops control characters, and keeps at
most 64 characters.

The key is a credential until it is used up: by one device, or by 10 for a review
invite. It expires after 7 days (90 days for a review invite).

## Joining, on the phone

1. Ovenlight checks the link (version 1, an https control server, every required field,
   a plain DNS host) and asks "Join Riley's Echo Board?", warning when the control
   server isn't Tailscale's.
2. It starts a guest node with the key and waits for it to run. The key is
   preauthorized, so the node never waits for the owner.
3. It checks that the node is in the network the invite names (its MagicDNS suffix
   matches the part of `host` after the first label). A node this join registered
   elsewhere logs out; one that joined earlier just doesn't claim.
4. It posts the key to `https://<host>/__ovenlight/claim` through that node. An answer
   with the code `used_elsewhere`, `other_person`, `not_invited`, `bad_request` or
   `not_found` is final. Anything else, `unknown_caller` while the connector doesn't
   know the new device yet and any answer without a known code among them, is retried
   for up to 25 seconds; the invite is then kept for Try Again.

A second invite from the same owner (same control server and network) reuses the node
and only claims. The node may not reach the invite's app yet: the invite's host is
absent when the node is connected and has a network map that doesn't list the host at
all (a host listed as offline is present). Ovenlight then claims through the hosts of
that owner's other apps on the phone instead, online ones first, one at a time until
one gives an answer other than a failure to connect, and reads that answer as above.
After a 200 it waits up to about 30 seconds for the invite's host to appear, as the
connector tags the device, then adds the app as any claim does. With no other host
reachable, it claims at the invite's host as before. Accepted invites are saved until
claimed, so a relaunch finishes an interrupted join.

## When sharing ends, on the phone

Ovenlight takes a shared app away when:

- the guest node loses its login (the owner deleted the device);
- `whoami` answers 403 `not_invited` (checked when the app opens, each time Ovenlight
  comes to the front, when the owner's app nodes come or go, a minute after a check an app
  didn't answer, and on a 403 page). Any other answer from `whoami`, even an error page
  that says "not invited" without the code, counts as reaching the app;
- the app's node has been missing from the guest node's peers for about 3 minutes while
  others are listed. When the owner stops sharing one app with a guest who keeps
  another, the device loses that app's tag and can no longer reach the node to hear
  `not_invited`.

Taking an app away closes it without loading it again, wipes its data store, logs the
node out and deletes its state once no app of that owner is left, and tells the guest
"No Longer Shared with You" once.
