Contents
↑ top

Rendered from shazow/apron@f164f0b · Markdown

Apron Chat Protocol

Protocol History v9 (2026-10-27)HISTORY.md →

Apron Chat Protocol is for groups whose members mostly trust each other: a team, a household, or a set of bots and the people who run them. That trust keeps the protocol small. There is no public federation, spam defense, or sybil resistance to coordinate. The server is the authority on identity, membership, and history, and a client can be little more than a renderer of what the server sends.

The goal is many Apron Chat apps and servers that work with each other. A client can connect to any Apron server, and a server can be a full chat service, a local bridge to another protocol, a coding harness, or a script that posts deploy notices. To make that practical, the protocol is incremental. The mandatory core (§3) is enough for a working chat, in about a hundred lines of code. Everything else is an optional capability (§4) that a server advertises, with a fallback for clients and servers that lack it.

Example of a valid session:

// <- server greeting with capabilities and auth schemes
{"method": "server", "params": {"apron": 9, "capabilities": ["rooms"], "auth": ["guest", "token"]}}

// -> guest auth, requesting a display name (the server may choose something else)
{"method": "auth", "id": "c1", "params": {"scheme": "guest", "name": "Ada"}}

// <- server assigns the identity
{"id": "c1", "result": {"you": {"user_id": "guest_1234", "name": "Ada"}}}

// -> client requests the joined rooms and their members (server may ignore the filters)
{"method": "room_list", "id": "c2", "params": {"filter": "joined", "members": true}}

// <- one room
{
  "id": "c2", "result": {
    "joined": [
      {
        "room_id": "general",
        "title": "General",
        "members": [{"user_id": "guest_1234", "name": "Ada"}]
      }
    ]
  }
}

// -> post a message
{"method": "message", "id": "c3", "params": {"room_id": "general", "body": {"text": "Hello"}}}

// <- server broadcasts to everyone in the room
{
  "method": "message", "params": {
    "message_id": "1724803200042", "log_id": "1724803200042", "room_id": "general",
    "from": {"user_id": "guest_1234", "name": "Ada"},
    "body": {"text": "Hello"}
  }
}

// <- confirmation, after the broadcast is complete
{"id": "c3", "result": {"message_id": "1724803200042"}}

This example uses capability rooms (§4.2) to list rooms. Without it, the client skips room_list and learns the room from the broadcast.

Required

1. Transport & framing#↑ contents

Apron runs over any transport that carries whole JSON messages in both directions. The framing borrows from JSON-RPC: a client matches each reply to its request, and every other frame is a one-way notification.

  • WebSocket is the reference transport. Other transports work if they deliver whole frames.
  • A frame is one JSON object: one WebSocket text message, or one line on a byte-stream transport such as TCP or stdio (newline-delimited JSON).
  • Frames use the shape of JSON-RPC 2.0 requests, responses, and notifications (method, params, id, result, error). They omit the "jsonrpc": "2.0" key, and every id is a string.
  • Implementations MUST ignore unknown keys, and MAY drop them.
  • Servers MAY process requests concurrently and reply in any order. If a client needs one request applied before another, it waits for the first reply.
  • On one connection, a result includes the effect of every notification sent before it. Clients apply frames in the order they arrive.
  • When a request causes notifications on its own connection, the server sends them before the result, such as the broadcast of a posted message. A sign-in is the only exception (§3.2.2).
  • Server announcements and broadcasts are notifications.
  • Servers reply error/unsupported to requests with unknown methods.
  • Notifications with unknown methods are ignored.
  • A request that depends on an unknown name, such as an auth scheme or a kind, fails with invalid_params.
  • Receivers ignore an unknown name in any other frame, or show the fallback of its section.
  • Implementations SHOULD accept frames up to 256 KiB. They MAY reject a larger request with error/too_large, and MAY drop a larger notification.
  • A server MAY advertise ping (§3.1). Clients that support it then send exactly {"method":"ping"} at that interval. The server answers with {"method":"pong"}, also before authentication. A server MAY close a connection that sent pings and then stopped.

1.1 Envelope and replies#

A request carries a string id (§2.4), and gets exactly one reply. A frame with a method and no id is a notification, and gets no reply.

Each method is either a request or a notification. A method is a request when its sender needs the reply: data, a confirmation that a change was applied, or an error. A method that only reports transient state is a notification. These are the core methods, and capabilities add others (§4):

sent by requests (with id) notifications (without id)
client auth, me, message ping
server none server, user, message, pong

A server MAY ignore a request method sent without an id. It MAY ignore the id of a notification method, and handle the frame as a notification.

// ->
{
  "method": "message", "id": "c42", "params": {
    "room_id": "general",
    "body": {"text": "hello", "format": "plain"}
  }
}
// <-
{"id": "c42", "result": {"message_id": "1724803200042"}}

A notification:

{"method": "activity", "params": {"room_id": "general", "typing": 8}}

A successful reply has a result object, which is {} when empty. An error reply has an error object with an integer code, a string message, and optional data:

{"id": "c42", "error": {"code": -32601, "message": "Unsupported method"}}
{"id": "c43", "error": {"code": -32002, "message": "Too Many Requests", "data": {"retry_after": 30}}}

This document writes error/<name> for these codes:

code name meaning
-32700 parse_error invalid JSON
-32600 invalid_request invalid envelope
-32601 unsupported method/capability not implemented
-32602 invalid_params invalid method parameters
-32603 internal_error internal server error
-32001 denied authentication/authorization failure
-32002 retry_after rate limited; data.retry_after is a delay in seconds
-32003 too_large message too large

Clients identify an error by its code only. message is free text for people. Servers SHOULD make it specific enough to show as is, such as "Session expired; sign in again" rather than "Denied".

These rules apply to every request:

  • A request that refers to an ID that does not exist, or that the user cannot see, is invalid_params.
  • Servers MAY normalize, limit, or reject any value that a client sends, and MAY fill in values that it leaves out. Clients use what the server sends back, not what they sent.
  • A rejected value is invalid_params, or too_large for its size. denied is for a well-formed request that the user or server does not allow, such as one past a count limit that the server sets.

Other application errors MAY use JSON-RPC codes that are not reserved. A valid notification never gets an error reply.

An error that is not a reply to a request has no id. The server MAY close the connection after it sends one:

{"error": {"code": -32002, "message": "Server at capacity", "data": {"retry_after": 30}}}

After retry_after, clients wait for the delay before they reconnect. After denied, clients do not reconnect automatically. They wait for the user to act.

1.2 Retries and deduplication#

A retry SHOULD keep the same id, method, and params, also across reconnects. A new operation MUST use a new id. Changing the params makes a new operation. Deduplication ignores the order of object keys.

Servers SHOULD deduplicate by (user_id, id), using the authenticated user_id:

  • The server does not execute or broadcast a duplicate again. The result of a duplicate shows the current state (§1).
  • An id used again with a different method or params is invalid_params.
  • The server handles concurrent duplicates as one request.

How long a server remembers an id is implementation-defined. Before authentication there is no user_id, so a request id applies only to its connection. The server MUST execute auth on each connection, also when it repeats an id (a deduplicated result would sign in a connection that presented no credentials).

2. Identifiers#↑ contents

Apron keeps state simple by giving every change a place in one log. Each logged record is the complete state of one thing at one moment. A client can apply records in any order and from any source, and still end in the same state. This section defines the IDs that make this work.

All IDs are strings.

2.1 log_id#

A log_id is the position of one change in the server's append-only log.

  • Decimal string of Unix epoch milliseconds, such as "1724803200042".
  • One strictly increasing sequence per server, covering every record.
  • Its value is the commit time. If the clock has not passed the previous log_id, the value is the previous log_id + 1.
  • Clients MAY show it as a time. Other timestamps, such as seen (§4.5.2), use the same format.
  • It is positive and below 2^53. Compare values as numbers. Clients MAY parse them as integers.
  • Unique within one server only.
  • A room's log is the subsequence of records that touch that room (§4.3.1).
  • Reference generator: str(max(unix_epoch_ms(), last_id + 1)).

2.2 message_id#

A message_id is the permanent identity of a message.

  • Equal to the log_id of its creation; never changes.
  • Unique across rooms.
  • log_id == message_id marks the creation; later changes have greater log_ids.

2.3 Records and replay#

Every record is the complete state for its key at its log_id. Capabilities add other records, each with its own key:

record key
room record room_id
message snapshot message_id
  • For each key, clients keep the record with the greatest log_id. The source (live, history, or embedded) and the arrival order do not matter.
  • At an equal log_id, a tombstone wins (§4.4).
  • Server fields are read-only: a request never changes them.

2.4 Opaque IDs#

room_id, user_id, and request id are opaque.

  • Any string. The side that creates the ID chooses it.
  • room_id and user_id are server-assigned. They MUST NOT be empty, or contain whitespace or control characters.
  • Request ids SHOULD be random. They identify operations, not log positions.

3. Core#↑ contents

The core alone is a basic but complete chat. Every server implements it, so every client can count on it. It covers the greeting, sign-in, identity, rooms, and messages. Capabilities add the rest (§4).

3.1 server frame#

class Server {
  apron: number;                // protocol version
  auth: string[];               // at least one scheme, in preference order (§3.2)

  agent?: string;               // implementation/version string, for debugging
  capabilities?: string[] = []; // §4
  welcome?: string;             // user-readable CommonMark details and auth instructions
  signup?: string[];            // schemes that create accounts; absent: same as auth (§3.2)
  ping?: number;                // seconds between client pings (§1)

  // "status" capability
  status?: string[];            // optional chosen status values accepted (§4.5.1)

  // push, without a capability
  push?: object;                // push kinds and wake scopes; enables push (§4.9)

  // extensions
  ext?: object;                 // extension data (§4.12)
}

When the server accepts a connection, it MUST send a server frame immediately. The client does not send a hello first.

{
  "method": "server", "params": {
    "apron": 9,
    "agent": "impl-name/1.0",
    "capabilities": ["history", "edit"],
    "auth": ["token"]
  }
}

The server MAY send a new server frame at any time. Each one fully replaces the previous one. Clients update their feature UI, but MUST NOT remove content that they already show.

3.2 Authentication#

A connection starts without an identity. The client picks one of the schemes that the server offers and sends auth, and the server answers with who the connection now is. Schemes range from guest access with no credentials to passkeys.

class Auth {
  scheme: string;               // one of server.auth or server.signup

  name?: string;                // requested display name
  user_id?: string;             // requested user_id
  agent?: string;               // implementation/version string, for debugging

  // "token", "email", and "external" schemes
  token?: string;

  // "webauthn" scheme (§4.10)
  action?: "register" | "login";
  step?: "begin" | "finish";
  challenge_id?: string;        // finish
  credential?: object;          // finish

  // "email" and "external" schemes (§4.11)
  state?: string;
  email?: string;               // email
  return_url?: string;          // external
}

class AuthResult {
  you?: User;                   // absent: nothing was authenticated
  token?: string;               // save the latest; it replaces any earlier one

  // "webauthn" scheme, begin step
  challenge_id?: string;
  public_key?: object;          // WebAuthn options

  // "external" scheme, proposal
  url?: string;
}
// ->
{
  "method": "auth", "id": "c1", "params": {
    "scheme": "token",
    "token": "...",
    "name": "Alice",
    "agent": "bottomless-web/0.3"
  }
}
// <-
{"id": "c1", "result": {"you": {"user_id": "alice", "name": "Alice"}}}
  • params.scheme selects the scheme:
    • guest: no credentials. The server assigns the identity. Suggested convention: guest_ plus a global counter, such as guest_1234 (§3.3).
    • token: a bearer string. This is the reference default.
    • webauthn: optional passkey scheme (§4.10).
    • email: optional sign-in by a code sent to an email address (§4.11).
    • external: optional sign-in on a web page of the server (§4.11).
  • A scheme that this document defines but the server does not offer is unsupported.
  • Which credentials an offered scheme accepts is server policy. For example, a guest-access server that offers token MAY accept any token.
  • With server.signup, server.auth lists the schemes that sign in, and signup lists the schemes that create an account. For example, "auth": ["token"], "signup": ["guest"] means: join as a guest, then sign in with the token that the server issues.
  • server.welcome tells users how the schemes of this server fit together, such as "Join as a guest. Your client keeps a token to sign you back in." Clients show it on the sign-in screen as CommonMark (§3.5.2), and never parse it.
  • name and user_id are optional requests, and work with any scheme. you is what the server assigned.
  • Servers SHOULD NOT assign a user_id that was used before, unless they authenticate its owner.

3.2.1 Bearer tokens#

A successful auth result MAY carry token. The client uses it with scheme: "token" on later connections.

  • Servers also send it in reply to scheme: "token" when they rotate the token that the client sent.
  • Clients save the latest token, which replaces any earlier one, and reconnect with it.
  • A server MAY reject a token that it replaced. Expired and revoked tokens are denied.
  • If a token is the only way back into an account, the client SHOULD encourage its user to add another scheme that the server offers.
  • Lifetime, rotation, and revocation are server policy.

