docs

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 apps and hold a claim for it. The winner is the candidate with the highest seq; ties fall to the highest at, 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