Auth
Devices
Every install of any that authorizes the same account is a device, identified by its peer id. The account keeps one synced registry of its devices, and a deterministic reader-side election answers "which of my devices runs the agent" with no coordinator.
The registry
The devices dataset lives in the account's tech space — the same place the space list lives — with one row per device, row id = peer id. All fields are synced, so the registry replicates to every device of the account and is invisible to other accounts.
{
"id": "12D3KooW…",
"name": "workstation",
"os": "linux",
"version": "0.9.1",
"apps": { "bao": { "version": "1.2" } },
"activeClaims": { "bao": { "seq": 3, "at": 1755450000 } }
}
| Field | Written by | Meaning |
|---|---|---|
name |
client (PUT /v1/devices/me); seeded from the hostname on first registration |
display name — a user-set name is never clobbered |
os, version |
the server, on every boot | runtime.GOOS vocabulary and the any build |
apps |
client | open slug set; presence means "installed here"; the value is a scalar bag, conventionally {version} |
activeClaims.<slug> |
POST /v1/devices/activate |
this device's claim to be the slug's active instance: seq (max of all visible seqs + 1) and at (unix seconds) |
Online status is deliberately not a field — a liveness bit would churn CRDT history on every heartbeat.
Election
Two devices can claim the same slug concurrently; the design makes that survivable by resolving it at read time:
Candidates for a slug are the live rows that carry the slug under
appsand hold a claim for it. The winner is the candidate with the highestseq; ties fall to the highestat, then to the lexicographically largest peer id.
Consequences:
- Deterministic on converged data. Every reader computes the same winner. The claim is writer-supplied data, not a CRDT version id — version ids are allocated per peer and would split-brain an election.
- Dangling claims never win. Uninstalling an app (or pruning a row) forfeits its claim; there is no un-claim write.
- Nobody deactivates another device. The role moves only through a newer claim or the winner's row losing the app.
The rule is implemented once, inside the SDK, and returned pre-resolved as the active map on GET /v1/devices. Read that map; do not reimplement the rule.
Note. A claim minted on a replica that has not synced the newest claims can lose once heads converge. After sync, observe
active.<slug>and re-claim if the role did not land.
HTTP surface
Account-scoped, behind the /v1 auth guard.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/devices |
mapped rows + active (per-slug winner) + self (this server's peer id) |
| POST | /v1/devices/query |
raw windowed snapshot, standard query body |
| POST | /v1/devices/query/subscribe |
raw live view over SSE, standard frames |
| PUT | /v1/devices/me |
self row only: {name?, apps?}; "apps": {"slug": null} uninstalls |
| POST | /v1/devices/activate |
{app} — claim the slug on this device; also self-heals apps.<app> |
| DELETE | /v1/devices/:peerId |
prune a row |
curl -s -X PUT http://127.0.0.1:7001/v1/devices/me \
-d '{"name": "laptop", "apps": {"bao": {"version": "1.2"}}}'
curl -s -X POST http://127.0.0.1:7001/v1/devices/activate -d '{"app": "bao"}'
curl -s http://127.0.0.1:7001/v1/devices
{ "devices": [ { "id": "12D3KooW…", "name": "laptop", "apps": { "bao": { "version": "1.2" } }, "…": "…" } ],
"active": { "bao": "12D3KooW…" },
"self": "12D3KooW…" }
any devices list
any devices register --name laptop --app bao=1.2
any devices register --remove-app bao
any devices activate bao
any devices remove <peerId> --yes
any devices subscribe
The self-row restriction is structural: the SDK resolves its own peer id for every write, so PUT /me and activate cannot touch another device's row.
A runtime's loop
For an agent runtime keyed on slug S:
| Moment | Action |
|---|---|
Boot; no other row carries apps.S |
claim — the majority case, valid even if a dangling claim points elsewhere |
Boot; active.S is another live device |
nothing — a second install stays passive |
| Active device pruned or app uninstalled | any survivor observing the change claims |
| Manual switch | the user activates on the target device; the newest claim wins by seq |
| Concurrent claims | every reader applies the same tiebreak; the loser sees it on subscribe and stands down |
Upsert self (apps.S) → read active.S and self → claim or stand by → watch POST /v1/devices/query/subscribe and react when the winner moves to or away from self. A UI works off the same endpoint and the same active map. The scheduling section builds on this election.
Pruning is permanent
Record tombstones are sticky: a pruned peer id can never re-register. A device that comes back stays unlisted until it derives fresh peer keys with a new any init. Prune dead devices, not resting ones — and prune from another device: the SDK refuses to delete its own row.
| Status | Code | When |
|---|---|---|
| 404 | device.not_found |
unknown peer id on DELETE |
| 400 | device.self_delete |
DELETE of this server's own row |
| 409 | device.pruned |
a self-row write after the row was pruned |
| 400 | request.invalid_field |
bad slug, or a non-scalar app value |
| 400 | request.missing_field |
empty update, or activate without app |