3.2.2 Order#

auth is a barrier. The server finishes an auth request before it processes any later frame on the connection.

  • Clients MAY send auth before server arrives. They MAY send other requests right after auth, without waiting for its result.
  • Later requests use the authentication that the auth left on the connection. An auth that authenticates nothing, such as a failure, changes nothing.
  • Before a successful auth, other requests get denied. The server ignores other notifications, except ping (§1).
  • The server MAY send notifications before auth, such as a ~private welcome (Appendix A.1, Appendix B).
  • A sign-in is an auth that gives the connection its first user, or a different one.
  • Every notification that a sign-in causes on its connection comes after the auth result.

3.3 Identity#

The server decides who everyone is. A client never asserts its own identity. It learns it from you, and learns about other users from the user objects that the server sends.

class User {
  user_id: string;

  name?: string;                // absent: shown as user_id
  avatar?: string;              // image URL (§4.8.6)
  roles?: string[];             // server-defined labels, such as "admin" or "bot"

  // "status" capability
  status?: string;              // current objects only; chosen in `you`, shown in others (§4.5.2)
  seen?: string;                // log_id format; with a shown "offline" (§4.5.2)

  // "ext" capability
  ext?: object;                 // §4.12
}

Every message carries its author in from:

"from": {"user_id": "alice", "name": "Alice"}

3.3.1 User objects#

Every identity on the wire uses the User shape, such as you, new, old, and from. user_id is required and stable. The other fields are optional.

There are two kinds of user objects:

  • Current objects describe the user now, such as you in auth and me results, and you and new in a user notification.
    • you in a result is complete: it carries every profile field that the server publishes.
    • A user notification carries user_id and at least the fields that changed. It carries a cleared field as its empty value.
  • Recorded objects describe the user at the time of a record, such as the from of a message. They carry user_id, and SHOULD carry name. Later snapshots MAY keep them unchanged.

Clients keep one user object for each user_id:

  • Clients replace the kept object with a complete object.
  • Clients merge every other current object into the kept object. Each field that it carries replaces the kept value. An empty value ("", [], {}) clears the field. Fields that it does not carry stay the same. null is an ordinary value.
  • Clients never merge recorded objects.
  • Clients show each field from the kept object. If the kept object does not have a field, they use the recorded object in the frame.
  • An empty or unknown name shows as user_id.
  • Clients SHOULD show a user as Name (@user_id) where there is space. They MUST do this when they know another user_id with the same name.

3.3.2 Profile#

A me request changes the user's own profile after authentication. It merges by the same rules as a current object. Clients can set name and avatar. roles is not settable. Servers announce a cleared field as its empty value:

// -> rename and clear the avatar
{"method": "me", "id": "c2", "params": {"name": "Alice ⚙", "avatar": ""}}
// <-
{"id": "c2", "result": {"you": {"user_id": "alice", "name": "Alice ⚙", "avatar": ""}}}

3.3.3 Changes#

After authentication, the server MAY send a user notification when a user object changes. It sends you to the user's own connections, and new to others who share a room with the user. new is the current object of the user.

// <- to the user's own connections
{"method": "user", "params": {"you": {"user_id": "guest_1234", "name": "Ada L"}}}
// <- to others who share a room with the user
{"method": "user", "params": {"new": {"user_id": "guest_1234", "name": "Ada L"}}}
// <- user_id change
{
  "method": "user", "params": {
    "new": {"user_id": "ada", "name": "Ada"},
    "old": {"user_id": "guest_1234", "name": "Ada L"}
  }
}
  • you replaces the identity of the connection. If its user_id changes, the connection acts as the new identity. It receives the deliveries for the rooms of the new identity (§3.4). Clients derive per-user state again.
  • old means that the same account changed its user_id from old.user_id to new.user_id. An example is a guest that becomes a new account.
  • When a connection signs in (§3.2.2) to an existing account, servers announce that the previous identity left. They do not send old.
  • After a user_id change, logged records keep the old user_id. Clients MAY treat it as the new identity.
  • Servers SHOULD NOT give a retired user_id to another user.

3.3.4 Roles#

Bots and agents are ordinary senders. Servers MAY assign roles, which are labels such as "admin", "moderator", or "trusted". Suggested convention: "bot" marks an automated user, and "admin" marks a server operator.

  • Clients show roles separately from the name, such as badges. They show unknown roles as written.
  • Roles are only for display, and give no permissions on the client.
  • A role change is a user notification, like any profile change.
// <- Ada becomes a moderator, to her own connections
{"method": "user", "params": {"you": {"user_id": "ada", "roles": ["moderator"]}}}
// <- to others who share a room with her
{"method": "user", "params": {"new": {"user_id": "ada", "roles": ["moderator"]}}}

3.4 Rooms#

Rooms are where messages live. Capability rooms (§4.2) adds the ways to find, join, and create them, and adds threads.

class Room {
  room_id: string;

  title?: string;               // absent: shown as room_id
  description?: string;         // CommonMark by convention

  // "rooms" capability
  parent_room_id?: string;      // marks a thread; fixed once the room is created
  private?: boolean = false;    // members only (§4.2.4)
  members?: User[];             // only in some frames
  member_count?: number;        // total members, when `members` is truncated

  // "history" capability: required when advertised
  log_id?: string;
  prev_log_id?: string;         // optional (§4.3)
  latest_log_id?: string;
  history_log_id?: string | null;

  // "ext" capability
  ext?: object;                 // §4.12
}

A room is a log with a room_id that the server chooses. Every message refers to its room (§3.5). A server without capability rooms MAY have only one room. Clients learn its room_id from its messages. If a client has no other data about a room, it uses the room_id as the title.

A connection receives the deliveries of each room that its user joined: its messages and other records. Without capability rooms, that is every room. System notices go by their scope instead (Appendix A.1).

3.5 Messages#

Messages are the center of the protocol. A client sends only what it controls. The server fills in the rest, and broadcasts the result as a snapshot that every client keeps by the same rule as any other record.

class Message {
  body?: {                      // required on creation; absent on tombstones (§4.4)
    text?: string = "";
    format?: "plain" | "markdown" = "plain";
    mentions?: string[] = [];   // user_ids
    embeds?: Embed[] = [];      // §4.8
  };

  room_id?: string;             // absent: the server's default room
  reply_to?: { message_id: string } | Message;   // bare from clients

  // set by the server
  message_id?: string;          // absent on transient notices; sent by clients only to save (§4.4)
  log_id?: string;              // absent on transient notices
  from: User;

  // "history" capability
  prev_log_id?: string;         // set by the server

  // "edit" capability
  prev_room_id?: string;        // set by the server, only after a move
  deleted?: boolean = false;

  // "ext" capability
  ext?: object;                 // absent on tombstones (§4.12)
}

A message is one object. The server broadcasts the complete object as an authoritative snapshot at one log position.

// ->
{
  "method": "message", "id": "c3", "params": {
    "room_id": "general",
    "body": {"text": "hello *world*", "format": "markdown"}
  }
}
// <- broadcast to every client in the room, including the sender
{
  "method": "message", "params": {
    "message_id": "1724803200042", "log_id": "1724803200042", "room_id": "general",
    "from": {"user_id": "alice", "name": "Alice"},
    "body": {"text": "hello *world*", "format": "markdown"}
  }
}
// <-
{"id": "c3", "result": {"message_id": "1724803200042"}}
field set by meaning
message_id server permanent ID (§2.2)
log_id server position of this snapshot in the log (§2.1)
prev_log_id server optional; this message's previous snapshot (§4.3.1)
prev_room_id server the previous snapshot's room, when it differs (§4.4)
from server author identity (§3.3), preserved across changes
room_id client the room the message is in; omitted, the default room
body client text, format, embeds, mentions
reply_to client optional message object referring to the message replied to
deleted client tombstone marker, default false (§4.4)
ext client optional extension data; merges by key (§4.12)

3.5.1 Creating#

  • body is required when a client creates a message. text defaults to "". embeds and mentions default to []. format is "plain" or "markdown", and the default is "plain".
  • A request without room_id posts to the default room of the server. The snapshot refers to that room.
  • Posting does not require joining the room.
  • A new message with no text and no embeds SHOULD NOT be logged or broadcast. Its result is then {}.
  • The result is {"message_id": "..."}, the permanent ID. It is the confirmation.

3.5.2 Rendering#

  • Clients MUST render both formats. "markdown" text is CommonMark. Clients MAY render common extensions, such as tables, and MAY render soft line breaks as hard breaks.
  • Other fields that this document calls CommonMark follow the same rules. Clients MAY show them as plain text.
  • Clients MUST disable raw HTML in CommonMark, or sanitize it with the same allowlist as HTML embeds (§4.8).
  • Clients MUST render an embed of unknown kind from its og, if it has one. Otherwise they render a labeled fallback card: the kind name, plus url or plain text if present.

3.5.3 Delivery#

  • The broadcast goes to the connections that receive the deliveries of the room (§3.4).
  • On a live connection, servers deliver the snapshots of each room in ascending log_id, and each snapshot one time for each connection.
  • Snapshots replace earlier ones under the replay rule (§2.3), also for messages that the client has not loaded.
  • Servers MAY publish a snapshot of any message at any time. Clients MUST accept it, also without capability edit.
  • A message notification without message_id is a transient notice, such as a private system notice (Appendix A.1). Clients show it, but never install it as a snapshot.

3.5.4 References#

  • reply_to holds a message object. Clients send it bare, with only message_id. Servers MAY send a full snapshot, and clients install it like any other. Embedded snapshots carry a bare reply_to.
  • reply_to.message_id MUST refer to an existing message other than the message itself. It MAY be in another room.
  • Clients show the replying message even when the target is missing or deleted.

3.5.5 Mentions#

A message lists the users that it mentions in body.mentions. Usually its body.text also shows each one (Appendix A.3):

"body": {
  "text": "@guest_1234 can you check `@property` in https://example.com/@bob?",
  "format": "markdown",
  "mentions": ["guest_1234"]
}
  • Servers and clients treat only the users in mentions as mentioned. The content of text does not change this.
  • Servers never parse text to find mentions.
  • When the user picks someone to mention, the composer adds that user to mentions, and writes the mention in text.
  • Mentions that notify a whole room are not defined.

3.6 Core conformance checklist#

Every server:

  1. Sends a server frame on connect (§3.1).
  2. Accepts at least one auth scheme, replies with you, and finishes auth before processing later frames (§3.2.2).
  3. Accepts message without room_id into its default room (§3.5.1).
  4. Accepts message creation: replies with message_id and broadcasts the snapshot to the room (§3.5).
  5. Replies error/unsupported to unknown requests, including message with a message_id when capability edit is absent; ignores unknown notifications.
  6. Follows §1 for framing and retries and §2 for identifiers.

A minimal client (informative):

  1. Authenticates with auth once server arrives, and posts with message (§3.2, §3.5).
  2. Groups messages by room_id, titling a room by its room_id unless it knows its record (§3.4).
  3. Keeps each message's snapshot with the greatest log_id, from any source, and renders tombstones (§2.3, §3.5).
  4. Keeps one user object per user_id, merged from current objects and falling back to from, and shows Name (@user_id) when two user_ids share a name (§3.3.1).
  5. Renders plain and markdown text with raw HTML disabled, and a fallback card for embed kinds it does not support (§3.5.2).
  6. Matches replies by id, acts on error codes, and ignores unknown notifications and keys (§1).

Optional

4. Capabilities#↑ contents

Everything beyond the core is optional. Each capability adds one coherent feature, such as history, rooms, or reactions, and says what a client does without it. So a client written for the core keeps working on any server, and a server can grow one capability at a time. The sections start with the capabilities closest to the core. A section whose heading is a capability name defines that capability.

server.capabilities lists the optional features of the server. A capability shows support, not permission. Servers still apply local policy to each request. Nothing is negotiated. Clients ignore capabilities that they do not know (§1). If the server does not have a capability, the client uses the fallback in this table:

capability adds fallback spec
command commands from client to server, such as /kick no commands §4.1
rooms room_list, room_join, room_leave, room_set, updates one default room, no threads §4.2
history page and recover a room's log session-only scrollback §4.3
edit message saves: edit, move, delete no edit/move/delete UI §4.4
status user status, idle connections, and mutes no online indicators §4.5
activity typing and read markers no typing or read indicators §4.6
reactions emoji reactions on messages reaction controls hidden §4.7
embed:upload upload embeds: files the sender writes over HTTP no attachments §4.8.4
embed:stream live-streamed text in a message post the finished text §4.8.5
ext extension data kept on users, messages, and rooms servers may drop ext §4.12

Some features have no capability:

  • Embeds other than upload and stream are body content (§4.8).
  • server.push enables push (§4.9).
  • server.auth enables passkeys, and email and external sign-in (§4.10, §4.11).
  • server.ping enables liveness checks (§1).

The features in this section add these methods (§1.1):

sent by requests (with id) notifications (without id)
client command, room_list, room_join, room_leave, room_set, history, status, reactions, push_register, push_unregister activity
server none room_update, status, activity, reactions

