Collaboration
One-to-one
A one-to-one (direct) space is shared by exactly two identities and derived deterministically from both account keys. Both peers compute the same space id, the same immutable ACL with both as writers, and the same read key — there is no invite token and no owner handshake.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/spaces/one-to-one |
open (or accept, or un-decline) a 1-1 by peer identity → 201 SpaceInfo |
| POST | /v1/spaces/one-to-one/register-incoming |
seed a pending row from an out-of-band signal → 204 |
| POST | /v1/spaces/:spaceId/one-to-one/accept |
accept a pending row → 200 SpaceInfo |
| POST | /v1/spaces/:spaceId/one-to-one/decline |
decline, synced and sticky → 204 |
The peer's identity is the id from their GET /v1/account, exchanged out of band — a link, a QR code, a shared space's members list.
Reach out
curl -s -X POST http://127.0.0.1:7001/v1/spaces/one-to-one \
-d '{"otherIdentity": "A5k…"}'
# → 201 SpaceInfo (status "active", spaceType "any.onetoone")
any one-to-one start <otherIdentity> # aliases: any 1-1, any direct
The space is derived and activated immediately — implicit self-approval. The call is idempotent, and it overrides an earlier decline of the same peer. Classify direct chats in any list on spaceType: "any.onetoone".
| Status | Code | When |
|---|---|---|
| 400 | request.missing_field |
otherIdentity empty |
| 400 | request.invalid_field |
undecodable identity, or your own (details.reason: "self") |
The state machine
Because the ACL is immutable there is nothing to accept cryptographically. "Approve incoming" is a local gate on whether this device materializes and syncs the derived space, surfaced as space statuses:
peer reaches out
│
inbox notifier / register-incoming
▼
one_to_one_pending (device-local row, nothing downloaded)
│ │
accept decline
▼ ▼
active one_to_one_declined (synced, sticky)
│
POST /v1/spaces/one-to-one {otherIdentity}
▼
active
| Status | Meaning |
|---|---|
one_to_one_pending |
someone reached out; row is device-local, no storage materialized |
active |
materialized and syncing |
one_to_one_declined |
suppressed on every device of the account; never auto-resurfaces |
Incoming. The other side learns about a request in one of two ways: automatically, through the coordinator inbox notifier the server starts when a coordinator is configured; or out of band, by the app calling register-incoming with the peer's identity and an optional display hint. Either way a pending row appears.
curl -s -X POST http://127.0.0.1:7001/v1/spaces/one-to-one/register-incoming \
-d '{"peerIdentity": "A5k…", "displayHint": {"name": "Alice", "iconCid": "…"}}'
# → 204, idempotent — no-op when a row already exists
any one-to-one register <peerIdentity> [--name ...] [--description ...] [--icon-cid CID]
Accept / decline by the pending row's space id — the server reads the peer identity off the row, so you never re-derive it:
curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/one-to-one/accept # → 200 SpaceInfo
curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/one-to-one/decline # → 204
any one-to-one accept <spaceId>
any one-to-one decline <spaceId>
Accept is equivalent to re-running the open call with the peer's identity and is idempotent. Decline writes a synced sticky marker; only an explicit later POST /v1/spaces/one-to-one with that identity un-declines.
Why it matters. Nothing is downloaded before you accept. A pending row is just an id and a display hint; reading it never pulls the space ciphertext, so an unsolicited request costs you no storage and leaks nothing about whether you looked.
Discovery
There is no bespoke inbox endpoint. Pending requests are rows in the space list, hidden from the active-only default like deleted spaces:
curl -s "http://127.0.0.1:7001/v1/spaces?status=one_to_one_pending" # snapshot
curl -s -N -X POST http://127.0.0.1:7001/v1/spaces/query/subscribe \
-d '{"dataset": "spaces"}' # live — filter rows on status
any one-to-one pending
The pending row carries the peer's display hint (name / iconCid) so a UI can render "Alice wants to chat" without syncing anything. GET /v1/spaces/:id on a pending row serves the row info only — it never materializes a non-active space.
After activation
Everything else is a regular space: both members are writers, so create objects, send chat and subscribe as usual, and read the two participants through the normal members collection. Two caveats:
- Both participants report
ownRole: "writer", neverowner— the ACL owner slot is a synthetic key nobody holds. Do not gate anything on owner forany.onetoonerows. - A 1-1 carries no chat object of its own. Register one as a bundle with a derived root — the only install shape that cannot fork when the two sides set up while apart.
Note. Deletion is local-only.
DELETE /v1/spaces/:spaceIdoffloads the space on this device and propagates the offload to the account's other devices, but a derived space is not node-owned and is never removed from the network; a laterPOST /v1/spaces/one-to-onere-derives and re-materializes it.