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 everyidis 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/unsupportedto 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, ortoo_largefor its size.deniedis 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
idused again with a different method or params isinvalid_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 previouslog_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_idof its creation; never changes. - Unique across rooms.
log_id == message_idmarks the creation; later changes have greaterlog_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_idanduser_idare 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.schemeselects the scheme:guest: no credentials. The server assigns the identity. Suggested convention:guest_plus a global counter, such asguest_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
tokenMAY accept any token. - With
server.signup,server.authlists the schemes that sign in, andsignuplists 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.welcometells 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.nameanduser_idare optional requests, and work with any scheme.youis what the server assigned.- Servers SHOULD NOT assign a
user_idthat 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
authbeforeserverarrives. They MAY send other requests right afterauth, without waiting for its result. - Later requests use the authentication that the
authleft on the connection. Anauththat authenticates nothing, such as a failure, changes nothing. - Before a successful
auth, other requests getdenied. The server ignores other notifications, exceptping(§1). - The server MAY send notifications before
auth, such as a~privatewelcome (Appendix A.1, Appendix B). - A sign-in is an
auththat gives the connection its first user, or a different one. - Every notification that a sign-in causes on its connection comes after the
authresult.
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
youinauthandmeresults, andyouandnewin ausernotification.youin a result is complete: it carries every profile field that the server publishes.- A
usernotification carriesuser_idand 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
fromof a message. They carryuser_id, and SHOULD carryname. 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.nullis 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
nameshows asuser_id. - Clients SHOULD show a user as
Name (@user_id)where there is space. They MUST do this when they know anotheruser_idwith 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"}
}
}
youreplaces the identity of the connection. If itsuser_idchanges, 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.oldmeans that the same account changed itsuser_idfromold.user_idtonew.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_idchange, logged records keep the olduser_id. Clients MAY treat it as the new identity. - Servers SHOULD NOT give a retired
user_idto 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
usernotification, 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#
bodyis required when a client creates a message.textdefaults to"".embedsandmentionsdefault to[].formatis"plain"or"markdown", and the default is"plain".- A request without
room_idposts 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
textand noembedsSHOULD 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
kindfrom itsog, if it has one. Otherwise they render a labeled fallback card: the kind name, plusurlor plaintextif 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
messagenotification withoutmessage_idis 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_toholds a message object. Clients send it bare, with onlymessage_id. Servers MAY send a full snapshot, and clients install it like any other. Embedded snapshots carry a barereply_to.reply_to.message_idMUST 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
mentionsas mentioned. The content oftextdoes not change this. - Servers never parse
textto find mentions. - When the user picks someone to mention, the composer adds that user to
mentions, and writes the mention intext. - Mentions that notify a whole room are not defined.
3.6 Core conformance checklist#
Every server:
- Sends a
serverframe on connect (§3.1). - Accepts at least one
authscheme, replies withyou, and finishesauthbefore processing later frames (§3.2.2). - Accepts
messagewithoutroom_idinto its default room (§3.5.1). - Accepts
messagecreation: replies withmessage_idand broadcasts the snapshot to the room (§3.5). - Replies
error/unsupportedto unknown requests, includingmessagewith amessage_idwhen capabilityeditis absent; ignores unknown notifications. - Follows §1 for framing and retries and §2 for identifiers.
A minimal client (informative):
- Authenticates with
authonceserverarrives, and posts withmessage(§3.2, §3.5). - Groups messages by
room_id, titling a room by itsroom_idunless it knows its record (§3.4). - Keeps each message's snapshot with the greatest
log_id, from any source, and renders tombstones (§2.3, §3.5). - Keeps one user object per
user_id, merged from current objects and falling back tofrom, and showsName (@user_id)when twouser_ids share a name (§3.3.1). - Renders
plainandmarkdowntext with raw HTML disabled, and a fallback card for embed kinds it does not support (§3.5.2). - 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
uploadandstreamare body content (§4.8). server.pushenables push (§4.9).server.authenables passkeys, and email and external sign-in (§4.10, §4.11).server.pingenables 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 alog_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):fromand 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
command#/kickWithout it no commandsAdds 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.textis the command line as the user typed it, slash included. The server parses it.- If composer text starts with
/, clients send it as acommand. 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 withmessage_idordeletedisinvalid_params. mentions,reply_to,embeds, andextare arguments. Mentioned users are not notified, andextis not kept.- The result is
{}, or{"embeds": [...]}with write URLs for newuploadembeds (§4.8.3). A failure is an ordinary error, and the client shows itsmessage. - The server replies as needed with system notices
(Appendix A.1). It sends
~privateto the sender,~roomto the room, and~serverto everyone. - Effects arrive as the frames they cause, such as
room_update. - Commands are for what a server provides beyond this spec, such as
/banwith 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~privatenotice. 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
/nickasmeor/joinasroom_join. They send the other commands ascommand.
// -> 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
rooms#room_list, room_join, room_leave, room_set, updatesWithout it one default room, no threadsLets 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 onlyjoined,"not_joined"onlynot_joined, and"all", the default, both. An array the filter asks for is present even when empty, and the other is omitted.parent_room_idlists only the threads of that room. Without it,joinedholds joined rooms at all depths, threads included, andnot_joinedholds only top-level rooms.room_idlists only that room, in the array that matches its membership. It overridesparent_room_id. Servers SHOULD support this filter.members: trueadds each room'smembersand the result'susers(below); without it, a result carries neither.user_id, withmembers: true, lists only that user inmembersandusers. A server MAY ignore this filter.latest_log_idlists only the rooms whoselatest_log_idis greater. A server MAY ignore this filter. Its result is then a full listing withoutleft.- A server that applies this filter includes
left, also when it is empty. - When the result has
joined,leftlists the rooms that the user left since then, as[{room_id}]likeroom_update(§4.2.3). - Rooms that were deleted or became invisible since then SHOULD also be in
left.
- A server that applies this filter includes
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 greatestlog_idwins (§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_idof the room. - Clients start the member list of a room from its
membersinroom_listor inroom_updatejoined. 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_idinroom_list(§4.2.1). - Joins and leaves are membership records, not
usernotifications (§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 itsmembers, as inroom_list(§4.2.1).usersMAY 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 thelatest_log_idof a thread inupdatedis 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
_idare fixed at creation. - A server MAY also fix
private. An edit that omitsprivatekeeps its value. extmerges (§4.12). Other omitted fields are cleared.parent_room_idMUST refer to an existing visible room.private: truemakes the room visible only to its members. A thread that is created withoutprivatetakes the value of its parent. Members add other users by joining them (§4.2.2), if the server supports it.- A server includes
private: trueonly on a room that it keeps private. A server without private rooms MUST reject a creation withprivate: trueasunsupported. - A client that asks for a private room checks for
private: truein theroom_updatejoinedrecord 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
history#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, thelog_idof the previous record for the same key. Other records do not. - To get that record, a client sends
historywithafterandbeforeboth set to thatlog_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#
afterandbeforeare inclusivelog_idbounds. 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.
limitis 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. Withoutafter, it ends at the newest and runs back. first_log_idandlast_log_idare the first and lastlog_ids of the slice before compaction. The result has both or neither.moreshows that more changes match in the selected direction. An empty slice returnsmore: falseand neither bound.rooms,messages,reactions, andmembershipsMAY each be omitted when empty. Clients treat a missing array as empty. Each array is in ascendinglog_idorder.- To continue forward, use
after = last_log_id + 1. To continue backward, usebefore = 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_listandroom_updateMUST carrylog_id,latest_log_id, andhistory_log_id(§4.2). - Room records in a history result are logged records. Clients ignore their delivery fields.
- Every result includes
latest_log_idandhistory_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. Ifhistory_log_idisnull, it islatest_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: trueonly.
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:
- When live delivery starts, after authentication or after a join, buffer the live records for the room.
- Get the
latest_log_idof the room from its room record (§4.2.1) or from ahistorypage. Call it H. Keep only the buffered records above H. - With a checkpoint C, page forward from
after: C + 1withbefore: Huntilmore: false. Without one, start atafter: history_log_id, or load the newest page withbefore: Hand older pages on demand. - 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
edit#message saves: edit, move, deleteWithout it no edit/move/delete UIA 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. nulldoes 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
status#status, idle connections, and mutesWithout it no online indicatorsPresence 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
onlineif a connection is attended (§4.5.3),idleif no connection is attended, andofflineif 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
dndsilences the notifications of the user, asmutedoes. server.status(§3.1) lists the optional values that the server accepts as a chosen status. Only servers with capabilitystatussend it. Servers always acceptonlineand"", and do not list them.- Clients offer only the listed optional values.
- A server without
idletreats the presence of a connected user asonline.
4.5.2 Delivery#
statusis only in current user objects (§3.3.1). A change is ausernotification. Servers MAY delay it.youcarries 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
statusalso when it isofflineor"". - A user without a
statushas no known status. - Clients show an unknown
statusvalue as unknown, with the value.
// <- Alice's last connection closed
{"method": "user", "params": {"new": {"user_id": "alice", "status": "offline", "seen": "1724803600000"}}}
seenis the time when the shown status last becameoffline, in the format of alog_id(§2.1).seenchanges only when the shown status changes tooffline(otherwise it would show when a user with a choseninvisibleconnects).- A user object MAY carry
seenonly when it carries a shownoffline. - A server that sends
seenincludes it in:- each complete user object with a shown
offline, such as inroom_listusers. - each
usernotification that changes the shown status tooffline.
- each complete user object with a shown
- Clients show
seenonly while the shown status isoffline.
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
statusas a request. The server replies{}when it applies the change. - If there is an error, such as
retry_afterorinvalid_params, nothing changes. - A
statusfrom the server is a notification. - Absent fields do not change.
idle reports whether somebody attends the sending connection:
- It is
falsewhen 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
idlewhen nobody attends it, andidle: falsewhen 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
idlereports, 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. Withoutroom_id, it applies everywhere. Withroom_id, it applies only in that room and its threads. Astatusrequest withroom_idand nomuteisinvalid_params. muteis 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 sendmute: false. - Clients apply a
statusthat 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
usernotification for each user who shares a room with them, with the shown status innew, unless it isofflineor"". Servers MAY limit these to the users that they would list inmembers(§4.2.1). - One
statusnotification for each mute in effect, with the seconds left ortrue.
4.6 activity#
Adds typing and read markersWithout it no typing or read indicators
activity#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.0stops 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_idof 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
reactions#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
reactionsarray of the notification holds one element for each user. Itsfromis 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. emojisentries 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.
upload and stream are body content.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 withsandbox="allow-scripts", and never withallow-same-origintoo (together, they let the embed remove its own sandbox). Allow no top navigation and no popups. Use a restrictive Permissions-Policy. Clamp the dimensions, becauseheightis 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.uploadis §4.8.4, andstreamis §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
ogpropertiestitle,description,site_name, andimage,video, andaudio(each withurl,type,width,height,alt). They ignore other properties. og.imageis a preview to show.og.videoandog.audioare what a player loads. Theurlof the embed is where a click goes.- Servers SHOULD set
ogin the broadcast. They MAY keep, replace, or drop anogthat a client sent. - Servers SHOULD host or proxy the media that
ogrefers to, and set its dimensions. Clients SHOULD NOT loadogmedia 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 anembed_idis new. A save that leaves out anembed_idremoves that embed. - The server owns
embed_id, theurlof an upload, and theurlandtextof 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
PUTtowrite_url.write_urlis 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
ogdescribing the file:imagefor a preview, andvideooraudiofor playable media. It MAY keep what the sender gave, such asog.image.alt. - Without
og, clients show a file card:title, with a link tourl.
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": "…"}
formatdefines 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
PUTbody towrite_url. The end of the body ends the stream. - Read:
GET urlreturns 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 ofurl. Both URLs then stop working. If the server has capabilityedit, 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 smalldata:image/{png,jpeg,gif,webp};base64,URLs. - A larger image goes through an upload, with capabilities
commandandembed:upload. A/avatarcommand (§4.1) with oneuploadembed asks the server to use that file as the avatar of the sender. When the upload completes, the server setsavatarand sendsuser(§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.
server.push enables push.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#
kindis a key ofserver.pushother thanwake. Fields other thanurl,push_id, andwakeare specific to the kind. Third-party kinds (§4) define their own delivery.urlis required. A registration belongs to the authenticated user and itsurl. It stays after the connection that made it closes.- A registration with the same
urlreplaces the earlier registration of the user.push_unregisterremoves it. Unregistering an unknownurlsucceeds. - Servers MAY refuse a registration with
denied, such as from a guest (§3.2). An endpoint that the server will not send to isinvalid_params. - Servers SHOULD accept only
httpspush endpoints that resolve to addresses that are not internal. push_id(optional) is 1 to 64 characters fromA-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
urldoes not accept pushes, such as one that answers 404 or 410.
4.9.2 Kinds#
relay: the server sends the payload in a POST tourl, withtoken(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 ap256dhand anauththat the client generated, as forwebpush.- With
keys, the body is the payload, encrypted as forwebpush. It hasContent-Type: application/octet-streamandContent-Encoding: aes128gcm. - Without
keys, the body is the payload withContent-Type: application/json.
webpush: Web Push (RFC 8030).keyis 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.urlis the subscription endpoint.keys(required) holds itsp256dhandauthin unpadded base64url, as inPushSubscription.toJSON().- Servers encrypt the payload as one
aes128gcmrecord (RFC 8291), and sign with the private key forkey.
- Servers send
TTLandUrgencyheaders (RFC 8030) with every push, also to relays.Urgencyis:lowfor a push withoutmessage.normalfor a push that onlyjoinedselects.highfor 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: thepush_idof the registration, if it has one. Clients drop a payload with apush_idthat they do not know. A client that registered apush_idMAY 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.wakelistsbadgesendunread. - Servers MAY leave messages that a room
mutesilences out ofunread. Amutewithoutroom_iddoes not changeunread.
- Servers whose
message: the message (§3.5) withoutlog_id. Clients never install it as a snapshot. Servers do not push transient notices.- Servers SHOULD omit
format,embeds, andext. - To fit the limit, servers MAY truncate
body.text, and leave out any field exceptmessage_id,room_id, andfrom.user_id. - A payload without
messageshows no notification.
- Servers SHOULD omit
- Clients SHOULD show at most one notification for each
push_idandmessage_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 exceptbadgeselects new messages in rooms that the user can see:mentions: messages whosementionslist the user (§3.5.5). Servers MAY also wake for an edit that adds the user tomentions.private: messages in private rooms (§4.2.4) that the user joined, and in their threads.replies: messages whosereply_torefers to a message of the user (§3.5.4).joined: messages in rooms that the user joined.badge: every change tounread. A push for a change that no other scope selects has nomessage. Servers ignorebadgeforwebpush. Servers MAY skip intermediate values ofunread, and send only the latest.
- Servers advertise only the scopes that they implement, and ignore other
scopes in
wake. An emptywakewakes for nothing. Withoutwake, the server uses its default scopes. These SHOULD bementionsandreplies, where advertised. - A push that
muteor a chosendnd(§4.5.1) silences has nomessage. It goes only to registrations that wake forbadge. - 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
idleoroffline(§4.5.1). Servers MAY wait a short time first, and skip the push if theread_message_idof the user is past the message.
4.10 WebAuthn authentication#
server.auth enables passkeys, and email and external sign-in.
server.auth enables passkeys, and email and external sign-in.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 aregisterbegin step foruser.nameanduser.displayNameinpublic_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.
server.auth enables passkeys, and email and external sign-in.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
authrequest withouttoken. 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'sstate, 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 carriedstate, plus&server=…with the server's address (A.4). - A client asks its user before it uses a link without the
stateof a proposal that the client made. It then presents the link's token withscheme: "token". - For a link with the
stateof its own proposal, a client uses the server that it proposed to, and ignoresserver.
4.11.3 Approval#
- Approval is an
authrequest withtoken, andtokenor 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
youand a bearertokenfor 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 alsodenied, 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 areturn_urlon 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.urlis an absolutehttpsURL, orhttpon a loopback host. Clients reject any otherurl, such asjavascript:…. - The client opens
urlin 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_urlwith the link fragment, which replaces any fragment ofreturn_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 WebSocketOriginheader. - 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
ext#extSome 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
extone 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.nullis an ordinary value, and"ext": {}changes nothing. - Writes are
me(§3.3.2),messagerequests (§3.5, §4.4), androom_set(§4.2.4). A write that creates a record, or saves a message whose current snapshot is a tombstone, merges into an emptyext. A save withdeleted: truedropsext. - Complete user objects and records carry their complete
ext. Ausernotification 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 mergedextis over a limit istoo_large, and changes nothing. - Clients send
extonly to a server that advertises capabilityext, or the capability of the extension that defines the key. - Without capability
ext, servers MAY drop theextthat clients send. - An extension keeps its own data under its name in
ext, on whatever object it defines, such as theserverframe. 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'sserver(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#
authis a barrier (§3.2.2). Requests that follow it never run without authentication, or as an earlier identity.- The server executes
authon each connection, also when it repeats anid(§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
tokenfrom 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
Refererheaders, 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
stateof 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_urlon 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 knownuser_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_idto another user (§3.3.3). Logged messages and mentions refer to it, and the new owner would inherit them. rolesare 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_paramsas 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: truebefore 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
seenchanges only when the shown status changes tooffline(§4.5.2). So the connections of a user with a choseninvisiblestay 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
htmlembeds from every source (§4.8). This prevents stored cross-site scripting (XSS). iframeembeds run withsandbox="allow-scripts", never withallow-same-origintoo (§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
httpsurlfor external sign-in (§4.11.5). So a server cannot run script in the client with ajavascript: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 asserver. - 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 afile:orjavascript:URL, or start another app.
5.5 Network privacy and server requests#
- Servers host or proxy
ogmedia 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_idand the URLs that it hosts (§4.8.2). So a client cannot point an embed at a file that it does not own. write_urlis a credential that expires (§4.8.3). So only the sender can write an upload.- Servers accept only
httpspush 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 byidalone (§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. Afterretry_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_idis 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
~roommessages. ~privatemessages are transient notices (§3.5.3). A private notice that should last belongs in a room of its own.- A
~privatenotice MAY omitroom_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.comandtag#opscontain neither. - Suggested convention: servers assign ordinary
user_ids androom_ids from letters, digits,_,., and-, starting and ending with a letter, a digit, or_, such asguest_1234orgeneral. 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
textrenders is up to the client. - Clients MAY show an
@idthat refers to a known user with the user's latest display name (§3.3.1), such as a chip. They MAY show a#idthat refers to a known room as a link with its title. Unknown IDs render as written.
A.4 Links#
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/#orapron:?asapplication/x-www-form-urlencodedpairs, 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
httpsform 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
tokenin theapron:form (any app can claim the scheme). - A link without
serverin content from a server, such asapron:?room=opsinbody.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
stateof 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.nullcomes 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
actionsembed that a client sends. - Choosing one of
commandssends itscommandas acommandrequest, or as the request it refers to, such as/join(§4.1). When the message has amessage_id, the request'sreply_torefers to it. - Clients ignore groups they do not know (§1).
- Clients without
actionssupport render the fallback card (§3.5.2). Itsogcan 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.