A name that this document does not define is an extension name. It starts with ext:, such as the capability ext:irc. This applies to every list of names that an extension can add to, such as methods, capabilities, auth schemes, and kinds. This document never defines a name with that prefix. Display values, such as a status or a role, are free text and take no prefix.

Six frame patterns cover everything that is logged or announced:

  • Records (room records, message): the complete state at a log_id (§2.3).
  • Per-user state (reactions, memberships): the user and their complete state for a scope. The newest wins for each user. Logged.
  • Activity (activity): from and changes to the transient state of the user. Absent fields do not change it. Not logged.
  • Announcements (server): not logged, and sent again in full. Each one replaces the last.
  • Room updates (room_update): changes to the rooms of the user. They carry room records and memberships, never a full list (§4.2.3).
  • Users (user, and every current user object): not logged. Each one merges into the kept object (§3.3.1).

4.1 command#

Adds commands from client to server, such as /kickWithout it no commands

729 words

Adds the command request, which sends an instruction to the server. It takes the same params as a new message (§3.5). Only its effect is different:

  • body.text is the command line as the user typed it, slash included. The server parses it.
  • If composer text starts with /, clients send it as a command. If it starts with //, clients send it as a message that starts with /.
  • A command is never logged, broadcast, or saved. It has no message_id. A command with message_id or deleted is invalid_params.
  • mentions, reply_to, embeds, and ext are arguments. Mentioned users are not notified, and ext is not kept.
  • The result is {}, or {"embeds": [...]} with write URLs for new upload embeds (§4.8.3). A failure is an ordinary error, and the client shows its message.
  • The server replies as needed with system notices (Appendix A.1). It sends ~private to the sender, ~room to the room, and ~server to everyone.
  • Effects arrive as the frames they cause, such as room_update.
  • Commands are for what a server provides beyond this spec, such as /ban with the server's own moderation rules.
  • Which commands exist, their arguments, and who may use them are server policy.
  • Suggested convention: commands list and remove the sign-in methods of an account (§3.2).
  • Servers that support commands SHOULD provide /help. It replies with a ~private notice. The notice lists the commands that the sender can use, with their arguments and what they do.
  • Clients MAY handle a command themselves when it matches a request, such as /nick as me or /join as room_join. They send the other commands as command.
// -> remove a user from the room; mentions refer to the target
{
  "method": "command", "id": "c30", "params": {
    "room_id": "general",
    "body": {"text": "/kick @guest_1234 spamming", "mentions": ["guest_1234"]}
  }
}
// <- to guest_1234's connections; the room's other members get the membership alone
{
  "method": "room_update", "params": {
    "left": [{"room_id": "general"}],
    "memberships": [
      {"log_id": "1724803900001", "room_id": "general", "members": [{"user": {"user_id": "guest_1234", "name": "Guest"}, "joined": false}]}
    ]
  }
}
// <- to the room's remaining members: the notice
{
  "method": "message", "params": {
    "message_id": "1724803900002", "log_id": "1724803900002", "room_id": "general",
    "from": {"user_id": "~room", "name": "General"},
    "body": {"text": "@guest_1234 was removed by @alice: spamming"}
  }
}
// <- then the result
{"id": "c30", "result": {}}

// -> answer an agent's permission prompt by replying to it
{
  "method": "command", "id": "c31", "params": {
    "room_id": "agent", "reply_to": {"message_id": "1724803900000"},
    "body": {"text": "/approve"}
  }
}
// <- or, from a user without the right
{"id": "c31", "error": {"code": -32001, "message": "Only the session owner can approve"}}

// -> list the available commands
{"method": "command", "id": "c32", "params": {"room_id": "general", "body": {"text": "/help"}}}
// <- to the sender only
{
  "method": "message", "params": {
    "room_id": "general",
    "from": {"user_id": "~private", "name": "Only you"},
    "body": {
      "text": "- `/kick @user [reason]`: remove someone from this room\n- `/avatar` with an image: set your avatar",
      "format": "markdown"
    }
  }
}
// <-
{"id": "c32", "result": {}}

// -> set an avatar from an upload (§4.8.6)
{
  "method": "command", "id": "c33", "params": {
    "body": {"text": "/avatar", "embeds": [{"kind": "upload", "title": "me.png"}]}
  }
}
// <-
{
  "id": "c33", "result": {
    "embeds": [{"embed_id": "embed_1300", "kind": "upload", "write_url": "https://chat.example/w/9c1e…"}]
  }
}

4.2 rooms#

Adds room_list, room_join, room_leave, room_set, updatesWithout it one default room, no threads

2,415 words

Lets users find, join, and create rooms and threads. It adds the requests room_list, room_join, room_leave, and room_set, and the notification room_update. Visibility and membership are server policy.

A room record describes one room, as room_list, room_update, and history carry it:

{
  "room_id": "general", "log_id": "1724800000000", "title": "General",
  "description": "Ops chatter: deploys, alerts, *incidents*.",
  "latest_log_id": "1724803200042", "history_log_id": "1724800000000"
}

In the "set by" column, server fields are read-only (§2.3), and room_set replaces client fields (§4.2.4). delivery fields describe the room as this client sees it. They are not logged. Clients always use the latest value, even if log_id did not change. A frame that omits a delivery field does not change it.

field set by meaning
room_id server required
log_id server position of this room record (§2.1)
prev_log_id server optional; this room's previous record (§4.3.1)
parent_room_id client optional; fixed at creation; marks a thread (§4.2.4)
private client optional; visible only to members (§4.2.4)
title client optional plain string; absent falls back to room_id
description client optional string, CommonMark by convention: what the room is about
ext client optional extension data; merges by key (§4.12)
latest_log_id delivery greatest log_id in the room's log, memberships included
history_log_id delivery inclusive lower bound of retrievable history, or null if none
members delivery on request in room_list, and in room_update joined (§4.2.1)
member_count delivery optional; how many users have joined, when members is truncated (§4.2.1)

A room record is complete (§2.3). A server or client field that it omits is cleared.

description is the summary of the room, such as its purpose or the state of its conversation. Users who can edit the room can change it with room_set. It is CommonMark (§3.5.2).

A thread is a room with a parent_room_id. A client that ignores this field shows threads as ordinary rooms. A client that uses it groups threads under their parent, and MAY collapse or hide them. Servers set title on threads.

4.2.1 Listing#

room_list returns the rooms that match its filters, as room records (above). The result has up to two arrays. joined holds the rooms that the user joined. not_joined holds the visible rooms that the user did not join. Listing never joins a room.

// -> my rooms, threads included, with their members
{"method": "room_list", "id": "c20", "params": {"filter": "joined", "members": true}}
// <-
{
  "id": "c20", "result": {
    "joined": [
      {
        "room_id": "general", "log_id": "1724800000000", "title": "General",
        "latest_log_id": "1724803500000", "history_log_id": "1724800000000",
        "members": [{"user_id": "alice"}, {"user_id": "bob"}, {"user_id": "carol"}]
      },
      {
        "room_id": "1724803312001", "log_id": "1724803312001",
        "parent_room_id": "general", "title": "Deploy", "description": "Why the 4pm deploy failed",
        "latest_log_id": "1724803400000", "history_log_id": "1724803312001",
        "members": [{"user_id": "alice"}, {"user_id": "bob"}]
      }
    ],
    "users": [
      {"user_id": "alice", "name": "Alice", "avatar": "https://..."},
      {"user_id": "bob", "name": "Bob"},
      {"user_id": "carol", "name": "Carol"}
    ]
  }
}
// -> reconnecting: my rooms that changed since a position, and those I left
{"method": "room_list", "id": "c21", "params": {"filter": "joined", "latest_log_id": "1724803450000"}}
// <-
{
  "id": "c21", "result": {
    "joined": [{"room_id": "general", "latest_log_id": "1724803500000", ...}],
    "left": [{"room_id": "1724803399000"}]
  }
}
// -> general's threads I have not joined
{"method": "room_list", "id": "c22", "params": {"parent_room_id": "general", "filter": "not_joined"}}
// -> one room, with its members
{"method": "room_list", "id": "c23", "params": {"room_id": "1724803312001", "members": true}}

Every filter is optional:

  • filter: "joined" lists only joined, "not_joined" only not_joined, and "all", the default, both. An array the filter asks for is present even when empty, and the other is omitted.
  • parent_room_id lists only the threads of that room. Without it, joined holds joined rooms at all depths, threads included, and not_joined holds only top-level rooms.
  • room_id lists only that room, in the array that matches its membership. It overrides parent_room_id. Servers SHOULD support this filter.
  • members: true adds each room's members and the result's users (below); without it, a result carries neither.
  • user_id, with members: true, lists only that user in members and users. A server MAY ignore this filter.
  • latest_log_id lists only the rooms whose latest_log_id is greater. A server MAY ignore this filter. Its result is then a full listing without left.
    • A server that applies this filter includes left, also when it is empty.
    • When the result has joined, left lists the rooms that the user left since then, as [{room_id}] like room_update (§4.2.3).
    • Rooms that were deleted or became invisible since then SHOULD also be in left.

A result lists the matching rooms, with the most recently active first. joined lists every match. For not_joined, servers MAY list only the most recently active rooms. Servers list a private room only to its members (§4.2.4).

With members: true, each room in joined and not_joined carries members. This lists every user who joined the room, as current user objects (§3.3) that MAY carry only user_id. The result SHOULD carry users, the complete current objects of the users in members, each one time.

In a large room, a server MAY truncate members, for example to the most recently active users. It then SHOULD include member_count, the number of users who joined.

4.2.2 Membership#

room_join and room_leave take a room_id. They return {}.

A server MAY also accept a user_id, to add or remove another user. A server that does not support user_id replies unsupported. The change is an ordinary join or leave by that user.

// -> Alice adds Bob to her private room
{"method": "room_join", "id": "c3", "params": {"room_id": "1724803950000", "user_id": "bob"}}

A join subscribes every connection of the user to the deliveries of the room (§3.4). Users join a thread like any room.

Every membership change is a logged record in the room. This includes the creation of a room with room_set, and changes that the server makes. A membership record carries members, with one entry for each user. Each entry has the user as a recorded object (§3.3.1), and joined. room_update delivers membership records in memberships (§4.2.3):

// ->
{"method": "room_join", "id": "c24", "params": {"room_id": "1724803399000"}}
// <- to the room's other members
{
  "method": "room_update", "params": {
    "memberships": [
      {"log_id": "1724803450100", "room_id": "1724803399000", "members": [{"user": {"user_id": "ada", "name": "Ada"}, "joined": true}]}
    ]
  }
}
// <- to the joining user's connections: the room with its members, and the membership
{
  "method": "room_update", "params": {
    "joined": [{"room_id": "1724803399000", ..., "members": [{"user_id": "ada"}, {"user_id": "bob"}]}],
    "memberships": [
      {"log_id": "1724803450100", "room_id": "1724803399000", "members": [{"user": {"user_id": "ada", "name": "Ada"}, "joined": true}]}
    ],
    "users": [{"user_id": "ada", "name": "Ada"}, {"user_id": "bob", "name": "Bob"}]
  }
}
// <-
{"id": "c24", "result": {}}

// ->
{"method": "room_leave", "id": "c25", "params": {"room_id": "1724803312001"}}
// <- to the leaving user's connections; the room's other members get the membership alone
{
  "method": "room_update", "params": {
    "left": [{"room_id": "1724803312001"}],
    "memberships": [
      {"log_id": "1724803450200", "room_id": "1724803312001", "members": [{"user": {"user_id": "ada", "name": "Ada"}, "joined": false}]}
    ]
  }
}
{"id": "c25", "result": {}}
  • The key of a membership is (room_id, user.user_id). The record with the greatest log_id wins (§2.3). Live records carry one entry. Compacted history records MAY carry more (§4.3.4), and each entry replaces the membership of that user.
  • The server delivers a membership record to everyone who is a member of the room before or after the change. The record advances the latest_log_id of the room.
  • Clients start the member list of a room from its members in room_list or in room_update joined. They update it from the memberships that they receive, live and in history.
  • A server MAY keep some memberships outside the log, such as for temporary guests. It then sends no membership records for them, and ignores latest_log_id in room_list (§4.2.1).
  • Joins and leaves are membership records, not user notifications (§3.3).
  • Posting in a room does not require a join (§3.5.1). The server MAY join the poster. A poster who did not join does not receive the broadcast.

4.2.3 Updates#

room_update tells the connections of the user what changed. It never carries the full list:

  • joined: the room records of rooms that the user joined after signing in, by any means. Each has its members, as in room_list (§4.2.1). users MAY come with them.
  • left: [{room_id}] for each room that the user is no longer in, for any reason.
  • memberships: membership records (§4.2.2), to the members of the room.
  • updated: room records that are new or changed, when the membership of the user did not change. These are edits to a joined room, and new or edited threads of a joined room.
    • The changes to the record of a thread go to the members of the parent, also if they did not join the thread. This does not apply to a private thread.
    • Messages in a thread do not cause updated. So the latest_log_id of a thread in updated is from its last record change.
