CLEMA protocol

Contact Management

A contact list belongs to one person and is account state: every change to it is an event of the owner's account stream, contact.added, contact.renamed, contact.deleted or contact.requested. The device that makes a change is answered with the entry as it now stands, at the place of its event; the owner's other devices, and a device that was away, receive the event. Contact changes are operations: each carries an operation_id, and a retry answers as the first request did. A change that changes nothing, such as a rename to the name the entry has, makes no event and its answer carries no seq.

Every command and event about an entry carries the same object:

Field Type Description
user_id number The person's numeric ID
nick string The entry's name: the owner's for the person, or a snapshot of the name the person went by when nobody named them
avatar_hash string The person's avatar hash, absent without an avatar and across a block either way
requested_at string When a request was made, for a request
phone string The number in the owner's address book that found the person, on the change an address book made
removed boolean true for an entry that is gone from the list

List Contacts

Request:

{"id": "30", "type": "contact.list"}

Success:

{
  "id": "30",
  "type": "contact.list",
  "ok": true,
  "data": {
    "contacts": [
      {"user_id": 67890, "nick": "Friend", "avatar_hash": "a1b2c3d4"},
      {"user_id": 67891, "nick": "Work Buddy"}
    ],
    "requests": [
      {"user_id": 67892, "nick": "Stranger", "requested_at": "2024-01-15T14:00:00Z"}
    ]
  }
}

requests lists the contact requests waiting for an answer, newest first, and is absent when there are none. A device that follows nothing yet loads the list after asking sync.state where its account stream stands, as with block.list (see Following Streams).

Add Contact

Request:

{
  "id": "31",
  "type": "contact.add",
  "data": {
    "operation_id": "0195d1a0-add",
    "user_id": 67890,
    "nick": "New Friend"
  }
}

Adding somebody who asked takes them in.

Field Type Required Description
operation_id string Yes Idempotency key generated once for this logical operation
user_id number Yes User's numeric ID
nick string No Name for the entry; without one it takes the name the person goes by (see below)
request boolean No true answers the person's request: it takes them in only while they still wait

An answer (request: true) holds only while the request waits. Once it was answered elsewhere — by another device that kept the question on screen — or dropped by a block, the answer changes nothing and fails with contact_not_found, which does not say why. Adding the same person twice in two operations fails the second time with contact_exists.

Success:

{
  "id": "31",
  "type": "contact.add",
  "seq": "42",
  "ok": true,
  "data": {
    "contact": {"user_id": 67890, "nick": "New Friend", "avatar_hash": "a1b2c3d4"},
    "presence": {"user_id": 67890, "online": true, "state": "available", "time": "2024-01-15T14:00:00Z"}
  }
}

The answer, and the contact.added event of the owner's other devices, carry the entry and the contact's presence as presence.get would report it to the owner.

An entry nobody named — added without a nick, or a request — takes the name the person goes by: their profile nickname, else their first and last name, else their numeric ID, cut to 64 characters. It is a snapshot, as a name in an address book is: the person renaming themselves later does not rename them in anybody's list, and only the owner renames the entry.

Failure:

{
  "id": "31",
  "type": "contact.add",
  "ok": false,
  "error": {
    "code": "user_not_found",
    "message": "User does not exist"
  }
}

Rename Contact

Request:

{
  "id": "32",
  "type": "contact.rename",
  "data": {
    "operation_id": "0195d1a0-rename",
    "user_id": 67890,
    "nick": "Best Friend"
  }
}

Success:

{"id": "32", "type": "contact.rename", "seq": "43", "ok": true, "data": {"contact": {"user_id": 67890, "nick": "Best Friend", "avatar_hash": "a1b2c3d4"}}}

Delete Contact

Request:

{
  "id": "33",
  "type": "contact.delete",
  "data": {
    "operation_id": "0195d1a0-delete",
    "user_id": 67890
  }
}

Success:

{"id": "33", "type": "contact.delete", "seq": "44", "ok": true, "data": {"contact": {"user_id": 67890, "removed": true}}}

The same command turns a contact request away. Either way the person is out of the list for good: no request and no address book brings them back, only adding them by hand does. contact_not_found answers somebody who is neither a contact nor a request.

With "request": true the command only answers the person's request, as contact.add does: it turns them away while they still wait, and fails with contact_not_found once the request was answered elsewhere or dropped — a contact taken in meanwhile stays one.

Contact Events

{"type": "contact.renamed", "seq": "43", "data": {"contact": {"user_id": 67890, "nick": "Best Friend", "avatar_hash": "a1b2c3d4"}}}
{"type": "contact.deleted", "seq": "44", "data": {"contact": {"user_id": 67890, "removed": true}}}

contact.added carries the entry with the contact's presence, as the answer to contact.add does; contact.renamed, contact.deleted and contact.requested carry the entry alone. An event is read as its recipient sees the person when it is delivered: the name the change gave the entry, and the avatar and presence the person has then. A block takes the blocked person out of the blocker's list without a contact.deleted of its own: clients apply block.added to the list.

The other party hears nothing: a contact list is one person's, and being in somebody's list is not something the protocol reports.