Skip to content

05. Capabilities

Status: Draft.

1. Intent

A device advertises the typed capabilities it offers right now. Peers discover them, request access, and receive explicit grants. Capabilities are live: announced when a session starts, updated while it lasts, withdrawn when it ends, and never persisted by peers. See ADR 0008 and ADR 0016.

2. Capability descriptors

A capability descriptor names a capability type (a reverse-DNS name such as org.ligatureprotocol.capability.clipboard), its scope (user or device, chapter 10), and type-specific parameters. The principal that offers it is the sender of the manifest it arrives in. See org.ligatureprotocol.capability.

A family with two directions declares in its params which of them the device offers: emits and displays for notifications (section 3c), send and receive for files (section 3d), and the same shape for media control (control, be controlled) when it is specified. A device advertises the family if it offers either direction; the peer reads the flags to know what it may send and what it will receive. One stream per session carries both directions for the clipboard and notifications; files, whose payloads are large, use a stream per transfer and per file (section 3d).

2a. Manifests and the live registry

After a session is authenticated and gossip has run (chapter 04, chapter 06) each side opens a manifest stream to the other and sends a manifest (org.ligatureprotocol.manifest): the full list of descriptors it currently offers. While the session lasts it sends a new full manifest on the same stream whenever a capability appears (a display is plugged in) or disappears (a phone goes to the background and loses clipboard access); the newest replaces the last. When the session ends, every capability from that peer is withdrawn. A device's view of the network is therefore the union of the manifests of the peers it is connected to, and a hub's directory is nothing more than that.

A manifest is filtered by audience: to another person's device it lists only the capabilities that person could use (for example share, and device-scoped capabilities the group policy allows them), not everything the device offers. This is the least-exposure rule of chapter 03 applied to metadata. Open question: whether a device may announce a capability on behalf of a peer it is connected to, so that two devices with no direct path can still see each other through a hub.

flowchart LR
    C["connect"] --> A["authenticate<br/>raw public keys"] --> Q{"recognised?"}
    Q -- no --> PO["pairing offer, nothing else"]
    Q -- yes --> G["gossip<br/>revocations first"] --> M["manifest"] --> S["one stream per capability<br/>request, grant, invoke"]
    S -. updates .-> M
    S --> X["disconnect"] --> W["every capability from that peer is withdrawn"]

3. Initial capability families

The first families are: notifications, clipboard, files, share, remote input, media control, telephony/SMS, displays, sensors. Each will become its own spec section with its own schema; share (section 3a), clipboard (section 3b), notifications (section 3c) and files (section 3d) are specified so far.

3a. Sharing between people

org.ligatureprotocol.capability.share is the ad-hoc, AirDrop-like push of text, links or files to a chosen device, usually another person's. It is user-scoped: it belongs to a binding, so a share to the family laptop is a share to a particular account on it. The receiver accepts or declines each share on the receiving device; acceptance is a one-time grant, which the receiver may make standing for a particular sender. The payload travels on the share stream of the pairwise session. files (section 3d) is the transfer of files among a person's own devices. See ADR 0022. Open question: size limits and resumption for large shares, and whether a share may be addressed to several devices at once (which is several sessions under chapter 03).

3b. Clipboard

org.ligatureprotocol.capability.clipboard (schema) is the first family specified. It is user-scoped: a binding offers it, and the same person's other bindings use it without a prompt (section 4). A device advertises it only while it can reach its local clipboard. The descriptor's params carry mimeTypes, the content types the device accepts and can put on its clipboard; maxBytes, the push bound, 512 KiB by default and never more than the frame limit of chapter 03 less 64 KiB of headroom; and fetch, whether the device holds a promise for announced content and fetches it on paste.

A clipboard holds one selection in several representations at once (plain text and HTML from a browser, PNG and TIFF for an image, a URI list for files). On the capability's stream each side sends a set whenever its local clipboard changes: setAt, when the clipboard took the value; text, the plain-text representation, present whenever the source offers plain text so every receiver gets something; parts, the other representations, each a mime with either the bytes or a file descriptor of section 3d; and files, descriptors of a selection of files. A sender includes only parts whose mime the receiver lists, with HTML as the interoperable rich-text form (RTF travels beside it where the source has it, never alone) and no conversion between formats on the wire. A set with neither text, parts nor files says the clipboard emptied.

Push small, announce large. Parts whose bytes fit within the receiver's maxBytes, largest first until the budget is spent, are pushed in the set; the rest are announced, each as a file descriptor with its id, size, mime and a required sha256. Files on the clipboard are always announced. The receiver puts a promise for each announced part on its local clipboard and, only when an application pastes that type, fetches the bytes with a files transfer it starts (section 3d, a request for the id), delivered to the clipboard rather than the download folder, and checked against the announced hash. The sender keeps announced content on offer until its clipboard changes or an hour passes, then withdraws it, so a later request is declined as gone; a receiver whose fetch fails, or whose connection to the sender is lost, drops its promises and leaves its clipboard holding what it was pushed. A device that advertises fetch false is sent only what fits in maxBytes and no file parts.