// <- after the join above
{"method": "room_update", "params": {"joined": [{"room_id": "1724803399000", "parent_room_id": "general", "title": "Incident", ...}]}}
// <- after the leave above
{"method": "room_update", "params": {"left": [{"room_id": "1724803312001"}]}}
// <- a joined room was renamed
{"method": "room_update", "params": {"updated": [{"room_id": "general", "log_id": "1724803600000", "title": "General (ops)", ...}]}}

4.2.4 Creating and editing#

room_set without room_id creates a room, and joins the creator to it. room_set with room_id replaces the client fields of that room (§4.2). Both forms return {"room_id": "..."}.

// -> start a thread in general
{
  "method": "room_set", "id": "c26", "params": {
    "parent_room_id": "general", "title": "Deploy",
    "description": "Why the 4pm deploy failed"
  }
}
// <- to the creator: the room with its members, and the creator's membership
{
  "method": "room_update", "params": {
    "joined": [
      {
        "room_id": "1724803312001", "log_id": "1724803312001",
        "parent_room_id": "general", "title": "Deploy",
        "description": "Why the 4pm deploy failed",
        "latest_log_id": "1724803312002", "history_log_id": "1724803312001",
        "members": [{"user_id": "ada"}]
      }
    ],
    "memberships": [
      {"log_id": "1724803312002", "room_id": "1724803312001", "members": [{"user": {"user_id": "ada", "name": "Ada"}, "joined": true}]}
    ]
  }
}
// <- then the result
{"id": "c26", "result": {"room_id": "1724803312001"}}

// -> later, a summarizer bot rewrites the description, resubmitting the title
{
  "method": "room_set", "id": "s9", "params": {
    "room_id": "1724803312001", "title": "Deploy",
    "description": "Root cause: **expired cert** on the build runner. Fix is rolling out."
  }
}
// <- to the thread's members and to general's members
{
  "method": "room_update", "params": {
    "updated": [
      {
        "room_id": "1724803312001", "log_id": "1724803900000", "prev_log_id": "1724803312001",
        "parent_room_id": "general", "title": "Deploy",
        "description": "Root cause: **expired cert** on the build runner. Fix is rolling out.",
        "latest_log_id": "1724803900000", "history_log_id": "1724803312001"
      }
    ]
  }
}
{"id": "s9", "result": {"room_id": "1724803312001"}}
  • Fields that end in _id are fixed at creation.
  • A server MAY also fix private. An edit that omits private keeps its value.
  • ext merges (§4.12). Other omitted fields are cleared.
  • parent_room_id MUST refer to an existing visible room.
  • private: true makes the room visible only to its members. A thread that is created without private takes the value of its parent. Members add other users by joining them (§4.2.2), if the server supports it.
  • A server includes private: true only on a room that it keeps private. A server without private rooms MUST reject a creation with private: true as unsupported.
  • A client that asks for a private room checks for private: true in the room_update joined record before it posts in the room. If the value is missing, the client tells the user, and does not use the room.
  • Suggested convention: members of a room may edit it.

4.3 history#

Adds page and recover a room's logWithout it session-only scrollback

1,134 words

Live delivery covers only the time that a client is connected. history fills the gaps. It pages through the log, so that a client can show older messages and recover what it missed while it was away.

Adds the history request, a stateless query for a window of the log. With room_id, it queries that room's log (§4.3.1). Without room_id, it queries the log of every room whose deliveries the connection receives (§3.4), as one log. Servers SHOULD support this. A server that does not support it replies unsupported. The result splits the records by kind:

  • rooms: room records (§4.2).
  • messages: message snapshots (§3.5).
  • reactions: reaction sets (§4.7).
  • memberships: memberships (§4.2.2).
// ->
{
  "method": "history", "id": "c9", "params": {
    "room_id": "general",
    "after": "1724803200000", "before": "1724806800000", "limit": 200
  }
}
// <-
{
  "id": "c9", "result": {
    "rooms": [],
    "messages": [
      {
        "message_id": "1724803200042", "log_id": "1724803200042", "room_id": "general",
        "from": {...}, "body": {...}
      }
    ],
    "reactions": [
      {
        "log_id": "1724803312011", "message_id": "1724803200042", "room_id": "general",
        "reactions": [{"from": {"user_id": "carol", "name": "Carol"}, "emojis": ["👍"]}]
      }
    ],
    "memberships": [
      {
        "log_id": "1724803300000", "room_id": "general",
        "members": [{"user": {"user_id": "dave", "name": "Dave"}, "joined": true}]
      }
    ],
    "first_log_id": "1724803200042", "last_log_id": "1724803312011", "more": true,
    "latest_log_id": "1724806800000", "history_log_id": "1724800000000"
  }
}

4.3.1 A room's log#

A record about a message, such as a snapshot or a reaction set, is in each room that the message is in just before or just after the record. Room records and memberships are in their own room. The earlier history of a moved message stays in the source room, and prev_room_id points there.

A record can point to the previous record for its key:

  • Room records and message snapshots MAY carry prev_log_id, the log_id of the previous record for the same key. Other records do not.
  • To get that record, a client sends history with after and before both set to that log_id.
  • After a move (§4.4), a message snapshot whose previous record is in another room also carries prev_room_id. That is the room to ask.

4.3.2 Bounds and order#

  • after and before are inclusive log_id bounds. Either one MAY be omitted.
  • The server limits the bounds to the available history. Then it selects a contiguous slice of the changes in the log, of any kind.
  • limit is a positive count of changes, applied before compaction. Servers MAY supply a default.
  • With after, the slice starts at the oldest change within the bounds and runs forward. Without after, it ends at the newest and runs back.
  • first_log_id and last_log_id are the first and last log_ids of the slice before compaction. The result has both or neither.
  • more shows that more changes match in the selected direction. An empty slice returns more: false and neither bound.
  • rooms, messages, reactions, and memberships MAY each be omitted when empty. Clients treat a missing array as empty. Each array is in ascending log_id order.
  • To continue forward, use after = last_log_id + 1. To continue backward, use before = first_log_id - 1. Calculate these as numbers, and encode them as strings. Never calculate a continuation from compacted records.

4.3.3 Availability and retention#

  • With this capability, room records in room_list and room_update MUST carry log_id, latest_log_id, and history_log_id (§4.2).
  • Room records in a history result are logged records. Clients ignore their delivery fields.
  • Every result includes latest_log_id and history_log_id. The server reads them at the same time as the page. They describe the log, not the page.
  • Retention can advance between requests, so clients check each response before they apply it.
  • If the server rejects a request for lack of resources, it returns an error, not an empty result.

Over time, servers compact or discard old history:

  • Servers SHOULD compact old history at rest, and not discard it. Compaction keeps the latest record for each key (§4.3.4).
  • Compacted history is still available. It does not move history_log_id.
  • A server that discards the start of the log advances history_log_id.
  • The effective lower bound is history_log_id. If history_log_id is null, it is latest_log_id + 1. The effective lower bound MUST NOT decrease.
  • Without room_id, the effective lower bound is that of the server's log.
  • Before a server discards a part that holds memberships, it appends one membership record. This record lists every current member, with joined: true only.

4.3.4 Compaction#

After the server selects the slice, it MAY compact it:

  • It MAY keep only the last room record and the last snapshot of each message in the slice.
  • It MAY fold the reaction sets of each message into one record. That record carries the last set of each user in the slice, at the greatest folded log_id.
  • It MAY fold the memberships of each room into one record in the same way. That record carries the last membership of each user.
  • It keeps empty sets and leaves.
  • Kept records keep their original log_ids and contents. They never include changes after the slice.
  • A compacted page and an uncompacted page give the same final state.

4.3.5 Replay and recovery#

Replay follows §2.3. A client can apply a record without earlier state. The order across the arrays does not matter.

Recovery, for each room:

  1. When live delivery starts, after authentication or after a join, buffer the live records for the room.
  2. Get the latest_log_id of the room from its room record (§4.2.1) or from a history page. Call it H. Keep only the buffered records above H.
  3. With a checkpoint C, page forward from after: C + 1 with before: H until more: false. Without one, start at after: history_log_id, or load the newest page with before: H and older pages on demand.
  4. Apply the buffered records. The checkpoint is now H.

If the effective lower bound of a response is past the next position that the client needs, the client clears the state of the room. It then starts again from that bound. Clients recover each room that they show independently. A thread loads when the user opens it.

4.4 edit#

Adds message saves: edit, move, deleteWithout it no edit/move/delete UI

581 words

A message is a record, so an edit is only a new snapshot of it at a later log position. The same mechanism moves and deletes messages.

Lets a message request with an existing message_id save that message:

  • A save replaces every client field (§3.5) with the submitted state, except ext, which merges (§4.12). It removes omitted fields, and replaces objects and arrays whole.
  • null does not mean deletion.
  • Clients MUST send again every client field that they want to keep, except ext.
  • The server keeps message_id, from, and the other server fields.
  • Saves apply in server order, and the later save wins.
  • A save mentions only the users that it adds to mentions (§3.5.5). Users that were already in the list are not mentioned again.
// -> edit
{
  "method": "message", "id": "c12", "params": {
    "message_id": "1724803200042", "room_id": "general",
    "body": {"text": "hello world", "format": "plain"}
  }
}
// <- snapshot with a new log_id
{
  "method": "message", "params": {
    "message_id": "1724803200042", "log_id": "1724803312007", "room_id": "general",
    "prev_log_id": "1724803200042",
    "from": {"user_id": "alice", "name": "Alice"},
    "body": {"text": "hello world", "format": "plain"}
  }
}
// <-
{"id": "c12", "result": {"message_id": "1724803200042"}}

A save with a different room_id moves the message. The destination MUST exist, and the caller MUST be able to see it. The server delivers the snapshot to both rooms (§4.3.1). Clients move the message, and do not treat it as deleted. The snapshot carries prev_room_id, the source room. If the message has reactions, the server then logs one reactions record (§4.7) in the destination, with every set that is not empty.

// -> move Bob's reply into thread room 1724803312001
{
  "method": "message", "id": "c15", "params": {
    "message_id": "1724803200043", "room_id": "1724803312001",
    "reply_to": {"message_id": "1724803200042"},
    "body": {"text": "Hello back!"}
  }
}
// <- to both rooms
{
  "method": "message", "params": {
    "message_id": "1724803200043", "log_id": "1724803312020", "room_id": "1724803312001",
    "prev_log_id": "1724803200043", "prev_room_id": "general",
    "from": {"user_id": "bob", "name": "Bob"},
    "reply_to": {"message_id": "1724803200042"},
    "body": {"text": "Hello back!"}
  }
}

Delete is a save with deleted: true. body is then optional, and the server MUST omit body and ext from the tombstone. deleted: true on creation is invalid_params.

// ->
{
  "method": "message", "id": "c14", "params": {
    "message_id": "1724803200043", "room_id": "1724803312001", "deleted": true
  }
}
// <-
{
  "method": "message", "params": {
    "message_id": "1724803200043", "log_id": "1724803312050", "room_id": "1724803312001",
    "from": {"user_id": "bob", "name": "Bob"},
    "deleted": true
  }
}

Clients show tombstones, and hide their reactions.

Earlier snapshots of a deleted message still hold its content. A server MAY redact them: it rewrites them, and embedded copies of them (§3.5.4), into tombstones at their original log_ids. Redaction is the only permitted rewrite of a logged record.

4.5 status#

Adds user status, idle connections, and mutesWithout it no online indicators

1,262 words

Presence and notification control share a capability, because both answer one question: is now a good time to reach this user? A status tells others whether the user is around. Idle reports and mutes tell the server when to hold back notifications.

Lets users choose a status with me (§3.3.2). Clients use the status request to report idle connections, and to set the mutes of the user.

// <- the server frame accepts dnd and invisible
{
  "method": "server", "params": {
    "apron": 9, "capabilities": ["status"], "auth": ["token"],
    "status": ["dnd", "invisible"]
  }
}
// -> do not disturb
{"method": "me", "id": "c40", "params": {"status": "dnd"}}
// <-
{"id": "c40", "result": {"you": {"user_id": "alice", "name": "Alice", "status": "dnd"}}}
// <- to others who share a room
{"method": "user", "params": {"new": {"user_id": "alice", "status": "dnd"}}}

4.5.1 Values#

A user's status has three views:

  • The chosen status is the value that the user sets with me.
  • The presence is the server's view of the user's connections. It is online if a connection is attended (§4.5.3), idle if no connection is attended, and offline if the user has no connections.
  • The shown status is what others see. The server derives it from the chosen status and the presence:
chosen status presence online presence idle presence offline
online (default) online idle offline
dnd (optional) dnd dnd offline
invisible (optional) offline offline offline
"" "" "" ""
  • A chosen "" is no status. Users choose it to opt out. Servers set it when they do not accept a value.
  • A chosen dnd silences the notifications of the user, as mute does.
  • server.status (§3.1) lists the optional values that the server accepts as a chosen status. Only servers with capability status send it. Servers always accept online and "", and do not list them.
  • Clients offer only the listed optional values.
  • A server without idle treats the presence of a connected user as online.

