AppView
The Colibri AppView is responsible for aggregating message data from across the network and providing endpoints for interacting with this data. Any requests to the AppView are meant to be proxied via the user’s PDS, to which the client application makes the requests. When requests are forwarded, the following DID URI should be used to indicate the target:
did:web:api.colibri.social#colibri_appviewThe push-registration endpoints registerPush and unregisterPush instead target the notification service:
did:web:api.colibri.social#colibri_notifBoth services resolve to the same endpoint today, so this only affects which atproto-proxy fragment clients send; using #colibri_notif for push registration keeps the declared service boundary correct if the notification service is ever split onto its own deployment. The notification feed reads (listNotifications, getUnreadCount, updateSeen, updateSeenForMessage, getUnseen) remain on #colibri_appview.
Implemented Endpoints
Section titled “Implemented Endpoints”The AppView supports a range of XRPC methods that PDSs can call. All com.atproto endpoints adhere to the original specification. There are also certain social.colibri XRPC endpoints which allow for extended functionality. All of these endpoints are available under the /xrpc/ path.
com.atproto.identity.resolveDid
Section titled “com.atproto.identity.resolveDid”Resolves a DID to a DID document. Does not bi-directionally verify the handle.
com.atproto.identity.resolveHandle
Section titled “com.atproto.identity.resolveHandle”Resolves an atproto handle (hostname) to a DID. Does not necessarily bi-directionally verify against the DID document.
com.atproto.identity.resolveIdentity
Section titled “com.atproto.identity.resolveIdentity”Resolves an atproto handle or DID to a DID document. Handles are resolved to a DID first, then the DID document is fetched.
com.atproto.sync.getBlob
Section titled “com.atproto.sync.getBlob”Proxies a blob fetch to the PDS that hosts the given DID. Blobs are not stored locally.
com.atproto.sync.getRecord
Section titled “com.atproto.sync.getRecord”Get a single cached record from the AppView.
com.atproto.sync.listRecords
Section titled “com.atproto.sync.listRecords”List a range of cached records in a repository, matching a specific collection.
social.colibri.server.describeServer
Section titled “social.colibri.server.describeServer”Identifies the server as a Colibri AppView and reports its flavor and version. method=“GET” nsid=“social.colibri.server.describeServer” response=[object Object] />
software is always "colibri-appview". This is the field clients key on to decide whether a host is a Colibri AppView. flavor identifies the AppView build ("vanilla" for the stock AppView; forks may set any arbitrary string). version is the running crate version.
social.colibri.actor.getData
Section titled “social.colibri.actor.getData”Get the status data and profile for a Colibri user.
Profile fields (displayName, avatar, banner, description) are sourced from the user’s social.colibri.actor.profile record. When that record has syncBluesky: true, the AppView instead serves these four fields live from the user’s app.bsky.actor.profile record (Bluesky stays the source of truth). If the user has no social.colibri.actor.profile record yet (not onboarded), the AppView falls back to app.bsky.actor.profile. The Colibri-only theme is always taken from the Colibri profile record, and isBot is always derived from the Bluesky self-label convention.
social.colibri.actor.setState
Section titled “social.colibri.actor.setState”Sets the online state for a Colibri user.
social.colibri.actor.listCommunities
Section titled “social.colibri.actor.listCommunities”Lists all communities the Colibri user is a part of (and not banned from).
isOwner reports whether the authenticated caller owns the community (user holding the “Owner” role).
social.colibri.actor.listMutes
Section titled “social.colibri.actor.listMutes”Lists every channel or community the authenticated user has muted. Each entry is one social.colibri.actor.mute record, subject is the AT-URI of the muted channel or community.
social.colibri.sync.subscribeEvents
Section titled “social.colibri.sync.subscribeEvents”Opens a connection with a WebSocket stream that transmits relevant events for the user.
Authentication
Section titled “Authentication”Unlike the HTTP endpoints, this WebSocket cannot be PDS-proxied: browsers expose no way to set an Authorization header (or any custom header) on a WebSocket. The token is therefore passed through the one handshake field a browser can control: the subprotocol list (Sec-WebSocket-Protocol).
The client mints a short-lived service auth token (aud: did:web:api.colibri.social, lxm: social.colibri.sync.subscribeEvents) and opens the socket offering two subprotocols, in order:
colibri.auth.bearer: a fixed sentinel marking that the next entry is the token.- the service-auth JWT itself.
new WebSocket(url, ["colibri.auth.bearer", token]);A service-auth JWT is base64url ([A-Za-z0-9._-]), every character of which is a valid subprotocol token, so it passes through the handshake unmodified. The AppView reads the entry following the colibri.auth.bearer sentinel, verifies it, and echoes the colibri.auth.bearer sentinel back in the response Sec-WebSocket-Protocol header, per RFC 6455 a browser that offered a subprotocol fails the connection unless the server selects one, so this echo is required. The JWT itself is never echoed back.
The auth query parameter is still accepted as a transitional / local-dev fallback; when both are present, the subprotocol token wins.
Events
Section titled “Events”The event stream can choose to transmit events to clients who have signed up to receive them. All events follow the same JSON data structure: they have a type (which is the same as the heading below), and an optional data field.
Sent when the AppView receives a heartbeat message from the client. This event carries no data.
community_event
Section titled “community_event”Events of this type are sent to clients when a community has been updated or deleted. If the event is set to delete, only the community’s uri needs to be supplied.
{ event: 'upsert' | 'delete'; uri: string; name?: string; description?: string; picture?: blob; categoryOrder?: string[]; requiresApprovalToJoin?: boolean;}member_event
Section titled “member_event”Events of this type are sent to clients when a member joins a community, has their roles changed, or leaves. member_event is broadcast to all connected clients (not just the subject). Clients should check community to scope the update to the right community, and check member.did against their own DID to detect when they themselves have been admitted or had their roles updated.
event |
When | membership |
member |
memberDid |
|---|---|---|---|---|
join |
A new member record is written (auto-admit or approveMembership) |
AT-URI of the user’s membership declaration, if available | Always present | Absent |
roles_updated |
An existing member record is updated (e.g. a moderator changes roles) | Absent | Always present with the new roles array |
Absent |
leave |
A member record is deleted (kick, ban, or self-leave). Sent to all remaining members | Absent | Absent | Always present |
{ event: 'join' | 'roles_updated' | 'leave'; community: string; membership?: string; member?: { did: string; handle: string; roles: string[]; joinedAt: string; nickname?: string; data: { displayName: string; avatar?: blob; banner?: blob; description?: string; isBot: boolean; onlineState: string; status: { text: string; emoji?: string; }; }; }; /** DID of the member who left, only present on `leave` events. */ memberDid?: string;}application_event
Section titled “application_event”Events of this type are sent to clients for changes to the moderator-facing pending-applications queue of a requiresApprovalToJoin community. Broadcast to all connected clients.
event |
When | did / handle / createdAt / data |
|---|---|---|
create |
A new social.colibri.membership is indexed for a closed community, or a kicked member’s original membership record is still on file and the community currently requires approval |
Always present |
resolve |
The application was admitted via approveMembership |
Absent |
dismiss |
A moderator hid the application from the active queue via dismissApplication (off-protocol) |
Absent |
undismiss |
A dismissed application was restored via undismissApplication (off-protocol) |
Absent |
{ event: 'create' | 'resolve' | 'dismiss' | 'undismiss'; community: string; membership: string; did?: string; handle?: string; createdAt?: string; data?: { displayName: string; avatar?: blob; banner?: blob; description?: string; isBot: boolean; onlineState: string; status: { text: string; emoji?: string; }; };}category_event
Section titled “category_event”Events of this type are sent to clients when a category has been created, updated, or deleted. When the event is delete, all data except the uri and event can be omitted.
{ event: 'upsert' | 'delete'; uri: string; community?: string; name?: string; channelOrder?: string[];}channel_event
Section titled “channel_event”Events of this type are sent to clients when a channel has been created, updated, or deleted. When the event is delete, all data except the uri and event can be omitted. On upsert, category is always present so clients can place the channel in the correct category without a follow-up fetch.
{ event: 'upsert' | 'delete'; uri: string; community?: string; category?: string; name?: string; description?: string; type?: string; ownerOnly?: boolean; allowedRoles?: string[]; allowedMembers?: string[];}role_event
Section titled “role_event”Events of this type are sent to clients when a role has been created, updated, or deleted. When the event is delete, all data except uri is omitted. On upsert, community, name, permissions, and position are always present.
{ event: 'upsert' | 'delete'; uri: string; community?: string; name?: string; color?: string; permissions?: string[]; position?: number; hoisted?: boolean; mentionable?: boolean;}message_event
Section titled “message_event”Events of this type are sent to clients when a message has been sent, edited, or deleted. When the event is delete, all data except the uri and event can be omitted. On upsert, the author object is always present.
{ event: 'upsert' | 'delete'; uri: string; channel?: string; text?: string; facets?: facet[]; createdAt?: string; edited?: boolean; parent?: string; attachments?: { blob: blob; name?: string; }[]; author?: { did: string; handle: string; data: { displayName: string; avatar?: blob; banner?: blob; description?: string; isBot: boolean; onlineState: string; status: { text: string; emoji?: string; }; }; };}reaction_event
Section titled “reaction_event”Events of this type are sent to clients when a reaction has been added to or removed from a message.
{ event: 'added' | 'removed'; uri: string; emoji?: string; target?: string;}user_event
Section titled “user_event”Events of this type are sent to clients when a user the client knows about has updated their Bluesky profile or Colibri status.
{ did: string; status?: { emoji?: string; text: string; state: 'online' | 'away' | 'dnd' | 'offline'; }; profile: { displayName?: string; avatar?: blob; banner?: blob; description?: string; isBot: boolean; handle: string; };}typing_event
Section titled “typing_event”Events of this type are sent to clients when a user is typing in the channel the user is currently viewing. For notes on how to report this to the AppView, see the view message type.
{ event: 'start' | 'stop'; channel: string; did: string;}Messages
Section titled “Messages”The following messages can be sent from the client to the AppView. All messages follow the same JSON data structure: they have a type (which is the same as the heading below), and an optional data field.
heartbeat
Section titled “heartbeat”A generic event that carries no data. It is used to keep the socket connection alive. The AppView will always respond with an ack event.
typing
Section titled “typing”Clients send a typing message to report that the current user is typing in a channel. The AppView broadcasts a typing_event to every other client that is currently viewing the same channel (determined by the last view message they sent). No response is sent back to the sender.
{ channel: string;}Clients can send a view message to the AppView to inform it about the channel the user is currently viewing. The AppView uses this to determine who should receive typing_event broadcasts.
{ channel: string;}voice_join
Section titled “voice_join”Clients must send a voice_join message to the AppView when the user joins a voice channel.
{ channel: string; community: string;}voice_leave
Section titled “voice_leave”Clients must send a voice_leave message to the AppView when the user leaves a voice channel.
social.colibri.community.create
Section titled “social.colibri.community.create”Mints a new community DID on the AppView’s own PDS and bootstraps a fully populated community. The AppView authenticates against PDS_LOC using admin credentials so it can create accounts without an invite code, stores the new account’s credentials encrypted in community_credentials, and writes five records to the new repo:
social.colibri.community(pinned atrkey: "self")social.colibri.category: a default"General"categorysocial.colibri.channel: a default"general"text channel inside that categorysocial.colibri.role: an"Owner"role with every permission andprotected: trueso role-management endpoints refuse to delete itsocial.colibri.member:subject= authenticated caller, holding the Owner role
The caller ends up as an owner-equivalent member of the new community. A community picture is uploaded by sending its raw bytes as the request body with the image’s MIME type declared via the mimeType query parameter. If supplied, the picture is validated against the allowed MIME types (image/jpeg, image/png, image/gif), uploaded to the community’s PDS, and stored as the community picture. An empty body means no picture.
The AppView identifies communities by DID; handles minted alongside the account are an implementation detail of PDS hosting and are not expected to be used for identity resolution. The AppView fails to start if any of PDS_LOC, APPVIEW_HANDLE_DOMAIN, or CREDENTIAL_ENCRYPTION_KEY are missing.
social.colibri.community.update
Section titled “social.colibri.community.update”Updates the community’s settings. Only fields that are supplied are changed. A new community picture is uploaded by sending its raw bytes as the request body with the image’s MIME type declared via the mimeType query parameter. An empty body leaves the existing picture untouched.
Broadcasts community_event { event: "upsert", uri, name?, description?, picture?, requiresApprovalToJoin? }.
social.colibri.community.delete
Section titled “social.colibri.community.delete”Deletes a community. Requires the caller to hold the community.delete permission. For AppView-managed communities (those minted via create) the entire PDS account is torn down via com.atproto.admin.deleteAccount, removing the repo and every record on it. For BYO communities the AppView does not administer the hosting PDS, so the account is left intact; the AppView only stops tracking it.
In both cases the stored credentials and every locally cached record for the community DID are removed, so the community no longer surfaces through any read endpoint.
social.colibri.community.registerCredentials
Section titled “social.colibri.community.registerCredentials”Registers user-supplied credentials (PDS endpoint + identifier + app password) for an existing community DID hosted on a PDS the AppView doesn’t manage. The AppView verifies proof-of-control by performing a com.atproto.server.createSession against the supplied PDS.
social.colibri.community.approveMembership
Section titled “social.colibri.community.approveMembership”Admits a pending applicant to a closed community (requiresApprovalToJoin: true) by writing the community-side social.colibri.member record. Requires approval.manage permission.
On admission, broadcasts application_event and clears any dismissal row for the subject.
social.colibri.community.listApplications
Section titled “social.colibri.community.listApplications”Lists the pending join applications for a community that requires approval (requiresApprovalToJoin: true). A pending application is an indexed social.colibri.membership targeting the community whose author does not yet hold a social.colibri.member record on the community repo.
dismissedApplications entries share the exact same shape as applications.
social.colibri.community.dismissApplication
Section titled “social.colibri.community.dismissApplication”Hides a pending join application from the active listApplications queue without resolving it. Tracked only in AppView storage. Requires approval.manage.
Broadcasts application_event { event: "dismiss", community, membership, did }.
social.colibri.community.undismissApplication
Section titled “social.colibri.community.undismissApplication”The inverse of dismissApplication. Requires approval.manage.
Broadcasts application_event { event: "undismiss", community, membership, did }.
social.colibri.category.create
Section titled “social.colibri.category.create”Creates a new category in a community and appends it to the community’s categoryOrder. Requires category.create permission.
Broadcasts category_event { event: "upsert", uri, community, name, channelOrder: [] } and community_event { event: "upsert", uri, categoryOrder }.
social.colibri.category.update
Section titled “social.colibri.category.update”Updates a category’s name. Requires category.update permission.
Broadcasts category_event { event: "upsert", uri, name }.
social.colibri.category.delete
Section titled “social.colibri.category.delete”Deletes a category and removes it from the community’s categoryOrder. Requires category.delete permission.
Broadcasts category_event { event: "delete", uri } and community_event { event: "upsert", uri, categoryOrder }.
social.colibri.channel.create
Section titled “social.colibri.channel.create”Creates a new channel inside a category and appends it to the category’s channelOrder. Requires channel.create permission.
allowedRoles and allowedMembers optionally seed the channel’s post allow-list at creation, letting a channel be born restricted (see channel.update for the allow-list semantics). Both are provided as repeated query-string values. The same hierarchy guard as channel.update applies to every seeded entry: a non-admin creator may only grant access to roles strictly below their own highest role and to members they outrank; the community owner is exempt. A violation returns Forbidden.
Broadcasts channel_event { event: "upsert", uri, community, category, name, type, allowedRoles, allowedMembers } and category_event { event: "upsert", uri, channelOrder }.
social.colibri.channel.update
Section titled “social.colibri.channel.update”Updates a channel’s name, description, owner-only flag, and/or post restrictions. Requires channel.update permission.
allowedRoles and allowedMembers form an allow-list of who may post in the channel: when both are empty, every community member may post (the default). When either is non-empty, only the community owner, members holding one of the listed roles, or the listed member DIDs may post. ownerOnly: true takes precedence over both lists and restricts posting to the owner alone.
Because a non-empty allowedRoles/allowedMembers list cannot be distinguished from “no change requested” using the list itself, clearing a restriction back to “everyone may post” requires the corresponding clearAllowedRoles/clearAllowedMembers flag, since passing an empty list is a no-op.
Two hierarchy guards apply:
ownerOnlyis owner-only to change. Holdingchannel.updateis not enough. Only the community owner may flip this flag, since it overrides every other restriction (including the editor’s own access, if they’re not the owner).allowedRoles/allowedMembersedits are scoped to the editor’s hierarchy. Only the entries that actually change (added or removed relative to the channel’s current lists) are checked, entries already present that aren’t touched are left alone. For each changed role, the editor’s highest role position must be strictly greater than that role’s position (so an editor can never add or remove their own top role, or anything at or above it). For each changed member, the editor must outrank that member by the same strictly-greater rule applied to roles, computed the same way as the moderation hierarchy check used bybanUser/kickUser. The community owner is exempt from both checks. A violation returnsForbidden.
Broadcasts channel_event { event: "upsert", uri, name?, description?, ownerOnly?, allowedRoles?, allowedMembers? }.
social.colibri.channel.delete
Section titled “social.colibri.channel.delete”Deletes a channel and removes it from its parent category’s channelOrder. Requires channel.delete permission.
Broadcasts channel_event { event: "delete", uri } and category_event { event: "upsert", uri, channelOrder }.
social.colibri.community.reorderChannels
Section titled “social.colibri.community.reorderChannels”Persists a new channel order within a category. channelOrder is provided as repeated query-string values. Requires channel.update permission.
Broadcasts category_event { event: "upsert", uri, channelOrder }.
social.colibri.community.reorderCategories
Section titled “social.colibri.community.reorderCategories”Persists a new category order for the community sidebar. categoryOrder is provided as repeated query-string values. Requires category.update permission.
Broadcasts community_event { event: "upsert", uri, categoryOrder }.
social.colibri.community.kick
Section titled “social.colibri.community.kick”Removes a member from the community by DID. Writes a kick moderation record and revokes the member record. Unlike kickUser, accepts a DID directly (no handle resolution). Requires member.kick permission.
Broadcasts member_event { event: "leave", community, memberDid } via the tap firehose once the member record is deleted.
social.colibri.community.setMemberRoles
Section titled “social.colibri.community.setMemberRoles”Replaces a member’s full role set. roles is provided as repeated query-string values (AT-URIs or bare rkeys). Requires role.manage permission.
Broadcasts member_event { event: "roles_updated", community, member: { did, roles, data... } } via the tap firehose once the member record is updated.
social.colibri.role.create
Section titled “social.colibri.role.create”Creates a new role in a community. Requires role.manage permission. position sets the role’s hierarchy rank; higher values sit higher. permissions is provided as repeated query-string values.
Broadcasts role_event { event: "upsert", uri, community, name, permissions, position, color?, hoisted?, mentionable? } via the tap firehose once the role record is written.
social.colibri.role.update
Section titled “social.colibri.role.update”Updates a role’s fields. Only supplied fields are changed; omitted fields keep their current values. Supplying a non-empty permissions list replaces the current permission set. Requires role.manage permission. Returns InvalidRequest if the role has protected: true.
Broadcasts role_event { event: "upsert", uri, community, name, permissions, position, ... } via the tap firehose once the role record is updated.
social.colibri.role.delete
Section titled “social.colibri.role.delete”Deletes a role from a community. Requires role.manage permission. Returns InvalidRequest if the role has protected: true (e.g. the bootstrap Owner role).
Broadcasts role_event { event: "delete", uri } via the tap firehose once the role record is deleted.
social.colibri.community.listBannedUsers
Section titled “social.colibri.community.listBannedUsers”Lists all banned users for a community, each hydrated into their full profile (handle, Bluesky profile, and Colibri status).
social.colibri.community.listCategories
Section titled “social.colibri.community.listCategories”Lists all categories in a community.
social.colibri.community.listChannels
Section titled “social.colibri.community.listChannels”Lists all channels in a community.
social.colibri.community.listRoles
Section titled “social.colibri.community.listRoles”Lists all roles cached for a community.
social.colibri.community.listMembers
Section titled “social.colibri.community.listMembers”Lists all members with their roles, status information and Bluesky profiles.
social.colibri.community.getData
Section titled “social.colibri.community.getData”Returns all cached data for a community in a single response: the community record, its categories, channels, roles, and members. Returns NotFound if no community record is cached for the given URI.
social.colibri.community.blockMessage
Section titled “social.colibri.community.blockMessage”Hides a message in a given community. Requires the caller to hold the message.hide permission.
Broadcasts message_event { event: "delete", uri: <message> } via the tap firehose once the moderation record is indexed, so connected clients remove the hidden message without a reload.
social.colibri.community.banUser
Section titled “social.colibri.community.banUser”Bans a user from a community by writing a ban moderation record. Requires the member.ban permission. Callers can only ban members ranked strictly below them in the role hierarchy.
social.colibri.community.unbanUser
Section titled “social.colibri.community.unbanUser”Unbans a user from a community, allowing them to write messages there again. Requires the member.unban permission.
Broadcasts member_event { event: "leave", community, memberDid } via the tap firehose once the member record is revoked.
social.colibri.community.kickUser
Section titled “social.colibri.community.kickUser”Kicks a user from a community by writing a kick moderation record and revoking their membership. Unlike a ban, the kicked user is not prevented from rejoining. Requires the member.ban permission. Callers can only kick members ranked strictly below them in the role hierarchy.
Broadcasts member_event { event: "leave", community, memberDid } via the tap firehose once the member record is revoked.
social.colibri.community.createInvitation
Section titled “social.colibri.community.createInvitation”Creates an invitation code for the specified community. Requires the invitation.create permission.
social.colibri.community.getInvitation
Section titled “social.colibri.community.getInvitation”Get information about a given invitation code. Does not require authentication.
social.colibri.community.listInvitations
Section titled “social.colibri.community.listInvitations”List all invitations for the specified community. Requires the invitation.create permission.
social.colibri.community.deleteInvitation
Section titled “social.colibri.community.deleteInvitation”Deactivates an invitation code for the specified community. Requires the invitation.delete permission.
social.colibri.channel.listMessages
Section titled “social.colibri.channel.listMessages”Get a paginated message history for the specified channel, newest messages first. Banned users’ messages are filtered out, as are messages that have been hidden by a hideMessage moderation action (and not later un-hidden). Hidden messages are excluded from both the top-level list and any embedded parent. Set all to true to include hidden messages in the response (e.g. for moderation views); it defaults to false. The createdAt field reflects when the message was authored (taken directly from the AT Protocol record), not when the AppView indexed it.
social.colibri.channel.getReadCursor
Section titled “social.colibri.channel.getReadCursor”Get the read cursor for the current user and specified channel.
social.colibri.channel.listUnreadStatus
Section titled “social.colibri.channel.listUnreadStatus”Returns per-channel unread status for the authenticated user across every channel in a community.
social.colibri.channel.listReactions
Section titled “social.colibri.channel.listReactions”Get all reactions for a specified message, grouped by emoji with reactor DIDs.
social.colibri.embed.getMetadata
Section titled “social.colibri.embed.getMetadata”Fetches a URL server-side and returns its OpenGraph/Twitter-card metadata, so the client’s IP is never exposed to the target site. Requires authentication to keep the AppView from becoming a public open URL-fetch proxy. Results are cached, including empty ones, to avoid re-fetching pages that carry no embed tags. The outbound request is guarded against SSRF (private, loopback, and link-local addresses are refused). largeImage reflects whether the preview should render large or as a small thumbnail.
social.colibri.embed.getImage
Section titled “social.colibri.embed.getImage”Proxies a remote embed preview image through the AppView so the client’s IP is never exposed to the image host. Unauthenticated, like com.atproto.sync.getBlob, because it is loaded directly via an <img src>, but constrained by the same SSRF guard and an image/* content-type requirement: anything that isn’t an image is rejected.
social.colibri.notification.listNotifications
Section titled “social.colibri.notification.listNotifications”Returns a paginated list of notifications for the authenticated user, newest first. Each notification embeds the underlying message body so clients can render the notification without a follow-up fetch. message is omitted if the underlying message has been removed from the AppView cache.
The kind value is one of mention or reply.
Mentions and replies are deduplicated per (recipient, message, kind), so a single message that both mentions and replies to the recipient produces two notification rows.
social.colibri.notification.getUnreadCount
Section titled “social.colibri.notification.getUnreadCount”Returns the count of unseen notifications for the authenticated user.
social.colibri.notification.updateSeen
Section titled “social.colibri.notification.updateSeen”Marks every unseen notification for the authenticated user whose indexedAt is at or before seenAt as seen. If seenAt is omitted, the AppView uses its current clock for both the cutoff and the stamped seenAt.
social.colibri.notification.getUnseen
Section titled “social.colibri.notification.getUnseen”Returns the authenticated user’s unseen notifications within a single channel. Clients use this when a channel opens to learn which messages still owe a ping, so each one can be cleared (via updateSeenForMessage) as its message scrolls into view.
social.colibri.notification.updateSeenForMessage
Section titled “social.colibri.notification.updateSeenForMessage”Marks the authenticated user’s unseen notifications for a single message as seen. When at least one row is cleared, the AppView broadcasts a seen_event with event: "message_seen" to the user’s other connected clients so their unread badges update live.
social.colibri.notification.registerPush
Section titled “social.colibri.notification.registerPush”Registers a Web Push subscription for the authenticated user so the AppView can deliver background push notifications when the app is closed.
social.colibri.notification.unregisterPush
Section titled “social.colibri.notification.unregisterPush”Drops a previously registered Web Push subscription for the authenticated user, identified by its endpoint. Called when the user disables notifications or the browser rotates/expires the subscription.
notification_event
Section titled “notification_event”Notification events are emitted over social.colibri.sync.subscribeEvents immediately after a notification is indexed. The payload mirrors a single entry of listNotifications and always carries the message body.
{ id: integer; kind: 'mention' | 'reply'; messageUri: string; authorDid: string; channelUri: string; indexedAt: string; message: { text: string; facets: facet[]; createdAt: string; parent?: string; attachments: { blob: blob; name?: string; }[]; edited?: boolean; };}Web Push delivery
Section titled “Web Push delivery”Alongside each notification_event broadcast over the WebSocket, the AppView also sends a VAPID Web Push message to every Web Push subscription the recipient has registered via registerPush.
The push payload is JSON shaped for the Service Worker that renders it:
{ title: string; // "New mention" | "New reply" body: string; // the message text tag?: string; // the message URI (collapses duplicates) icon?: string; data: { channelUri: string; // required messageUri: string; };}The AppView owns the VAPID keypair and exposes the public key to clients out of band (as the PUBLIC_VAPID_KEY build-time environment variable). Subscriptions that the push service reports as gone (HTTP 404/410) are pruned automatically.
seen_event
Section titled “seen_event”Emitted over social.colibri.sync.subscribeEvents to keep unread state in sync across a user’s devices. Unlike most events, a seen_event is delivered only to the originating user’s own connected clients (filtered by DID).
{ event: 'channel_read' | 'message_seen'; channelUri: string; messageUri?: string; // present on message_seen cleared?: number; // present on message_seen}Humming
Section titled “Humming”Colibri is built to be self-hostable, so it’s possible that a community’s members are spread across several AppViews. On-protocol data reaches every AppView through the tap firehose. Off-protocol signals, like online status, typing, voice presence, never touch a repo, so without Humming they are contained within the AppView that produced them. A user on one instance can’t see the presence of a community member on another.
Humming bridges that gap. A Hum is a single off-protocol event (a status change, a typing start, a voice join/leave) that one AppView relays to another so it can be delivered to that instance’s clients over its ordinary social.colibri.sync.subscribeEvents stream. Humming carries only these ephemeral signals. It cannot carry a message, a membership, or any other on-protocol data.
Topology: per-community hub
Section titled “Topology: per-community hub”Every community already has exactly one AppView that administers it, the holder of its community_credentials, the only party that can write to the community repo. That AppView is the community’s presence hub. It is discovered from the community record itself: social.colibri.community carries an appview field (a did:web) naming the home AppView.
Presence flows through the hub in a star topology, not a gossip mesh:
- A leaf (any AppView with a local, online member of the community) sends the member’s Hums to the community’s hub via
sendHum. - The hub injects the Hum for its own local clients and fans it out to every leaf subscribed via
subscribeHums. - Leaves never re-forward. A Hum received over
subscribeHumsis delivered to local clients only.
A Hum therefore makes at most two hops (leaf → hub → leaf), and an instance only ever sees traffic for communities its own users belong to. There is no whole-network flood.
Trust model
Section titled “Trust model”A Hum is only acted on if it passes every check below. The design goal is that a forged or abusive Hum can, at worst, cause a cosmetic presence blip for a user who already opted in.
- Off-protocol data only. The wire type for a Hum’s event is a closed union of
userEvent,typingEvent, andvoicePresenceEvent. An on-protocol payload will be ignored. - The peer AppView is authenticated. Every Hum carries an inter-service auth JWT signed by the sending AppView’s key (
iss= itsdid:web,aud= the receiver’sdid:web,lxm= the method NSID). This is a peer-to-peer AppView token, not the user OAuth token used by the rest of the API. - The origin must be an AppView. The verified issuer must be a
did:web. This stops a user from minting a service-auth token under their owndid:plcand originating presence for themselves. - The origin must be the subject’s declared presence service. A Hum about a user is dropped unless its
originequals that user’spresenceService(see Opting in). A valid signature proves who sent the Hum, this check proves who they may speak for. A missingpresenceServicemeans the user hasn’t opted in, so the Hum is dropped. - The subject must be a member. The subject must hold a
social.colibri.memberrecord on the target community, so a peer can’t inject a user into a community they never joined. - Channels must belong to the community. Any channel a typing or voice event references must be an
at-uriwhose authority is the target community.
Finally, the receiver re-derives the subject’s identity from its own view of that user’s repo and attributes the injected event to the envelope’s subject. The peer-supplied identity fields inside the event payload (display name, handle, the inner did) are discarded and only the ephemeral status/typing/voice signal is taken from the Hum. This closes any attempt to attribute an event to a different user.
Opting in
Section titled “Opting in”Cross-instance presence is opt-in per user. A user opts in by publishing a presenceService field (a did:web) on their social.colibri.actor.profile, naming the AppView allowed to speak for their presence. The reference Colibri client keeps this in sync with the AppView the user actually connects to (the one chosen in the settings picker) and exposes a “Share presence across AppViews” toggle, on by default.
Turning the toggle off removes the presenceService key from the profile. A missing key is the signal for “opted out”: every AppView drops Hums about that user, so their presence, typing, and voice never cross an instance boundary. Local, same-AppView presence is unaffected.
The Hum envelope
Section titled “The Hum envelope”Both endpoints exchange the same envelope:
{ origin: string; // did:web of the AppView that produced the Hum id: string; // unique per origin, used for dedup across relay paths ttl: number; // remaining hops (0..8), starts at 1, hub decrements to 0 subject: string; // did of the user the event is about community: string; // at-uri of the community the event is scoped to event: UserEvent | TypingEvent | VoicePresenceEvent;}userEvent and typingEvent are the same shapes emitted over subscribeEvents. voicePresenceEvent describes a voice channel join/leave:
{ event: 'join' | 'leave'; channel: string; // at-uri of the voice channel did: string; // subject (re-derived by the receiver)}social.colibri.sync.sendHum
Section titled “social.colibri.sync.sendHum”Informs this AppView, acting as a community’s hub, of an off-protocol event that occurred for one of the calling AppView’s users.
Authentication
Section titled “Authentication”sendHum is authenticated with a peer-AppView inter-service auth JWT, not the user OAuth token used elsewhere. The token is supplied as a bearer token (Authorization: Bearer <jwt>), with aud set to the receiving AppView’s did:web and lxm set to social.colibri.sync.sendHum. The receiver resolves the issuer’s DID document, verifies the signature, and treats iss as the peer AppView identity, which must equal the envelope’s origin.
The receiver then runs the trust model checks, injects the event locally (presence and voice as Community-scoped events on subscribeEvents, typing as a client-to-client typing event), and, if it is a hub, while ttl > 0, decrements ttl and republishes the Hum to its subscribeHums subscribers.
Errors: AuthRequired (missing/invalid token), Forbidden (any trust-model check failed), RateLimited (peer exceeded its per-peer budget), UnsupportedEvent (payload was not an off-protocol event), NotEnabled (Humming disabled on this AppView).
social.colibri.sync.subscribeHums
Section titled “social.colibri.sync.subscribeHums”A peer AppView opens this WebSocket to receive, in real time, the Hums this AppView relays in its hub role.
Authentication
Section titled “Authentication”Like subscribeEvents, a browser-incompatible header can’t be set on a WebSocket, so the peer’s inter-service auth JWT (aud = this AppView, lxm = social.colibri.sync.subscribeHums) is offered through the subprotocol list, after the same colibri.auth.bearer sentinel:
new WebSocket(url, ["colibri.auth.bearer", token]);The sentinel is echoed back on success, the token is not.
Need-to-know egress
Section titled “Need-to-know egress”A valid token authenticates who is connecting, not what they may see. Streaming every relayed Hum to any authenticated peer would leak the presence of every user in every community on the hub. Instead, the hub only forwards Hums for a community when that community has a member who declared the connecting peer as their presenceService. This authorized set is recomputed periodically as memberships and presenceService choices change.
A peer may additionally narrow what it receives by declaring the communities it cares about as repeated communities query parameters (?communities=<did>&communities=<did>). This is intersected with the authorized set: it can only shrink the stream, never widen it. Omitting it streams everything the peer is authorized for.
Propagation bounds & abuse controls
Section titled “Propagation bounds & abuse controls”The star topology bounds propagation structurally (two hops, no re-forwarding), and several additional guards protect self-hosted instances:
ttlstarts at1and the hub decrements it to0on rebroadcast; a bound that, combined with leaves never re-forwarding, prevents loops.- Dedup. Each AppView keeps a bounded set of recently-seen envelope
ids and drops duplicates that arrive over more than one relay path, along with any Hum it originated itself. HUM_MAX_PEERScaps the number of distinct remote hubs a leaf will dial (default32). Beyond the cap, extra hubs are skipped.HUM_MAX_SUBSCRIBERScaps concurrent inboundsubscribeHumsstreams a hub will hold (default256). Further connections are rejected withTooManySubscribers.- Per-peer rate limit on
sendHum(HUM_RATE_LIMIT, default240/minute per peer). Presence and typing are inherently low-rate, so a peer exceeding the budget isRateLimited.