The receiver applies a set newer than what it holds and ignores one equal to its current content, where equality is the hash of the representation set (each part's mime and the SHA-256 of its bytes, or its announced hash, in mime order, then the files by name), so two devices setting each other never loop. A get on the same stream asks the peer for its current value, which it answers with a set built the same way, or an empty one; a device sends it on connecting so one that was away holds the latest copy. A device learns an id only from a set it was sent: the sender's offers are never listed.

3c. Notifications

org.ligatureprotocol.capability.notifications (schema) carries what an app on one device posts to every other device of the person that shows notifications. It is user-scoped and needs no prompt between a person's own devices (section 4). The descriptor's params say emits (this device forwards what its apps post), displays (this device shows what it is sent) and maxIconBytes; a device that does one but not the other, such as a Mac that may post but not read other applications' notifications (chapter 08), advertises with one flag false. By default every notification a device emits goes to every connected peer that displays; shaping that is a later configuration matter and not part of the capability.

On the stream, post carries an id chosen by the emitting device and stable for the notification's life, the app's identifier and display name, a title, a body, when it was posted, and the hash of the app's icon; a post whose id is already shown replaces it. icon carries that hash, a MIME type and the bytes, sent once per app per session before the first post that names it and cached by the receiver for the session only. dismiss names the emitting device (origin) and the id, since ids are per device, and goes in one hop from the device where the user dismissed to the origin and to every displaying peer: the origin cancels the real notification, the others clear their copies. Only a dismissal the user makes on a device is ever sent; a received dismiss is acted on and never forwarded, and a notification shown on behalf of another device is never emitted again, so nothing cycles. An emitting device does not forward ongoing, silent or summary notifications, nor those of apps the user excluded; what a receiver shows is what it is sent. Nothing about a notification is stored: the receiver's copies, icon cache and id map live for the session (chapter 09). Open question: replies and actions carried back to the origin, and whether presence should ever narrow where a notification is shown.

3d. Files

org.ligatureprotocol.capability.files (schema) moves files among a person's own devices: a file chosen on one lands on another, and a device fetches content another device has offered. It is user-scoped and needs no prompt between a person's own devices (section 4); anything across people is share (section 3a). The descriptor's params say send (this device offers content and answers fetches), receive (it accepts pushed files), maxChunkBytes (the largest chunk it takes, within the frame limit of ADR 0036) and maxStreams (how many files it takes at once from one peer, so a small device may say one and a server many). Nothing here is browsing: a device learns what another offers only from an offer or from an announcement on another capability's stream, never by asking for a list.

A file is described the same way whatever is behind it, a file on disk, bytes in memory or a clipboard selection: an id the sender chose and keeps unique among what it offers, a name that is a base name and never a path, the exact size, a mime type, and optionally the sha256 and the modified time. The definition is standalone so that other families reference it; the clipboard's announcement of large content does.

A transfer is a control stream and one data stream per file, all with the capability type as their purpose. Whoever starts the transfer opens the control stream and chooses a transfer id that every stream of the transfer names: the sender for a push, whose first message is an offer listing the files; the receiver for a fetch, whose first message is a request naming the ids it wants, answered by an offer of those the sender still holds, so an id missing from the offer is gone. The receiver answers an offer with an accept naming the ids it will take, or either side sends a decline with a reason (gone, refused, too-large, no-space, busy). For each accepted file the sender opens a data stream whose first message is begin with the transfer and file id, followed by chunk messages carrying the bytes in order and an end carrying the SHA-256 computed while sending; it then finishes its side. The receiver checks the size against the descriptor and the hash against what it wrote, answers received or failed with a reason on the same stream, and finishes it. Once every accepted file has its answer both sides finish the control stream. The sender keeps no more data streams open to a peer than that peer's maxStreams, and files on different streams are independent of one another.

Either side cancels one file by resetting its data stream, with application error code 16 for a cancel the user asked for and 17 for a failure (the family's codes under ADR 0038), and the whole transfer by resetting the control stream; a stream that ends before end is a failure. The receiver removes whatever partial file it was writing. There is no cancel message, since it would queue behind the chunks it is meant to stop. Progress is each side's own count of bytes against size; nothing on the wire carries it.

A received file goes where the receiving side decides: by default the platform's download folder (chapter 08), or wherever the capability that asked for a fetch wants the bytes. The receiver keeps only the base name, drops control characters and a leading dot, and never overwrites an existing file. A sender keeps what it offers in memory, by id, and withdraws an id when the content is gone; a request for a withdrawn id is declined as gone. Nothing about a transfer is stored beyond the received file itself (chapter 09). Open question: resumption of an interrupted file by an offset in begin, and folders.

4. Requests and grants

A device requests a capability from a peer; the peer (or the user, through the peer) grants or denies. Grants are typed records with scope and expiry. See org.ligatureprotocol.grant.

Defaults follow identity boundaries (ADR 0015): a person's own devices may use that person's user-scoped capabilities without a prompt; anything across identities needs an explicit grant, approved by the person whose capability it is or, for a device-scoped capability, by the person present on the device or the device's policy (chapter 10). Standing grants are stored by the device that issued them and nowhere else (chapter 06). Open question: whether a person may opt their own devices out of the implicit default.

5. Invocation

Once granted, a capability is invoked over its own stream in the pairwise QUIC session (chapter 04), which is also the end-to-end encryption (chapter 03). Each capability family defines its own operations. No wire format is defined in this draft.

6. Bridging

Capabilities may be backed by an external protocol (KDE Connect, LocalSend, Matter, MPRIS). A bridge is just a device that advertises a capability on behalf of something else. See ADR 0010.

7. Open questions

  • Versioning and negotiation of capability types.
  • Schemas are JSON Schema 2020-12 (ADR 0020); the wire encoding is CBOR (ADR 0025).
  • Capability discovery across group identities: what a member can see of another member's devices before asking.