4.5.2 Delivery#

  • status is only in current user objects (§3.3.1). A change is a user notification. Servers MAY delay it.
  • you carries the chosen status. Every other user object carries the shown status.
  • Others see only the shown status. They never see the chosen status or the presence.
  • Clients take the user's own chosen status only from you. Another object about the user, even a complete one, does not replace it.
  • Complete user objects carry status also when it is offline or "".
  • A user without a status has no known status.
  • Clients show an unknown status value as unknown, with the value.
// <- Alice's last connection closed
{"method": "user", "params": {"new": {"user_id": "alice", "status": "offline", "seen": "1724803600000"}}}
  • seen is the time when the shown status last became offline, in the format of a log_id (§2.1).
  • seen changes only when the shown status changes to offline (otherwise it would show when a user with a chosen invisible connects).
  • A user object MAY carry seen only when it carries a shown offline.
  • A server that sends seen includes it in:
    • each complete user object with a shown offline, such as in room_list users.
    • each user notification that changes the shown status to offline.
  • Clients show seen only while the shown status is offline.

4.5.3 The status request#

// -> nobody has attended this connection for the last five minutes
{"method": "status", "id": "c41", "params": {"idle": 300}}
// <-
{"id": "c41", "result": {}}
// <- to others who share a room, if Alice's presence becomes idle
{"method": "user", "params": {"new": {"user_id": "alice", "status": "idle"}}}
// -> mute #random until unmuted, and everything for an hour
{"method": "status", "id": "c42", "params": {"room_id": "random", "mute": true}}
{"method": "status", "id": "c43", "params": {"mute": 3600}}
// <- to each of Alice's connections
{"method": "status", "params": {"room_id": "random", "mute": true}}
{"method": "status", "params": {"mute": 3600}}
// <-
{"id": "c42", "result": {}}
{"id": "c43", "result": {}}
// -> too many changes
{"method": "status", "id": "c44", "params": {"mute": false}}
// <-
{"id": "c44", "error": {"code": -32002, "message": "Too Many Requests", "data": {"retry_after": 5}}}
// <- after a later sign-in, the mutes in effect
{"method": "status", "params": {"room_id": "random", "mute": true}}
{"method": "status", "params": {"mute": 1800}}
  • Clients send status as a request. The server replies {} when it applies the change.
  • If there is an error, such as retry_after or invalid_params, nothing changes.
  • A status from the server is a notification.
  • Absent fields do not change.

idle reports whether somebody attends the sending connection:

  • It is false when somebody attends that connection. Otherwise it is the number of seconds since attention stopped, a non-negative integer.
  • A connection starts as attended. It keeps nothing from earlier connections. Its client sends idle when nobody attends it, and idle: false when somebody attends it again.
  • Clients MAY wait before they send idle, such as a few minutes without input. They then SHOULD send the seconds since the last input. They do not wait on a connection that starts unattended.
  • Servers MAY treat the connection as idle from the time that idle reports, such as to wake the user for mentions since then (§4.9.4).
  • Servers never send idle.

mute silences the notifications of the user:

  • It is true, false, or a positive integer number of seconds. Without room_id, it applies everywhere. With room_id, it applies only in that room and its threads. A status request with room_id and no mute is invalid_params.
  • mute is private. Others never see it.
  • Servers without timed mutes treat seconds as true.
  • Servers send each change to the mutes of the user to all connections of the user, as status. When a mute ends or is cleared, they send mute: false.
  • Clients apply a status that they receive as their own setting.

4.5.4 Sign-in#

At each sign-in (§3.2.2), clients drop the statuses and mutes that they kept, and apply the ones that arrive. After the auth result, the server sends:

  • One user notification for each user who shares a room with them, with the shown status in new, unless it is offline or "". Servers MAY limit these to the users that they would list in members (§4.2.1).
  • One status notification for each mute in effect, with the seconds left or true.

4.6 activity#

Adds typing and read markersWithout it no typing or read indicators

248 words

Adds the activity notification. A client sends it when its activity changes. Activity is typing, and how far the user has read in a room. Each field that is present updates that state. Absent fields do not change it. Activity is not logged. Servers MAY ignore typing and read_message_id.

// -> start typing
{"method": "activity", "params": {"room_id": "general", "typing": 8}}
// -> stop typing
{"method": "activity", "params": {"room_id": "general", "typing": 0}}
// -> advance the read cursor
{"method": "activity", "params": {"room_id": "general", "read_message_id": "1724803312050"}}
// <- (broadcast)
{
  "method": "activity", "params": {
    "room_id": "general", "from": {"user_id": "alice"},
    "typing": 8, "read_message_id": "1724803312050"
  }
}
  • typing (seconds): show the user as typing for up to this time, or until a new message from the user arrives. 0 stops it.
  • read_message_id: the user read the room up to and including this message. Clients only move it forward. Servers MAY ignore a cursor that moves back.
  • Server policy decides the delivery, such as to the room, or only to the user's own connections.
  • Servers MAY keep the latest read_message_id of each user in each room. They then send it to the connections of the user after those connections list the room (§4.2.1).

4.7 reactions#

Adds emoji reactions on messagesWithout it reaction controls hidden

266 words

Lets a client set its own complete set of emoji on one message. The server logs the change with a log_id, and broadcasts it to the room of the message. The result is {}.

// ->
{"method": "reactions", "id": "c17", "params": {"message_id": "1724803200043", "emojis": ["👍"]}}
// <- (broadcast)
{
  "method": "reactions", "params": {
    "log_id": "1724803312011", "message_id": "1724803200043", "room_id": "1724803312001",
    "reactions": [{"from": {"user_id": "carol", "name": "Carol"}, "emojis": ["👍"]}]
  }
}
// <-
{"id": "c17", "result": {}}
  • The request refers only to the message. The logged record carries the room of the message at that time (§4.3.1).
  • The reactions array of the notification holds one element for each user. Its from is a recorded user object (§3.3.1). Live broadcasts carry one element. Compacted history records (§4.3.4) MAY carry more. Each element replaces the set of that user on that message.
  • Clients keep state for each (message_id, user_id), and calculate the totals. They accept reactions for messages that they have not loaded.
  • emojis entries are strings. Duplicates collapse, and the order does not matter. One emoji sequence in each entry is the interoperable baseline.
  • Servers MAY limit the number of distinct emoji on each message or from each user.
  • The server MAY log nothing for a request that does not change the state.

4.8 Embeds and avatars#

Embeds other than upload and stream are body content.

1,440 words

Messages can carry more than text: files, live output, previews of links, or interactive content. Each embed has a kind that selects how to render it, and every client has a fallback for kinds that it does not know. Avatars are here because they use the same uploads.

class Embed {
  kind: string;                 // "upload", "stream", "iframe", "html", or another

  embed_id?: string;            // set by servers with any embed:* capability (§4.8.2)
  title?: string;
  url?: string;                 // where a click goes; the server's own for upload and stream
  og?: object;                  // OpenGraph description (§4.8.1)

  // "iframe" kind
  height?: number;              // suggested height

  // "html" kind
  html?: string;

  // "stream" kind (§4.8.5)
  format?: string = "plain";
  text?: string;                // the kept text, once the stream ends
}

body.embeds holds rich content in display order. kind selects the renderer. Clients render an unknown kind from og, or else as the fallback card (§3.5.2).

{"embed_id": "embed_1240", "kind": "upload", "title": "report.pdf", "url": "https://chat.example/f/Qm7xk2…"}
{"embed_id": "embed_1241", "kind": "iframe", "url": "https://backend:8443/term/abc", "height": 300}
{"embed_id": "embed_1242", "kind": "html", "html": "<table>…</table>"}
  • iframe: render with sandbox="allow-scripts", and never with allow-same-origin too (together, they let the embed remove its own sandbox). Allow no top navigation and no popups. Use a restrictive Permissions-Policy. Clamp the dimensions, because height is only a suggestion. Load lazily, and limit the number of live iframes at one time.
  • html: sanitize it with an allowlist sanitizer, such as DOMPurify, before insertion. Do this for every source.
  • upload is §4.8.4, and stream is §4.8.5.

4.8.1 OpenGraph metadata (og)#

Any embed MAY carry og, an OpenGraph description of its content as JSON. Property names drop the og: prefix. Structured properties are nested: og:image:width becomes image.width.

"og": {
  "title": "before.png",
  "image": {"url": "https://chat.example/f/Zr8Tq1…/thumb", "type": "image/webp", "width": 320, "height": 180, "alt": "Dashboard before the fix"}
}
  • Clients use the og properties title, description, site_name, and image, video, and audio (each with url, type, width, height, alt). They ignore other properties.
  • og.image is a preview to show. og.video and og.audio are what a player loads. The url of the embed is where a click goes.
  • Servers SHOULD set og in the broadcast. They MAY keep, replace, or drop an og that a client sent.
  • Servers SHOULD host or proxy the media that og refers to, and set its dimensions. Clients SHOULD NOT load og media from other origins.

4.8.2 Embed identity#

Servers that advertise any embed:* capability assign an opaque embed_id to each embed. Other servers MAY store embeds as they receive them.

  • A save keeps an embed when it sends the embed back with its embed_id. An embed without an embed_id is new. A save that leaves out an embed_id removes that embed.
  • The server owns embed_id, the url of an upload, and the url and text of a stream. It ignores them in requests, and restores them from its records on a save.
  • Servers SHOULD delete the content that they host for an embed when the embed is removed, or when its message is deleted or redacted.
  • Servers SHOULD make the URLs they host unguessable, such as with a random path segment rather than just the embed_id.

4.8.3 Writes#

New upload and stream embeds get their content over HTTP. The result of the message or command (§4.1) lists them, in request order:

// -> the sender attaches a file
{
  "method": "message", "id": "c8", "params": {
    "room_id": "general",
    "body": {"text": "Before the fix:", "embeds": [{"kind": "upload", "title": "before.png"}]}
  }
}
// <- the broadcast first, with the embed pending
{"embed_id": "embed_1235", "kind": "upload", "title": "before.png"}
// <- then the result, with the write URL for the sender only
{
  "id": "c8", "result": {
    "message_id": "1724803500000",
    "embeds": [{"embed_id": "embed_1235", "kind": "upload", "write_url": "https://chat.example/w/4c7a…"}]
  }
}
// sender: curl -T before.png https://chat.example/w/4c7a…
// <- the embed as broadcast in a later snapshot, completed
{
  "embed_id": "embed_1235", "kind": "upload", "title": "before.png",
  "url": "https://chat.example/f/Zr8Tq1…",
  "og": {
    "title": "before.png",
    "image": {"url": "https://chat.example/f/Zr8Tq1…/thumb", "type": "image/webp", "width": 320, "height": 180}
  }
}
  • The sender sends the content as the body of an HTTP PUT to write_url. write_url is a credential. It expires if it is not used.
  • The server finishes each write exactly one time. On success, it publishes a snapshot with the completed embed. If the write fails or does not start in time, it publishes a snapshot without the embed.
  • For a command, the server uses the finished write instead, such as to set an avatar (§4.8.6).

4.8.4 embed:upload#

Adds upload embeds: files the sender writes over HTTPWithout it no attachments

Adds upload embeds, files that the sender writes over HTTP. The sender gives an optional title, such as the file name. While url is absent, the upload is pending, and clients show a placeholder. On success, the server sets url to the file that it hosts.

  • The server SHOULD add og describing the file: image for a preview, and video or audio for playable media. It MAY keep what the sender gave, such as og.image.alt.
  • Without og, clients show a file card: title, with a link to url.

4.8.5 embed:stream#

Adds live-streamed text in a messageWithout it post the finished text

Lets a message carry live text. The sender writes it over HTTP, and readers see it grow. Stream embeds follow the rules for embed identity (§4.8.2) and writes (§4.8.3).

// -> the embed in a message request; the result and write follow §4.8.3
{"kind": "stream", "format": "terminal"}
// sender: foo 2>&1 | curl -T - <write_url>
// <- the embed as broadcast: live at its url, then finished with the kept text
{"embed_id": "embed_1234", "kind": "stream", "format": "terminal", "url": "https://chat.example/s/p3Wn9d…"}
{"embed_id": "embed_1234", "kind": "stream", "format": "terminal", "text": "…"}
  • format defines how to render the text. The default, "plain", shows the text as is, with its line breaks. Clients MAY support other formats natively, such as "markdown" (CommonMark, §3.5.2) or "terminal". They render unknown formats as plain.
  • Write: the sender sends UTF-8 text as a streaming PUT body to write_url. The end of the body ends the stream.
  • Read: GET url returns the text that the server kept. It continues as more text arrives, and ends when the stream ends. A reader that reconnects replaces the text that it showed with the new response.
  • Finish: when the stream ends, the server publishes a snapshot whose embed carries the kept text as text, in place of url. Both URLs then stop working. If the server has capability edit, the sender MAY save the message without the embed before that. This ends the stream.
  • Server policy sets how much text the server keeps, the size and time limits, and the grace period after a writer disconnects. At a limit, the server ends the stream, and keeps the last part of the text.
  • The chat server serves url. Clients SHOULD NOT connect to stream URLs on other origins.

4.8.6 Avatars#

A user object (§3.3) MAY carry avatar, an image to show next to the name of the user.

  • Servers SHOULD send only https: URLs, or small data:image/{png,jpeg,gif,webp};base64, URLs.
  • A larger image goes through an upload, with capabilities command and embed:upload. A /avatar command (§4.1) with one upload embed asks the server to use that file as the avatar of the sender. When the upload completes, the server sets avatar and sends user (§3.3.3).
  • Clients choose which avatar sources to load, and MAY ignore any avatar. They load avatars only as images, never as documents. They bind or escape the values, and never insert them into HTML as raw text.
  • Without a usable avatar, clients draw a placeholder such as initials.

4.9 Push#

server.push enables push.

1,266 words

Push reaches users when no client is open, such as a phone in a pocket. The server sends a small payload to a push service that the client registered, and the client shows it as a notification. Push has no capability of its own. server.push advertises it.

server.push (§3.1) maps each supported push kind to its public configuration. Its wake key lists the wake scopes that the server supports. If server.push is present, the server accepts push_register and push_unregister. A server that advertises push SHOULD advertise status (§4.5).

// <- the server frame offers relay and webpush
{
  "method": "server", "params": {
    "apron": 9, "capabilities": ["status"], "auth": ["webauthn", "token"],
    "push": {"relay": {}, "webpush": {"key": "BNcR..."}, "wake": ["mentions", "replies", "private"]}
  }
}
// ->
{
  "method": "push_register", "id": "c30", "params": {
    "kind": "relay", "url": "https://relay.example/p/xyz", "token": "...", "push_id": "t65S5XBst9bSDpjJ",
    "keys": {"p256dh": "BKx...", "auth": "Q2w..."}
  }
}
// ->
{
  "method": "push_register", "id": "c31", "params": {
    "kind": "webpush", "url": "https://push.example/s/abc", "push_id": "t65S5XBst9bSDpjJ",
    "keys": {"p256dh": "BOr...", "auth": "Hn3..."}, "wake": ["mentions", "private"]
  }
}
// ->
{"method": "push_unregister", "id": "c32", "params": {"url": "https://relay.example/p/xyz"}}

4.9.1 Registration#

  • kind is a key of server.push other than wake. Fields other than url, push_id, and wake are specific to the kind. Third-party kinds (§4) define their own delivery.
  • url is required. A registration belongs to the authenticated user and its url. It stays after the connection that made it closes.
  • A registration with the same url replaces the earlier registration of the user. push_unregister removes it. Unregistering an unknown url succeeds.
  • Servers MAY refuse a registration with denied, such as from a guest (§3.2). An endpoint that the server will not send to is invalid_params.
  • Servers SHOULD accept only https push endpoints that resolve to addresses that are not internal.
  • push_id (optional) is 1 to 64 characters from A-Z a-z 0-9 - _. Clients choose one for each server and account. It is an opaque value that shows neither the server nor the account.
  • Clients SHOULD register on each connection. Servers MAY drop a registration that the client did not renew within a server-defined period. Servers MAY also drop the least recently renewed registrations above a server-defined number for each user.
  • Clients SHOULD unregister before they sign out. Servers MAY remove the registrations of a user when they revoke the sessions of that user.
  • Servers remove a registration whose url does not accept pushes, such as one that answers 404 or 410.

4.9.2 Kinds#

  • relay: the server sends the payload in a POST to url, with token (optional) as a bearer token. The relay is responsible for delivery after that POST. Native apps use a relay that their vendor runs.
    • keys (optional) holds a p256dh and an auth that the client generated, as for webpush.
    • With keys, the body is the payload, encrypted as for webpush. It has Content-Type: application/octet-stream and Content-Encoding: aes128gcm.
    • Without keys, the body is the payload with Content-Type: application/json.
  • webpush: Web Push (RFC 8030).
    • key is the VAPID public key of the server (RFC 8292): an uncompressed P-256 point in unpadded base64url. Clients subscribe with it as the application server key. They subscribe again when it changes.
    • url is the subscription endpoint. keys (required) holds its p256dh and auth in unpadded base64url, as in PushSubscription.toJSON().
    • Servers encrypt the payload as one aes128gcm record (RFC 8291), and sign with the private key for key.
  • Servers send TTL and Urgency headers (RFC 8030) with every push, also to relays. Urgency is:
    • low for a push without message.
    • normal for a push that only joined selects.
    • high for all other pushes.

4.9.3 Payload#

Every kind delivers the same payload: a UTF-8 JSON object of at most 2048 bytes.

  • push_id: the push_id of the registration, if it has one. Clients drop a payload with a push_id that they do not know. A client that registered a push_id MAY drop a payload without one.
  • unread (optional): the unread count of the user, as the server counts it, such as messages after the read cursors of the user (§4.6). It is the same for all registrations of the user. Clients MAY show it as an app badge.
    • Servers whose server.push.wake lists badge send unread.
    • Servers MAY leave messages that a room mute silences out of unread. A mute without room_id does not change unread.
  • message: the message (§3.5) without log_id. Clients never install it as a snapshot. Servers do not push transient notices.
    • Servers SHOULD omit format, embeds, and ext.
    • To fit the limit, servers MAY truncate body.text, and leave out any field except message_id, room_id, and from.user_id.
    • A payload without message shows no notification.
  • Clients SHOULD show at most one notification for each push_id and message_id. A later notification for the same pair replaces the earlier one, from a push or from the client's own connection.
// a message push
{
  "push_id": "t65S5XBst9bSDpjJ", "unread": 2,
  "message": {
    "message_id": "1724803200042", "room_id": "general",
    "from": {"user_id": "alice", "name": "Alice"},
    "body": {"text": "Deploy is done, can someone check the dashboards?"}
  }
}
// a badge push, after the user read on another device
{"push_id": "t65S5XBst9bSDpjJ", "unread": 0}

4.9.4 Wake#

  • wake (optional) lists the scopes that a registration wakes for. Each scope except badge selects new messages in rooms that the user can see:
    • mentions: messages whose mentions list the user (§3.5.5). Servers MAY also wake for an edit that adds the user to mentions.
    • private: messages in private rooms (§4.2.4) that the user joined, and in their threads.
    • replies: messages whose reply_to refers to a message of the user (§3.5.4).
    • joined: messages in rooms that the user joined.
    • badge: every change to unread. A push for a change that no other scope selects has no message. Servers ignore badge for webpush. Servers MAY skip intermediate values of unread, and send only the latest.
  • Servers advertise only the scopes that they implement, and ignore other scopes in wake. An empty wake wakes for nothing. Without wake, the server uses its default scopes. These SHOULD be mentions and replies, where advertised.
  • A push that mute or a chosen dnd (§4.5.1) silences has no message. It goes only to registrations that wake for badge.
  • Servers do not wake a user for the user's own messages. Server policy decides other wake rules, such as rate limits.
  • Suggested convention: wake a user only when the user's presence is idle or offline (§4.5.1). Servers MAY wait a short time first, and skip the push if the read_message_id of the user is past the message.

4.10 WebAuthn authentication#

server.auth enables passkeys, and email and external sign-in.

410 words

Passkeys let users sign in without passwords or shared tokens, with a credential that their device keeps.

A passkey is bound to its RP ID, the domain of the web client that registered it. Browsers offer it only on that domain, so a phishing site cannot use it. This also means that a third-party client cannot sign in with another client's passkey. To add such a client or device, the user signs in with another scheme, such as token (§3.2.1) or email (§4.11). A server can also offer a command (§4.1) that issues more bearer tokens.

Servers that list webauthn in server.auth MUST use this exchange. No separate capability is necessary.

Both steps are auth requests with scheme: "webauthn". action: "register" creates a credential, and action: "login" signs in. Both steps use the same action.

Step Additional request fields Successful result
begin step: "begin" challenge_id (opaque), public_key (WebAuthn options)
finish step: "finish", challenge_id, credential you (§3.3)

public_key holds creation options for a registration, or request options for a login. It uses standard WebAuthn JSON, with binary fields as unpadded base64url. Clients pass it to navigator.credentials.create or .get, and return the credential in finish.

  • Registration MUST require discoverable credentials.
  • Login omits allowCredentials, or sends an empty array.
  • Both actions require user verification.
  • Servers SHOULD use the name (§3.2) of a register begin step for user.name and user.displayName in public_key.

Challenges MUST be unpredictable, and MUST expire. They MUST be bound to the connection, the action, the RP ID, the allowed origin, and any proposed registration identity.

  • A connection has one pending ceremony. A new begin step replaces it, and a disconnect cancels it. A matching finish step uses it up, also when the step fails.
  • Servers MUST perform WebAuthn verification before they record a credential or authenticate.
  • Only a verified finish step returns you.
  • A registration on a connection that is already signed in adds the passkey to that account.
  • Server policy decides who can register, and who can authenticate again.
  • Invalid challenges and failed verification are denied.

A verified finish result MAY carry a bearer token (§3.2.1) to resume the session, if the server also lists token. Servers MUST bind such a token to the allowed origin of the ceremony, and MUST make it expire. A mismatched origin is denied.

4.11 Email and external sign-in#

server.auth enables passkeys, and email and external sign-in.

1,040 words

Some sign-ins finish outside the connection. Email sign-in proves that a user controls an address. It suits sign-up and account recovery, and works on any device that gets mail. External sign-in sends the user to a web page of the server. The page can put any sign-in portal in front of Apron, such as OAuth, OpenID Connect, SAML, SSO, or a password check against a SASL backend.

Servers that list email or external in server.auth use this exchange, and also list token. An auth request without token proposes a sign-in or an addition. The server delivers a temporary token outside the connection. An auth request with that token approves the proposal:

// -> propose signing in with this address
{"method": "auth", "id": "c1", "params": {"scheme": "email", "email": "ada@example.com", "state": "q8Rz…"}}
// <- nothing is authenticated yet
{"id": "c1", "result": {}}

// email: "Your code is 418092, or open https://chat.example/login#token=Hk41x9…&state=q8Rz…"

// -> on any connection not signed in, such as one opened by the link
{"method": "auth", "id": "c2", "params": {"scheme": "email", "token": "Hk41x9…"}}
// <- signed in, with a bearer token for later connections
{"id": "c2", "result": {"you": {"user_id": "ada", "name": "Ada"}, "token": "st_Hk41…"}}
// -> propose an external sign-in that returns to this client
{"method": "auth", "id": "c1", "params": {"scheme": "external", "return_url": "https://web.apron.chat/", "state": "q8Rz…"}}
// <- the page to open
{"id": "c1", "result": {"url": "https://chat.example/sso?p=Qm7x…"}}

// the page signs the user in, then redirects to
// https://web.apron.chat/#token=Hk41x9…&state=q8Rz…&server=wss://chat.example/ws

// -> on a connection from https://web.apron.chat
{"method": "auth", "id": "c2", "params": {"scheme": "external", "token": "Hk41x9…"}}
// <-
{"id": "c2", "result": {"you": {"user_id": "ada", "name": "Ada"}, "token": "st_Hk41…"}}

4.11.1 Proposals#

  • A proposal is an auth request without token. It authenticates nothing.
  • On a connection that is signed in to an account, guests included, a proposal adds to that account. Otherwise it proposes a sign-in.
  • A connection has one pending proposal, and a new one replaces it.
  • A proposal expires within minutes. A disconnect does not cancel it. Approval uses it up.
  • Failed attempts count against the proposal, whichever connection makes them. A few failed attempts cancel it.
  • A proposal MAY carry state, an unguessable value of at most 256 bytes that the client chooses (like OAuth's state, it guards against a link that signs the user in to someone else's account). The server returns it unchanged in each link.

4.11.2 Temporary tokens#

  • The server delivers a temporary token for the proposal, as a link, a code to type, or both.
  • A temporary token expires within minutes of its delivery.
  • A token that is short enough to type, such as six digits, works only on the connection that made the proposal (short codes are easy to guess). A token that works on other connections MUST be unguessable.
  • A link carries the token in its URL fragment: #token=…, plus &state=… when the proposal carried state, plus &server=… with the server's address (A.4).
  • A client asks its user before it uses a link without the state of a proposal that the client made. It then presents the link's token with scheme: "token".
  • For a link with the state of its own proposal, a client uses the server that it proposed to, and ignores server.

4.11.3 Approval#

  • Approval is an auth request with token, and token or either scheme that the server offers.
  • Approval of a sign-in authenticates the connection that presents the token. That connection MUST NOT be signed in already. The result carries you and a bearer token for later connections (§3.2.1).
  • Approval of an addition works only on a connection that is signed in to the account that proposed it (otherwise someone could lead a victim to link the victim's identity to the other person's account). It adds the address or the external identity to that account, and returns {}.
  • An invalid, expired, or used token is denied. A sign-in token on a signed-in connection is also denied, and so is an address or an external identity that belongs to another account.

4.11.4 Email#

  • A proposal carries email. It returns {}, whether or not the address has an account.
  • The server emails the temporary token. It builds any link from its own configuration.
  • Account creation for unknown addresses, send rate limits (retry_after), and the bearer token's lifetime are server policy.

4.11.5 External#

  • A proposal MAY carry return_url, a URL of the client. Servers MUST accept only a return_url on an origin that they trust (otherwise the page would hand a working token to any site).
  • A proposal returns url, a page of the server. url is an absolute https URL, or http on a loopback host. Clients reject any other url, such as javascript:….
  • The client opens url in a browser. The page signs the user in by a method that the server chooses.
  • With return_url, the page shows its host, and whether the user signs in or adds an identity. The page asks the user to confirm before it redirects, also when the user is already signed in to the page.
  • The page redirects to return_url with the link fragment, which replaces any fragment of return_url.
  • Without return_url, the page shows a code to type after the user signs in. It tells the user to type the code only in the client where they started.
  • Servers SHOULD accept a link token only on a connection from the origin of return_url, such as by the WebSocket Origin header.
  • Account creation for new external identities is server policy.

4.12 ext#

Adds extension data kept on users, messages, and roomsWithout it servers may drop ext

466 words

Some data has no field in this document: a bridge's IDs for the messages it relays, an agent's settings for a room, or a user's time zone. Capability ext gives that data a place that survives other clients' saves.

Keeps the ext that clients write on users, messages, and rooms, and merges it as below. ext is an object whose keys are extension names without the ext: prefix (§4), such as irc:

"ext": {"irc": {"network": "libera", "channel": "#ops", "nick": "ada_", "msgid": "a1b2c3"}}
  • A write merges ext one level down. Each key that it carries replaces the kept value, an empty value ("", [], {}) clears that key, and keys that it leaves out stay. The value under a key is replaced whole. null is an ordinary value, and "ext": {} changes nothing.
  • Writes are me (§3.3.2), message requests (§3.5, §4.4), and room_set (§4.2.4). A write that creates a record, or saves a message whose current snapshot is a tombstone, merges into an empty ext. A save with deleted: true drops ext.
  • Complete user objects and records carry their complete ext. A user notification carries at least each key that changed, with a cleared key as its empty value, and clients merge it the same way.
  • Clients do not have to parse ext, or send it back.
  • Size limits apply to the merged ext. A write whose merged ext is over a limit is too_large, and changes nothing.
  • Clients send ext only to a server that advertises capability ext, or the capability of the extension that defines the key.
  • Without capability ext, servers MAY drop the ext that clients send.
  • An extension keeps its own data under its name in ext, on whatever object it defines, such as the server frame. It needs only its own capability.
// kept: "ext": {"irc": {"nick": "ada_"}, "tz": "Europe/Oslo"}
// -> change the time zone only
{"method": "me", "id": "c50", "params": {"ext": {"tz": "America/Toronto"}}}
// <-
{"id": "c50", "result": {"you": {"user_id": "ada", "name": "Ada", "ext": {"irc": {"nick": "ada_"}, "tz": "America/Toronto"}}}}
// -> clear irc
{"method": "me", "id": "c51", "params": {"ext": {"irc": ""}}}
// <-
{"id": "c51", "result": {"you": {"user_id": "ada", "name": "Ada", "ext": {"tz": "America/Toronto"}}}}

Informative

5. Security considerations#↑ contents

This section is informative. It explains the threats that the rules of earlier sections guard against, and refers to those rules.

5.1 Trust model#

Apron is for groups whose members mostly trust each other. The server is the single authority on identity, membership, and history. Within that model, the rules aim to keep a malicious or compromised member, an untrusted link or embed, or a bug in one implementation from compromising other clients or the server.

  • Users trust their server with their messages. There is no end-to-end encryption, so the server's operator can read every message.
  • Content from other users is untrusted. Clients render it as data, never as code.
  • A client can connect to any server. So a client does not run a server's markup or scripts, or open its URLs unchecked.
  • Apron is transport agnostic. It leaves encryption and authentication of the connection to the transport, such as wss:. So a link's server (A.4) can lead a client to an unencrypted connection, unless the client checks the transport.
  • Spam, sybil attacks, and public federation are out of scope.

5.2 Authentication#

  • auth is a barrier (§3.2.2). Requests that follow it never run without authentication, or as an earlier identity.
  • The server executes auth on each connection, also when it repeats an id (§1.2). A deduplicated result would sign in a connection that presented no credentials.
  • WebAuthn challenges are bound to their connection, action, RP ID, origin, and registration identity (§4.10). So one ceremony cannot complete another, and a login cannot pass as a registration.
  • A bearer token from WebAuthn is bound to the origin of its ceremony (§4.10). So a stolen token does not work from another web origin.
  • An email proposal returns {} for every address (§4.11.1). So proposals do not reveal which addresses have accounts.
  • A short code works only on the connection that proposed, and failed attempts from any connection count against the proposal (§4.11.2). This stops brute force across connections.
  • Link tokens travel in the URL fragment (§4.11.2). Browsers do not send fragments in request lines or Referer headers, so tokens stay out of server logs.
  • Only servers write links with a token, and never as apron: links (A.4). So a client does not share a token when the user shares a place, and an app that claims the scheme gets no token.
  • A link carries the state of its proposal (§4.11.2). Without it, a client could follow someone else's link into that person's account (login cross-site request forgery).
  • A signed-in connection cannot approve a sign-in (§4.11.3). So a link cannot silently switch a session to another account.
  • An addition works only on a connection of the account that proposed it (§4.11.3). Otherwise someone could lead a victim to link the victim's identity to the other person's account.
  • Servers accept only a return_url on a trusted origin, and bind link tokens to that origin (§4.11.5). Otherwise the sign-in page would be an open redirect that hands working tokens to any site.
  • The sign-in page asks the user to confirm before it redirects, and tells the user where to type a code (§4.11.5). Someone else may have started the sign-in, as in device-code phishing.

5.3 Identity and access control#

  • Display names are free text, and users can change them. Clients show Name (@user_id) when two known user_ids share a name (§3.3.1).
  • Clients never merge recorded user objects into current ones (§3.3.1). So an old history page cannot overwrite a current profile.
  • Servers do not give a retired user_id to another user (§3.3.3). Logged messages and mentions refer to it, and the new owner would inherit them.
  • roles are labels for display only (§3.3.4). The server enforces all permissions.
  • user_ids that start with ~ are reserved for system identities, and the suggested convention keeps @ and : out of the IDs that users choose (Appendix A.3). So no user can pose as the server, a room, or a federated user.
  • An ID that the caller cannot see gets the same invalid_params as an ID that does not exist (§1.1). So callers cannot probe whether private rooms or messages exist.
  • A client that asked for a private room checks for private: true before it posts (§4.2.4). A server that ignored the field would have created a public room.
  • The destination of a move is a room that the caller can see (§4.4). So a move cannot post into a hidden room.
  • Others see only the shown status of a user, and seen changes only when the shown status changes to offline (§4.5.2). So the connections of a user with a chosen invisible stay hidden.
  • Mutes are private to their user (§4.5.3).

5.4 Untrusted content#

  • Clients disable or sanitize raw HTML in CommonMark (§3.5.2), and sanitize html embeds from every source (§4.8). This prevents stored cross-site scripting (XSS).
  • iframe embeds run with sandbox="allow-scripts", never with allow-same-origin too (§4.8). Together, they would let the embed read the client's storage and bearer token, or remove its own sandbox.
  • Clients load avatars only as images, never as documents (§4.8.6). So an uploaded SVG cannot run script.
  • Clients open only an https url for external sign-in (§4.11.5). So a server cannot run script in the client with a javascript: URL.
  • Interactive clients send or join only on an action of the user, not because of a link (A.4). So a link cannot make a local server, such as a coding agent with guest sign-in, run a command with one click.
  • A link's keys are serialized as application/x-www-form-urlencoded, and clients use the first value of a repeated key (A.4). So an ID cannot add keys to a link, and a key appended to a link cannot replace one before it, such as server.
  • Clients only connect to a link's server, and never open it as a web page or in another app (A.4). So a link cannot make a client open a file: or javascript: URL, or start another app.

5.5 Network privacy and server requests#

  • Servers host or proxy og media and serve streams themselves, and clients load neither from other origins (§4.8.1, §4.8.5). Otherwise a message could act as a tracking beacon that reveals the IP addresses of its readers.
  • Hosted upload and stream URLs are unguessable (§4.8.2). So nobody can enumerate files across rooms.
  • Servers delete hosted content when its embed is removed, or when its message is deleted or redacted (§4.8.2). So deleted attachments do not stay available.
  • The server owns embed_id and the URLs that it hosts (§4.8.2). So a client cannot point an embed at a file that it does not own.
  • write_url is a credential that expires (§4.8.3). So only the sender can write an upload.
  • Servers accept only https push endpoints that do not resolve to internal addresses (§4.9.1). This prevents server-side request forgery (SSRF) against the server's network.
  • Clients drop push payloads with an unknown push_id (§4.9.3). So stale pushes for a removed account, and forged pushes to a known endpoint, show no notification.
  • Interactive clients ask the user before they connect to a server that the user has not used (A.4). So a posted link cannot make its readers' clients reveal their IP addresses to any host, or reach services on their networks.

5.6 Resource limits#

  • Size limits apply to the merged ext (§4.12). So repeated small merges cannot grow a record without bound.
  • Receivers can reject frames larger than 256 KiB (§1).
  • Servers deduplicate by (user_id, id), not by id alone (§1.2). So the request IDs of one user or connection never collide with another's.
  • After denied, clients do not reconnect on their own. After retry_after, they wait for the delay (§1.1). This prevents reconnection storms from revoked or misconfigured clients.

Appendix A — Conventions (informative)#↑ contents

A.1 System identities and scoped notices#

System identities are server-controlled user_ids with the ~ prefix (A.3), such as ~server. They carry an ordinary from and render like any sender. Clients MAY style them as system messages.

Three system identities tell the receiver who else got the message:

from.user_id received by logged for example
~server every user on the server yes maintenance notices, announcements
~room every member of the room yes removals with a reason, poll results
~private only this connection no welcomes, command replies, errors, reminders
  • room_id is where the message is shown, as for any message.
  • A server-wide notice also refers to a room, usually the default room (§3.5.1). It reaches every user whether or not they joined it.
  • These are sender identities that state a scope, not rooms.
  • Joins and leaves are memberships (§4.2.2), not ~room messages.
  • ~private messages are transient notices (§3.5.3). A private notice that should last belongs in a room of its own.
  • A ~private notice MAY omit room_id, like any message (§3.5.1). A client shows it even when it has no room to show it in yet, such as while it signs in.
// <- to everyone on the server, shown in the default room
{
  "method": "message", "params": {
    "message_id": "1724803500001", "log_id": "1724803500001", "room_id": "general",
    "from": {"user_id": "~server", "name": "Server"},
    "body": {"text": "Maintenance at 17:00 UTC."}
  }
}
// <- to everyone in general
{
  "method": "message", "params": {
    "message_id": "1724803500002", "log_id": "1724803500002", "room_id": "general",
    "from": {"user_id": "~room", "name": "General"},
    "body": {"text": "Poll closed: Tuesday wins, 7 votes to 4."}
  }
}
// <- to the new member's connection only, shown in general
{
  "method": "message", "params": {
    "room_id": "general",
    "from": {"user_id": "~private", "name": "Only you"},
    "body": {"text": "Welcome to General! Deploy chatter goes in threads."}
  }
}

A.2 Field naming#

Entity ID fields use the _id suffix (user_id, room_id, message_id, embed_id, parent_room_id). Embedded objects use descriptive names (from, body, reply_to). JSON-RPC's envelope id keeps its name. Extensions and future methods should follow the same pattern.

A.3 Prefixes in text#

A prefix says what kind of ID follows:

prefix refers to in body.text
@ a user a mention (§3.5.5), listed in body.mentions too
# a room a reference to the room; it mentions no one
~ a system identity never; it appears only as a sender (A.1)
  • After @ or #, the ID is the longest run of [A-Za-z0-9_.+@:-] that starts and ends with a letter, a digit, or _.
  • The prefix is not preceded by a letter or digit, so foo@bar.com and tag#ops contain neither.
  • Suggested convention: servers assign ordinary user_ids and room_ids from letters, digits, _, ., and -, starting and ending with a letter, a digit, or _, such as guest_1234 or general. This keeps the other characters free for special meanings, such as system identities (~server), federated users (carol@b.example), or email addresses as IDs.
  • Suggested convention: when users choose their own IDs, servers accept only that subset.
  • user_ids beginning with ~ are reserved for system identities. Servers SHOULD NOT assign them to users.
  • How text renders is up to the client.
  • Clients MAY show an @id that refers to a known user with the user's latest display name (§3.3.1), such as a chip. They MAY show a #id that refers to a known room as a link with its title. Unknown IDs render as written.

A link refers to a server, or to a room, message, or user on one, so that any client can open it. Its keys are relative to the server in server:

https://link.apron.chat/#server=wss://chat.example/ws&room=general&message=1724803200042
apron:?server=wss://chat.example/ws&room=general&message=1724803200042
key value
server the server's address, a URI whose scheme is the transport, such as wss://chat.example/ws
room a room_id, also of a thread
message a message_id in that room
user a user_id; with room, of a member of that room
token an invite (B.3), or a sign-in token (§4.11.2)
state with a sign-in token, the state of its proposal (§4.11.1)
  • The keys follow https://link.apron.chat/# or apron:? as application/x-www-form-urlencoded pairs, with : and / left as they are.
  • Clients use the first value of a repeated key.
  • Clients ignore keys that they do not know.
  • Clients write the https form when the user shares a link.
  • Clients only connect to server, and never open it as a web page or in another app.
  • Only servers write links with token.
  • Servers never write a link with token in the apron: form (any app can claim the scheme).
  • A link without server in content from a server, such as apron:?room=ops in body.text, refers to that server.
  • Interactive clients ask the user before they connect to a server that the user has not used.
  • Interactive clients do not send or join because of a link without an action of the user (otherwise a link could make a local server, such as a coding agent with guest sign-in, run a command with one click).

Links of each kind, in the https form:

  • A room: https://link.apron.chat/#server=wss://chat.example/ws&room=general
  • A user, as a member of a room: https://link.apron.chat/#server=wss://chat.example/ws&room=general&user=ada
  • A room on a server on the user's own computer, such as a coding agent, over ws: https://link.apron.chat/#server=ws://localhost:8080/ws&room=agent
  • An email sign-in, with the state of its proposal (§4.11.2): https://link.apron.chat/#server=wss://chat.example/ws&token=Hk41x9…&state=q8Rz…
  • An invite (B.3): https://link.apron.chat/#server=wss://chat.example/ws&token=inv_Qm7x…

Appendix B — Valid scenarios (informative)#↑ contents

Exchanges that are valid under this spec but easy to get wrong, collected so implementations accept them. Each follows from the sections it cites.

B.1 The server sends a welcome before auth#

Authentication gates what a client sends, not what it receives (§3.2.2). A server MAY follow its server frame with a ~private notice (Appendix A.1), such as how to sign in. It omits room_id, since the client knows no rooms yet. Sent before the server reads any frame, it precedes the auth result even when the client pipelined auth. The server sends it on each connection. Clients MAY replace the previous one rather than show both.

// <-
{"method": "server", "params": {"apron": 9, "capabilities": ["rooms"], "auth": ["webauthn", "token", "guest"]}}
// <- before any auth
{
  "method": "message", "params": {
    "from": {"user_id": "~private", "name": "Only you"},
    "body": {"text": "Guests can read along. **Sign in with a passkey** to post.", "format": "markdown"}
  }
}
// ->
{"method": "auth", "id": "c1", "params": {"scheme": "guest"}}
// <-
{"id": "c1", "result": {"you": {"user_id": "guest_1234"}}}

B.2 A bot connects, posts once, and disconnects#

A deploy hook or cron job needs no rooms. It MAY send auth and message together without waiting for server, since auth is a barrier (§3.2.2). Posting does not require joining (§3.5.1), so the bot receives no broadcast. The message_id result is its confirmation. It closes the connection once that arrives. If the connection drops first, it resends the same id and params on a new connection, and the server returns the original result rather than posting twice (§1.2).

// -> both at once, before server arrives
{"method": "auth", "id": "c1", "params": {"scheme": "token", "token": "...", "agent": "deploy-hook/1.0"}}
{"method": "message", "id": "deploy-7f3a", "params": {"room_id": "ops", "body": {"text": "Deployed v1.4.2"}}}
// <-
{"method": "server", "params": {"apron": 9, "capabilities": ["rooms"], "auth": ["token"]}}
// <-
{"id": "c1", "result": {"you": {"user_id": "deploy-bot", "name": "Deploy"}}}
// <- then the bot closes the connection
{"id": "deploy-7f3a", "result": {"message_id": "1724803200042"}}

B.3 An invite token signs up several people#

A server MAY treat a token as an invitation that creates a new identity on each use, up to a limit it sets, such as ten sign-ups from one link. Each result carries the new identity's own token. The client saves it and reconnects with it rather than the invite (§3.2.1). Issuing and revoking invites are server commands (§4.1). A used-up or expired invite is denied.

// -> each invitee signs in with the shared invite
{"method": "auth", "id": "c1", "params": {"scheme": "token", "token": "inv_Qm7x...", "name": "Bob"}}
// <- a new identity, with its own token for later connections
{"id": "c1", "result": {"you": {"user_id": "bob", "name": "Bob"}, "token": "st_Hk41..."}}

Draft

Appendix C — Under consideration#↑ contents

Designs that are not yet part of the protocol, kept here so implementations can experiment and converge on them.

C.1 WebRTC: signaling for audio, video, and peer-to-peer connections#

Planned capability rtc requires capability rooms. Any room can hold one WebRTC session at a time, and its members may join it (§4.2.2). The socket carries signaling. Media and data travel peer to peer. Mute and camera state, recording, and transcoding are left to clients and servers.

class Room {    // besides the fields of §3.4
  // "rtc" capability
  rtc?: { members: User[] } | null;  // delivery; present during a session, null when it ends
}

class User {    // in rtc_* frames, besides the fields of §3.3
  peer_id: string;              // one client's seat in the session, assigned by the server
}

A room's rtc lists who is in a session. It is a delivery field (§4.2). It is present while the room has a session, and lists each user in it once. It is null when the session ends. It reaches the room's members, and a public thread's parent members, through room_list and room_update updated. It lists users, not seats. Its appearance on a room is the ring. To reach a member who is offline, a caller's client also posts an ordinary message that mentions them (§4.9).

At each sign-in, clients drop every rtc that they kept. room_list lists each room that has a session, even when the request's latest_log_id filter would leave the room out.

Room membership is per user; a session is per device. Each connection in a session holds a seat. The server assigns each seat a peer_id that is unguessable and unique within the session. User objects in rtc_* frames are recorded objects (§3.3.1), never merged. Each carries the peer_id of one seat. Clients key peers on (user_id, peer_id).

C.1.1 Joining#

rtc_join takes a seat, starting the session if there is none. The result carries the joiner's peer_id, its ICE configuration in ice, and peers, the seats already in the session, oldest first. It MAY carry refresh_after, the seconds after which the client refreshes the result (C.1.2). The server handles one join per session at a time, so of two joins the later one's peers includes the earlier. The joiner offers to each peer, and they learn of it from its offer. Clients SHOULD NOT answer offers until their user has joined the session. Clients MAY use relay-only ICE, so servers SHOULD include TURN in ice.

// -> Bob's phone joins
{"method": "rtc_join", "id": "c41", "params": {"room_id": "1724803950000"}}
// <-
{
  "id": "c41", "result": {
    "peer_id": "p3",
    "ice": [{"urls": "stun:stun.example:3478"}, {"urls": "turn:turn.example", "username": "u", "credential": "c"}],
    "refresh_after": 3600,
    "peers": [{"user_id": "alice", "name": "Alice", "peer_id": "p1"}]
  }
}

C.1.2 Reclaiming and leaving#

A session outlives a dropped chat connection. rtc_join with a peer_id reclaims that seat for the same user after a reconnect, or refreshes ice or transport after refresh_after (with a new request id, §1.2); the other peers see no change. After reclaiming, a client restarts ICE with every peer not connected, using the fresh ice. Only a client that still holds its peer connections reclaims; otherwise it leaves and joins anew. A seat ends with rtc_leave, or when its connection stays closed past a grace period set by server policy. The session ends with its last seat.

// -> after reconnecting and authenticating
{"method": "rtc_join", "id": "c47", "params": {"room_id": "1724803950000", "peer_id": "p3"}}
// ->
{"method": "rtc_leave", "id": "c48", "params": {"room_id": "1724803950000"}}

C.1.3 Signaling#

rtc_signal is a notification relayed between seats. The sender refers to a seat in to. The server delivers it with the sender's seat in from. Only a connection holding a seat may send one. Signals from one seat to another arrive in order. The server drops signals to a seat whose connection is closed, that match no seat, or that exceed its rate or size limits. When a seat ends, the server sends each remaining peer a signal from it with payload: null.

// -> Alice (p1) to Bob's phone (p3)
{
  "method": "rtc_signal", "params": {
    "room_id": "1724803950000", "to": {"user_id": "bob", "peer_id": "p3"},
    "payload": {"sdp_type": "offer", "sdp": "v=0..."}
  }
}
// <- delivered to p3
{
  "method": "rtc_signal", "params": {
    "room_id": "1724803950000", "from": {"user_id": "alice", "name": "Alice", "peer_id": "p1"},
    "payload": {"sdp_type": "offer", "sdp": "v=0..."}
  }
}

A payload is one of these:

  • {"sdp_type": "offer" | "answer", "sdp": "..."} is a session description.
  • {"candidate": {"candidate": "candidate:...", "sdp_mid": "0", "sdp_m_line_index": 0, "username_fragment": "..."}} is an ICE candidate. Receivers apply it once the remote description is set.
  • {"candidate": null} ends the candidates.
  • null comes from the server only, and means the sending seat ended.

Peers use perfect negotiation (WebRTC §10.7). In each pair, the seat with the greater peer_id is polite. Receivers ignore unknown payload keys.

Mesh is the baseline topology. Peers negotiate pairwise and the server only relays. Clients SHOULD soft-cap participants. A server MAY instead put a session on a media server. rtc_join then returns a transport, such as {"type": "livekit", "url": "wss://sfu.example", "token": "..."}, in place of ice and peers, and the client follows that transport's own signaling. The server drives the room's rtc from it.

C.1.4 Apron over a data channel#

A data channel whose WebRTC protocol is apron/<protocol>, with the version of §3.1, carries Apron frames, one per ordered, reliable data channel message no larger than the remote peer's SCTP maximum. The oldest seat in the session hosts the room and runs the server side of Apron on its own device. That is peers[0], or the joiner itself when there are no peers. Each other seat opens a channel to it. A guest authenticates with guest, and the host assigns it the identity of its seat. When the host's seat ends, the next oldest seat hosts a new Apron session on new channels. Earlier history stays with the clients that received it. A reclaimed seat keeps its age. Clients route other data channels by their protocol and close ones they do not support. In a mesh, DTLS encrypts the channel end to end. Its fingerprints travel through the chat server's signaling, so clients MAY pin or compare them.

C.2 Multiplexing envelope#

Multiple logical protocol connections can share one physical WebSocket via an aggregator that proxies backends. This envelope is outside the core protocol.

A mux endpoint wraps every core frame in an envelope with an opaque connection ID:

{"conn_id": "b1", "frame": {"method": "message", "id": "c3", "params": {"room_id": "general", "body": {}}}}

Each conn_id carries an independent core session. Frame ordering is preserved per conn_id, not across them. Control uses unwrapped frames:

// ->
{"type": "conn_open", "conn_id": "b1", "url": "wss://backend.example/ws"}
// <-
{"type": "conn_ready", "conn_id": "b1"}
// <-
{"type": "conn_error", "conn_id": "b1", "code": "unreachable", "message": "..."}
// <- or ->
{"type": "conn_close", "conn_id": "b1"}

conn_id is chosen by the opener, unique per socket. After conn_ready, the backend's server frame arrives wrapped, first on that conn_id. conn_close from either side ends the logical connection. The aggregator forwards inner frames unparsed and holds only the conn_id↔upstream mapping. Backends remain authoritative, and frames may be encrypted end to end. Aggregator authentication is deployment-defined.

C.3 Actions embed#

Embed kind actions offers the reader choices, grouped by what choosing does, such as an invitation to accept or decline, a permission prompt, or a poll. It requires capability command (§4.1).

class Embed {   // "actions" kind, besides the fields of §4.8
  // set by the server
  commands?: { label: string; command: string }[];
}
// <- to Bob only
{
  "method": "message", "params": {
    "room_id": "general",
    "from": {"user_id": "~private", "name": "Only you"},
    "body": {
      "text": "Ada invited you to a room titled \"Hi\".",
      "embeds": [{
        "kind": "actions",
        "commands": [
          {"label": "Accept", "command": "/join #1724803950000"},
          {"label": "Decline", "command": "/leave #1724803950000"}
        ],
        "og": {"description": "Type /join #1724803950000 to accept."}
      }]
    }
  }
}
  • Only servers set actions. A server drops or rebuilds an actions embed that a client sends.
  • Choosing one of commands sends its command as a command request, or as the request it refers to, such as /join (§4.1). When the message has a message_id, the request's reply_to refers to it.
  • Clients ignore groups they do not know (§1).
  • Clients without actions support render the fallback card (§3.5.2). Its og can spell out the commands to type.

License#↑ contents

This specification is dedicated to the public domain under CC0 1.0 Universal. Anyone can copy, change, and implement it without permission.