Skip to content

Latest commit

 

History

History
1771 lines (1315 loc) · 59.6 KB

File metadata and controls

1771 lines (1315 loc) · 59.6 KB

Chat Backend — Client API Reference

This document is the integrator-facing reference for the chat backend. It covers every API a client (web, mobile, third-party) can call:

  • HTTP — the single POST /auth endpoint that exchanges an SSO token for a NATS user JWT.
  • NATS request/reply — RPC-style methods exposed by room-service, history-service, and search-service.
  • NATS publish + async reply — the message-send flow handled by message-gatekeeper.

For each method, this doc lists the subject, the request body schema and example, the success response schema and example, the error response, and the server-pushed events the client will receive on the success and error paths.

Table of contents

  1. Overview
  2. Connection & Auth
  3. Request/Reply Methods
  4. Message Send
  5. Error envelope reference

1. Overview

This doc covers the public client-facing API surface only.

Out of scope (documented elsewhere or backend-internal):

  • Backend-only JetStream subjects (MESSAGES, MESSAGES_CANONICAL, FANOUT, OUTBOX, INBOX, ROOMS streams). See docs/nats-subject-naming.md.
  • Server-pushed events not triggered by a specific client RPC (federation arrivals, presence, room-key rotation, cross-site member events).
  • Server-to-server subjects (chat.server.request.…).

Subject placeholders

Subjects in this doc use these placeholders:

Placeholder Meaning
{account} The user's NATS account (preferred username from SSO claims, e.g. alice).
{roomID} A room ID (see "ID formats" below).
{siteID} The site that owns the room (each site runs its own NATS).
{requestID} A 36-char hyphenated UUIDv7 generated by the client for the message-send async-reply pattern.

Encoding

All NATS payloads are JSON. All HTTP request/response bodies are JSON.

ID formats

ID kind Format Length Notes
Account Lowercase string variable SSO-derived; appears as {account} in subjects.
Channel room ID base62 17 chars Generated server-side at room creation.
DM room ID sorted concat of two accounts ~len(a)+3+len(b) Deterministic; same two users always produce the same ID.
Message ID base62 17 or 20 chars New messages are 20-char; 17-char accepted for legacy/federated messages.
Request ID hyphenated UUIDv7 36 chars Both inbound X-Request-ID headers and the requestId payload field for msg.send.

Request-ID propagation

Clients may include an X-Request-ID NATS message header on outbound requests. If present, the server uses it for log correlation; if absent, the server generates one. The header value must be a valid hyphenated UUID (v4 or v7, case-insensitive).

The msg.send flow is different — see §4: the client puts the request ID in the JSON payload (requestId field), and the server replies on chat.user.{account}.response.{requestID}.

Reply patterns

  • Standard NATS request/reply — the NATS client library auto-generates a reply subject under _INBOX.> and routes the reply back to the caller. Used by every method in §3.
  • Async reply on chat.user.{account}.response.{requestID} — used only by msg.send (§4). The client publishes (no synchronous reply expected on _INBOX.>); the server reads requestId from the payload and publishes the reply to chat.user.{account}.response.{requestID}. The client must already be subscribed to chat.user.{account}.> (the user wildcard) to receive it.

Timestamps

All event payloads carry a top-level timestamp field that is milliseconds since the Unix epoch in UTC. Domain timestamps inside payloads (e.g. Message.createdAt) are RFC 3339 strings.


2. Connection & Auth

2.1 NATS connection

A client connects to NATS using a user NKey pair plus a signed JWT obtained from the auth-service (§2.2). The JWT scopes the client's permissions to:

Permission Subject pattern Why
Publish chat.user.{account}.> The client may publish only under its own user namespace. All RPC requests, the message-send subject, and any client-emitted event fall here.
Publish _INBOX.> Required for the standard NATS request/reply pattern (the auto-generated reply inbox).
Subscribe chat.user.{account}.> Receives all responses, notifications, and per-user events.
Subscribe chat.room.> Subscribes to per-room message streams and room events for any room the user belongs to.
Subscribe _INBOX.> Required to receive replies to client-issued requests.

Recommended baseline subscriptions on connect:

  • chat.user.{account}.> — captures every personal event including async replies, notifications, and subscription updates.
  • chat.room.{roomID}.stream.msg for each room in the user's sidebar — receives new messages.

The exact event subjects a client may receive as a result of an RPC are listed under each method's "Triggered events" sections in §2.2, §3, and §4.

2.2 HTTP — POST /auth

Endpoint: POST /auth Reply: synchronous HTTP response

Exchanges an SSO token for a signed NATS user JWT. The returned JWT is what the client uses to connect to NATS (see §2.1).

Request body

Field Type Required Notes
ssoToken string yes OIDC-issued SSO token.
natsPublicKey string yes The client's NATS user public NKey (must pass nkeys.IsValidPublicUserKey).
{
  "ssoToken": "<sso-token>",
  "natsPublicKey": "UDXU4RCSJNZOIQHZNWXHXORDPRTGNJAHAHFRGZNEEJCPQTT2M7NLCNF4"
}

Success response

HTTP 200

Field Type Notes
natsJwt string Signed NATS user JWT. Use as the user JWT when connecting to NATS.
user.email string OIDC email claim.
user.account string The {account} value used in every NATS subject. Derived from preferred_username (falls back to name).
user.employeeId string Parsed from the SSO description claim.
user.engName string Parsed from the SSO description claim.
user.chineseName string Parsed from the SSO description claim.
user.deptName string OIDC dept-name claim.
user.deptId string OIDC dept-id claim.
{
  "natsJwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ...",
  "user": {
    "email": "alice@example.com",
    "account": "alice",
    "employeeId": "E12345",
    "engName": "Alice",
    "chineseName": "愛麗絲",
    "deptName": "Engineering",
    "deptId": "ENG"
  }
}

Error response

See Error envelope. HTTP statuses:

Status Meaning Example body
400 Missing or malformed fields, or invalid natsPublicKey. { "error": "ssoToken and natsPublicKey are required" }
401 SSO token expired or invalid. { "error": "SSO token has expired, please re-login" }
500 Server-side JWT signing failure. { "error": "failed to generate NATS token" }

Triggered events — success path

None — HTTP-only.

Triggered events — error path

None.

Dev mode

When the auth-service is started with DEV_MODE=true, the request body schema is { "account": string, "natsPublicKey": string } (no SSO token; the supplied account is trusted). This is local-development only and is not part of the production contract.


3. Request/Reply Methods

3.1 room-service

Create Room

Subject: chat.user.{account}.request.rooms.create Reply subject: auto-generated _INBOX.> (NATS request/reply)

Request body
Field Type Required Notes
name string yes Room name.
type string yes One of channel, dm, botDM, discussion.
createdBy string yes Internal user ID of the creator.
createdByAccount string yes Account name of the creator. Used for the owner subscription.
siteId string yes The site that will own this room.
members string[] no Required exactly one entry when type=dm (the other user's ID); ignored otherwise.
{
  "name": "engineering-announcements",
  "type": "channel",
  "createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
  "createdByAccount": "alice",
  "siteId": "siteA"
}
Success response

The created Room object.

Field Type Notes
id string Room ID. 17-char base62 for channels; sorted concat of two accounts for DMs.
name string
type string Same values as request.
createdBy string
siteId string
userCount number 1 immediately after creation (the owner).
lastMsgAt string Optional. RFC 3339 timestamp; absent until first message.
lastMsgId string Empty until first message.
lastMentionAllAt string Optional. RFC 3339 timestamp.
minUserLastSeenAt string Optional. RFC 3339 timestamp.
createdAt string RFC 3339 timestamp.
updatedAt string RFC 3339 timestamp.
restricted boolean Optional.
{
  "id": "01970a4f8c2d7c9aQ",
  "name": "engineering-announcements",
  "type": "channel",
  "createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
  "siteId": "siteA",
  "userCount": 1,
  "lastMsgId": "",
  "createdAt": "2026-05-06T08:00:00Z",
  "updatedAt": "2026-05-06T08:00:00Z"
}
Error response

See Error envelope.

{ "error": "DM requires exactly one other member, got 0" }
Triggered events — success path

None — reply only. Member additions are a separate RPC (Add Members); creating a room only enrolls the owner.

Triggered events — error path

None — error returned only via the reply subject.


List Rooms

Subject: chat.user.{account}.request.rooms.list Reply subject: auto-generated _INBOX.> (NATS request/reply)

Request body

Empty. Send {} or no payload.

{}
Success response
Field Type Notes
rooms array All rooms the requester is subscribed to. See Create Room for the Room schema.
{
  "rooms": [
    {
      "id": "01970a4f8c2d7c9aQ",
      "name": "engineering-announcements",
      "type": "channel",
      "createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
      "siteId": "siteA",
      "userCount": 12,
      "lastMsgAt": "2026-05-06T07:55:00Z",
      "lastMsgId": "01970a4f8c2d7c9aQRST",
      "createdAt": "2026-05-01T10:00:00Z",
      "updatedAt": "2026-05-06T07:55:00Z"
    }
  ]
}
Error response

See Error envelope.

Triggered events — success path

None — reply only.

Triggered events — error path

None — error returned only via the reply subject.


Get Room

Subject: chat.user.{account}.request.rooms.get.{roomID} Reply subject: auto-generated _INBOX.> (NATS request/reply)

The room ID is the last subject segment — there is no request body.

Request body

Empty. Send {} or no payload.

{}
Success response

A single Room object. See Create Room for the Room schema.

{
  "id": "01970a4f8c2d7c9aQ",
  "name": "engineering-announcements",
  "type": "channel",
  "createdBy": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
  "siteId": "siteA",
  "userCount": 12,
  "lastMsgAt": "2026-05-06T07:55:00Z",
  "lastMsgId": "01970a4f8c2d7c9aQRST",
  "createdAt": "2026-05-01T10:00:00Z",
  "updatedAt": "2026-05-06T07:55:00Z"
}
Error response

See Error envelope.

{ "error": "room not found" }
Triggered events — success path

None — reply only.

Triggered events — error path

None — error returned only via the reply subject.


Add Members

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.member.add Reply subject: auto-generated _INBOX.> (NATS request/reply)

This is an async-job RPC: the synchronous reply only confirms acceptance. The actual member adds run asynchronously in room-worker, which publishes the events listed under "Triggered events" below. To receive the AsyncJobResult event, the client must set an X-Request-ID NATS header on the original request (see Request-ID propagation).

Request body
Field Type Required Notes
roomId string no Optional; the server derives the room ID from the subject and ignores any non-matching value.
users string[] no Internal user IDs (or accounts) to add directly.
orgs string[] no Org IDs to add (expanded server-side to all org members).
channels array no Other channels to add as bulk sources. Each entry is { "roomId": string, "siteId": string }.
history.mode string no "none" (default) or "all" — controls whether new members see history before they joined.

The fields requesterId, requesterAccount, and timestamp on the Go AddMembersRequest are server-set — the client should omit them.

{
  "users": ["bob"],
  "orgs": [],
  "channels": [
    { "roomId": "01970a4f8c2d7c9aP", "siteId": "siteA" }
  ],
  "history": { "mode": "all" }
}
Success response
Field Type Notes
status string Always "accepted". Confirms the request passed authorization and was queued for processing.
{ "status": "accepted" }
Error response

See Error envelope. Returned synchronously when validation or authorization fails (e.g. requester not in room, room is full, room is restricted and requester is not owner).

{ "error": "room is at maximum capacity (200): cannot add 5 members to room with 198 existing" }
Triggered events — success path

1. chat.user.{requesterAccount}.response.{requestID} — async job result delivered to the requester when the bulk add finishes.

Recipients: the original requester. Only published if the client set X-Request-ID on the original request.

Field Type Notes
requestId string Echoes the X-Request-ID value from the original request.
job string "add_members".
success boolean true if all members were added; false if the worker hit an error.
error string Optional. Sanitized message; present only when success=false.
timestamp number Milliseconds since Unix epoch (UTC).
{
  "requestId": "01970a4f-8c2d-7c9a-abcd-e0123456789f",
  "job": "add_members",
  "success": true,
  "timestamp": 1746518400123
}

2. chat.user.{newMember}.event.subscription.update — one event per newly added member.

Recipients: each new member (the freshly added user — not the requester, not existing members).

Field Type Notes
userId string The new member's internal user ID.
subscription object The full Subscription record (room ID, room type, roles, joinedAt, etc.).
action string "added".
timestamp number Milliseconds since Unix epoch (UTC).
{
  "userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
  "subscription": {
    "_id": "01970a4f8c2d7c9a01970a4f8c2d7c9b",
    "user": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "bob" },
    "roomId": "01970a4f8c2d7c9aQ",
    "roomType": "channel",
    "siteId": "siteA",
    "roles": ["member"],
    "joinedAt": "2026-05-06T08:01:23Z"
  },
  "action": "added",
  "timestamp": 1746518483000
}
Triggered events — error path

When the synchronous reply is an error envelope, the request was rejected before publishing to the worker — no events follow.

When the asynchronous job fails after acceptance, the requester receives the AsyncJobResult above with success: false. No subscription.update events are published in that case.


Remove Member

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.member.remove Reply subject: auto-generated _INBOX.> (NATS request/reply)

This is an async-job RPC: the synchronous reply only confirms acceptance. The actual member removal runs asynchronously in room-worker, which publishes the events listed under "Triggered events" below. To receive the AsyncJobResult event, the client must set an X-Request-ID NATS header on the original request (see Request-ID propagation).

Request body
Field Type Required Notes
account string no Remove a single user. Mutually exclusive with orgId.
orgId string no Remove all users in this org. Mutually exclusive with account.
roomId string no Server derives from subject; non-matching values are rejected.

Exactly one of account or orgId must be set. The fields requester and timestamp on the Go RemoveMemberRequest are server-set — the client should omit them.

{ "account": "bob" }
Success response
Field Type Notes
status string Always "accepted". Confirms the request passed authorization and was queued for processing.
{ "status": "accepted" }
Error response

See Error envelope. Returned synchronously when validation or authorization fails (e.g. neither or both of account/orgId set, requester is not an owner, target is the last member, or org member cannot leave individually).

{ "error": "exactly one of account or orgId must be set" }
Triggered events — success path

1. chat.user.{requesterAccount}.response.{requestID} — async job result delivered to the requester when the removal finishes.

Recipients: the original requester. Only published if the client set X-Request-ID on the original request.

Field Type Notes
requestId string Echoes the X-Request-ID value from the original request.
job string "remove_member" for single-account removal; "remove_org" for org removal.
success boolean true if removal succeeded; false if the worker hit an error.
error string Optional. Sanitized message; present only when success=false.
timestamp number Milliseconds since Unix epoch (UTC).
{
  "requestId": "01970a4f-8c2d-7c9a-abcd-e0123456789f",
  "job": "remove_member",
  "success": true,
  "timestamp": 1746518400123
}

2. chat.user.{removedAccount}.event.subscription.update — one event per removed account.

Recipients: each removed account (the user whose subscription was deleted).

Field Type Notes
userId string The removed user's internal user ID.
subscription object The Subscription record at the time of removal (room ID, room type, user info).
action string "removed".
timestamp number Milliseconds since Unix epoch (UTC).
{
  "userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
  "subscription": {
    "user": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "bob" },
    "roomId": "01970a4f8c2d7c9aQ",
    "roomType": "channel"
  },
  "action": "removed",
  "timestamp": 1746518483000
}
Triggered events — error path

When the synchronous reply is an error envelope, the request was rejected before publishing to the worker — no events follow.

When the asynchronous job fails after acceptance, the requester receives the AsyncJobResult above with success: false. No subscription.update events are published in that case.


Update Member Role

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.member.role-update Reply subject: auto-generated _INBOX.> (NATS request/reply)

This is an async-job RPC. The synchronous reply confirms acceptance; the actual role change runs in room-worker and emits the event below. Unlike Add Members and Remove Member, room-worker does not publish an AsyncJobResult event for role updates — there is no chat.user.{requesterAccount}.response.{requestID} event for this RPC.

Request body
Field Type Required Notes
roomId string no Server derives from subject; non-matching values are rejected.
account string yes The account of the user whose role is being changed.
newRole string yes Either "owner" (promote) or "member" (demote).

The timestamp field on the Go UpdateRoleRequest is server-set — the client should omit it.

{ "account": "bob", "newRole": "owner" }
Success response
Field Type Notes
status string Always "accepted". Confirms the request passed authorization and was queued for processing.
{ "status": "accepted" }
Error response

See Error envelope. Returned synchronously when validation or authorization fails. Common errors include:

  • Requester is not an owner of the room.
  • Target account is not a member of the room.
  • newRole is neither "owner" nor "member".
  • Promote attempt when the target is already an owner.
  • Demote attempt when the target is not an owner.
  • Last-owner guard: an owner cannot demote themselves if they are the only owner.
  • Promote attempt on an org-only member (individual subscription required).
{ "error": "only owners can update roles" }
Triggered events — success path

chat.user.{targetAccount}.event.subscription.update — emitted once for the user whose role changed.

Recipients: the target user (the account whose role was updated) only — not the requester, not other room members.

Field Type Notes
userId string The target user's internal user ID.
subscription object The full Subscription record reflecting the updated roles array.
action string "role_updated".
timestamp number Milliseconds since Unix epoch (UTC).
{
  "userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
  "subscription": {
    "_id": "01970a4f8c2d7c9a01970a4f8c2d7c9b",
    "u": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "bob" },
    "roomId": "01970a4f8c2d7c9aQ",
    "roomType": "channel",
    "siteId": "siteA",
    "roles": ["owner", "member"],
    "joinedAt": "2026-05-06T08:01:23Z"
  },
  "action": "role_updated",
  "timestamp": 1746518483000
}
Triggered events — error path

When the synchronous reply is an error envelope, no events follow. The async job has no separate failure event for role updates — failures inside the worker are logged but no client-visible signal is emitted.


List Members

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.member.list Reply subject: auto-generated _INBOX.> (NATS request/reply)

Request body
Field Type Required Notes
limit number no If set, must be > 0. Caps the number of members returned.
offset number no If set, must be >= 0. For pagination.
enrich boolean no When true, populates the display fields (engName, chineseName, isOwner, sectName, memberCount) on each entry. Omitted-or-false returns the lean record only.
{ "limit": 50, "enrich": true }
Success response
Field Type Notes
members array One entry per individual or org membership.

RoomMember:

Field Type Notes
id string Membership record ID (UUIDv7 hex).
rid string Room ID (note JSON key is rid, not roomId).
ts string RFC 3339 timestamp of when the membership was created.
member object See RoomMemberEntry below.

RoomMemberEntry:

Field Type Notes
id string The user account or org ID.
type string "individual" or "org".
account string Optional. Account name (set for individuals).
engName string Optional. Populated only when request had enrich: true.
chineseName string Optional. Populated only when enrich: true.
isOwner boolean Optional. Populated only when enrich: true.
sectName string Optional. Populated only when enrich: true and entry is an org.
memberCount number Optional. Populated only when enrich: true and entry is an org.
{
  "members": [
    {
      "id": "01970a4f8c2d7c9a01970a4f8c2d7c9b",
      "rid": "01970a4f8c2d7c9aQ",
      "ts": "2026-05-01T10:00:00Z",
      "member": {
        "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
        "type": "individual",
        "account": "alice",
        "engName": "Alice",
        "chineseName": "愛麗絲",
        "isOwner": true
      }
    }
  ]
}
Error response

See Error envelope. Common errors: "not a member of this room", "limit must be > 0", "offset must be >= 0".

Triggered events — success path

None — reply only.

Triggered events — error path

None — error returned only via the reply subject.


Mark Messages Read

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.message.read Reply subject: auto-generated _INBOX.> (NATS request/reply)

This is a synchronous RPCroom-service performs all writes inline before replying. The handler validates room membership, recomputes the per-subscription alert flag, persists the new lastSeenAt and alert on the user's Subscription, optionally recomputes Room.MinUserLastSeenAt, and (for cross-site users) publishes a subscription_read event to the user's home-site outbox so the destination inbox-worker can update its local subscription cache.

Request body
Field Type Required Notes
roomId string no Server derives from subject. If supplied, must match the subject's roomID; mismatches are rejected.
{ "roomId": "01970a4f8c2d7c9aQ" }
Success response
Field Type Notes
status string Always "accepted". Confirms the read receipt was applied.
{ "status": "accepted" }
Error response

See Error envelope. Common errors:

  • "only room members can list members" — the user has no subscription in the room (sentinel reused across membership-gated RPCs).
  • "room ID mismatch" — the body's roomId doesn't match the subject.
  • "invalid message-read subject: …" — the subject is malformed.
{ "error": "only room members can list members" }
Behaviour notes
  • Alert recomputation: new alert = oldSub.alert && len(oldSub.threadUnread) > 0. Reading the room clears the alert when there are no unread thread mentions; it stays set when thread-level unreads remain.
  • originalLastSeen resolution: the handler uses subscription.lastSeenAt if present, otherwise falls back to subscription.joinedAt (newly-joined rooms have never been read).
  • Room-floor recompute (Room.MinUserLastSeenAt): skipped when room.lastMsgAt is null or when originalLastSeen > room.lastMsgAt (the user was already up-to-date — the floor cannot have moved). Otherwise the handler computes the new floor as MIN(lastSeenAt OR joinedAt) across the room's subscriptions and writes it to rooms.minUserLastSeenAt (or unsets the field if the aggregate is empty).
  • Cross-site federation: if the user's home site (users.siteId) differs from the handler's site, a subscription_read event is published to outbox.{handlerSite}.to.{userSite}.subscription_read with payload {account, roomId, lastSeenAt, alert, timestamp} (timestamps as int64 UnixMilli). The destination inbox-worker applies the write with an $lt order-safety guard so out-of-order delivery cannot regress lastSeenAt. The outbox publish happens before the room-floor recompute so the user's home site receives every read receipt — even ones that don't move the room floor.
  • No system message, no fan-out events: read receipts are silent; only the requester receives the accepted reply.
Triggered events — success path

None — reply only. (Cross-site users may observe a delayed subscription.update on their home site driven by the outbox/inbox flow above; this is treated as cache convergence rather than a client-visible event for this RPC.)

Triggered events — error path

None — error returned only via the reply subject.


List Org Members

Subject: chat.user.{account}.request.orgs.{orgID}.members Reply subject: auto-generated _INBOX.> (NATS request/reply)

The org ID is the second-to-last subject segment — there is no request body.

Request body

Empty. Send {} or no payload.

{}
Success response
Field Type Notes
members array All individuals in the org.

OrgMember:

Field Type Notes
id string Internal user ID.
account string Account name.
engName string
chineseName string
siteId string The user's home site.
{
  "members": [
    {
      "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
      "account": "alice",
      "engName": "Alice",
      "chineseName": "愛麗絲",
      "siteId": "siteA"
    }
  ]
}
Error response

See Error envelope.

{ "error": "invalid org" }
Triggered events — success path

None — reply only.

Triggered events — error path

None — error returned only via the reply subject.


3.2 history-service

Message schema

Used by every history-service method that returns messages. Mirrors the Cassandra Message row.

Field Type Notes
roomId string
createdAt string RFC 3339 timestamp.
messageId string 17- or 20-char base62.
sender object A Participant — see below.
msg string The message body.
targetUser object Optional. Participant — set for direct/system messages addressed to a specific user.
mentions array Optional.
attachments string[] Optional. Each entry is base64-encoded bytes.
file object Optional. {id, name, type}.
card object Optional. {template, data?}. data is base64.
cardAction object Optional. {verb, text?, cardId?, displayText?, hideExecLog?, cardTmId?, data?}.
tshow boolean Optional. Whether a thread reply is also shown in the parent room.
tcount number Optional. Number of replies on a thread parent.
threadParentId string Optional. Set when this message is a thread reply.
threadParentCreatedAt string Optional. RFC 3339.
quotedParentMessage object Optional. Embedded snapshot — see below.
visibleTo string Optional. Visibility scope.
reactions object Optional. Map of emoji → Participant[].
deleted boolean Optional. true for tombstoned messages.
type string Optional. System-message type when set (e.g. "member_added"); regular messages omit it.
sysMsgData string Optional. Base64-encoded raw JSON payload for system messages.
siteId string Optional. The site that owns the message.
editedAt string Optional. RFC 3339. Set after an edit.
updatedAt string Optional. RFC 3339. Mirrors editedAt for edits, set on delete to record the deletion time.
threadRoomId string Optional. The thread room ID when this is a thread message.
pinnedAt string Optional. RFC 3339.
pinnedBy object Optional. Participant.

Participant:

Field Type Notes
id string Internal user (or app) ID.
engName string Optional.
companyName string Optional.
appId string Optional. Set for bot messages.
appName string Optional.
isBot boolean Optional.
account string Optional.

QuotedParentMessage (embedded snapshot of the quoted message at the time of quoting):

Field Type Notes
messageId string
roomId string
sender object Participant.
createdAt string RFC 3339.
msg string Optional. Body snapshot.
mentions array Optional.
attachments string[] Optional.
messageLink string Optional.
threadParentId string Optional. Set if the quoted message itself is a thread reply.
threadParentCreatedAt string Optional. RFC 3339.

Load History

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.history Reply subject: auto-generated _INBOX.> (NATS request/reply)

Request body
Field Type Required Notes
before number no Milliseconds since Unix epoch (UTC). Returns messages with createdAt < before. Omit (or null) for "now".
limit number yes Maximum number of messages to return.
{
  "before": 1746518400000,
  "limit": 50
}
Success response
Field Type Notes
messages array Most-recent first. See Message schema.
{
  "messages": [
    {
      "roomId": "01970a4f8c2d7c9aQ",
      "createdAt": "2026-05-06T07:55:00Z",
      "messageId": "01970a4f8c2d7c9aQRST",
      "sender": {
        "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
        "account": "alice",
        "engName": "Alice"
      },
      "msg": "morning team"
    }
  ]
}
Error response

See Error envelope.

{ "error": "not subscribed to room" }
Triggered events — success path

None — reply only.

Triggered events — error path

None — error returned only via the reply subject.


Load Next Messages

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.next Reply subject: auto-generated _INBOX.> (NATS request/reply)

Fetches messages newer than a cursor — the forward-pagination counterpart to Load History.

Request body
Field Type Required Notes
after number no Milliseconds since Unix epoch (UTC). Returns messages with createdAt > after. Omit for "no lower bound".
limit number yes Maximum number of messages to return.
cursor string yes Pagination cursor returned by a previous response. Use empty string for the first page.
{
  "after": 1746518400000,
  "limit": 50,
  "cursor": ""
}
Success response
Field Type Notes
messages array Oldest-first within the page. See Message schema.
nextCursor string Optional. Opaque cursor to pass to the next call. Empty when hasNext=false.
hasNext boolean true if more messages exist beyond this page.
{
  "messages": [
    {
      "roomId": "01970a4f8c2d7c9aQ",
      "createdAt": "2026-05-06T07:55:00Z",
      "messageId": "01970a4f8c2d7c9aQRST",
      "sender": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "alice" },
      "msg": "morning team"
    }
  ],
  "nextCursor": "eyJ0cyI6MTc0NjUxODQwMDAwMH0=",
  "hasNext": true
}
Error response

See Error envelope.

Triggered events — success path

None — reply only.

Triggered events — error path

None — error returned only via the reply subject.


Load Surrounding Messages

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.surrounding Reply subject: auto-generated _INBOX.> (NATS request/reply)

Fetches messages around a target message — useful for "jump to this message" navigation. Returns up to limit messages total, centered on messageId.

Request body
Field Type Required Notes
messageId string yes The central message to center the window on.
limit number yes Total number of messages to return (including the central one).
{
  "messageId": "01970a4f8c2d7c9aQRST",
  "limit": 50
}
Success response
Field Type Notes
messages array Window of messages centered on messageId, oldest-first. See Message schema.
moreBefore boolean true if more messages exist before the window.
moreAfter boolean true if more messages exist after the window.
{
  "messages": [
    {
      "roomId": "01970a4f8c2d7c9aQ",
      "createdAt": "2026-05-06T07:55:00Z",
      "messageId": "01970a4f8c2d7c9aQRST",
      "sender": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "alice" },
      "msg": "morning team"
    }
  ],
  "moreBefore": true,
  "moreAfter": false
}
Error response

See Error envelope.

Triggered events — success path

None — reply only.

Triggered events — error path

None — error returned only via the reply subject.


Get Message By ID

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.get Reply subject: auto-generated _INBOX.> (NATS request/reply)

Request body
Field Type Required Notes
messageId string yes The message to fetch.
{ "messageId": "01970a4f8c2d7c9aQRST" }
Success response

A single Message object. See Message schema.

{
  "roomId": "01970a4f8c2d7c9aQ",
  "createdAt": "2026-05-06T07:55:00Z",
  "messageId": "01970a4f8c2d7c9aQRST",
  "sender": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "alice" },
  "msg": "morning team"
}
Error response

See Error envelope.

{ "error": "message not found" }
Triggered events — success path

None — reply only.

Triggered events — error path

None — error returned only via the reply subject.


Edit Message

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.edit Reply subject: auto-generated _INBOX.> (NATS request/reply)

Only the original sender may edit a message.

Request body
Field Type Required Notes
messageId string yes The message to edit.
newMsg string yes The new content. Must be non-empty and within the size limit.
{
  "messageId": "01970a4f8c2d7c9aQRST",
  "newMsg": "morning team — updated"
}
Success response
Field Type Notes
messageId string Echoes the input.
editedAt number Milliseconds since Unix epoch (UTC).
{
  "messageId": "01970a4f8c2d7c9aQRST",
  "editedAt": 1746518700000
}
Error response

See Error envelope. Common errors: "only the sender can edit", "message not found", "newMsg must not be empty", "newMsg exceeds maximum size", "failed to edit message".

Triggered events — success path

chat.room.{roomID}.eventMessageEditedEvent. Recipients: every client subscribed to the room.

Field Type Notes
type string Always "message_edited".
timestamp number Milliseconds since Unix epoch (UTC). Event publish time.
roomId string
messageId string The edited message's ID.
newMsg string Optional. New plaintext content. Set for rooms without a key (DMs). Empty for encrypted rooms — see encryptedNewMsg.
encryptedNewMsg object Optional. The roomcrypto.EncryptedMessage JSON object for encrypted (channel) rooms. Empty for DMs.
editedBy string The sender's account.
editedAt number Milliseconds since Unix epoch (UTC). Domain time of the edit.
{
  "type": "message_edited",
  "timestamp": 1746518700123,
  "roomId": "01970a4f8c2d7c9aQ",
  "messageId": "01970a4f8c2d7c9aQRST",
  "newMsg": "morning team — updated",
  "editedBy": "alice",
  "editedAt": 1746518700000
}
Triggered events — error path

None — error returned only via the reply subject.


Delete Message

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.delete Reply subject: auto-generated _INBOX.> (NATS request/reply)

Soft-deletes a message (sets deleted=true on the row; row is preserved for audit). Only the original sender may delete. Idempotent — re-deleting an already-deleted message returns success without re-publishing the event.

Request body
Field Type Required Notes
messageId string yes The message to delete.
{ "messageId": "01970a4f8c2d7c9aQRST" }
Success response
Field Type Notes
messageId string Echoes the input.
deletedAt number Milliseconds since Unix epoch (UTC). For an already-deleted message, this is the original deletion time.
{
  "messageId": "01970a4f8c2d7c9aQRST",
  "deletedAt": 1746518800000
}
Error response

See Error envelope. Common errors: "only the sender can delete", "message not found", "failed to delete message".

Triggered events — success path

chat.room.{roomID}.eventMessageDeletedEvent. Recipients: every client subscribed to the room. Not published when the request hits an already-deleted message or loses a concurrent-delete CAS.

Field Type Notes
type string Always "message_deleted".
timestamp number Milliseconds since Unix epoch (UTC). Event publish time.
roomId string
messageId string The deleted message's ID.
deletedBy string The sender's account.
deletedAt number Milliseconds since Unix epoch (UTC). Domain time of the delete.
{
  "type": "message_deleted",
  "timestamp": 1746518800123,
  "roomId": "01970a4f8c2d7c9aQ",
  "messageId": "01970a4f8c2d7c9aQRST",
  "deletedBy": "alice",
  "deletedAt": 1746518800000
}
Triggered events — error path

None — error returned only via the reply subject.


Get Thread Messages

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.thread Reply subject: auto-generated _INBOX.> (NATS request/reply)

Returns the replies in a thread. The thread parent's messageId is supplied in the request.

Request body
Field Type Required Notes
threadMessageId string yes The top-level thread message ID. Must be a thread parent — not a reply.
cursor string no Pagination cursor returned by a previous response. Omit for the first page.
limit number yes Maximum number of replies to return.
{
  "threadMessageId": "01970a4f8c2d7c9aQRST",
  "limit": 50
}
Success response
Field Type Notes
messages array Replies in the thread, oldest-first within the page. See Message schema.
nextCursor string Optional. Opaque cursor for the next page.
hasNext boolean true if more replies exist beyond this page.
{
  "messages": [
    {
      "roomId": "01970a4f8c2d7c9aQ",
      "createdAt": "2026-05-06T08:00:00Z",
      "messageId": "01970a4f8c2d7c9aQUVW",
      "sender": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9b", "account": "bob" },
      "msg": "good morning",
      "threadParentId": "01970a4f8c2d7c9aQRST",
      "threadParentCreatedAt": "2026-05-06T07:55:00Z"
    }
  ],
  "hasNext": false
}
Error response

See Error envelope.

Triggered events — success path

None — reply only.

Triggered events — error path

None — error returned only via the reply subject.


Get Thread Parent Messages

Subject: chat.user.{account}.request.room.{roomID}.{siteID}.msg.thread.parent Reply subject: auto-generated _INBOX.> (NATS request/reply)

Lists the parent messages of threads the user has subscribed to (or all threads, depending on filter). Use this to drive a "Threads" tab in the client.

Request body
Field Type Required Notes
filter string yes One of "all", "following" (only threads the user is subscribed to), or "unread" (only threads with unread replies).
offset number yes For pagination. 0 for the first page.
limit number yes Maximum number of thread parents to return.
{
  "filter": "following",
  "offset": 0,
  "limit": 50
}
Success response
Field Type Notes
parentMessages array Thread parent messages, ordered by most-recent reply activity. See Message schema.
total number Raw count before access filtering. Use for pagination math only — parentMessages.length may be smaller.
{
  "parentMessages": [
    {
      "roomId": "01970a4f8c2d7c9aQ",
      "createdAt": "2026-05-06T07:55:00Z",
      "messageId": "01970a4f8c2d7c9aQRST",
      "sender": { "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a", "account": "alice" },
      "msg": "let's discuss the rollout",
      "tcount": 3
    }
  ],
  "total": 42
}
Error response

See Error envelope.

Triggered events — success path

None — reply only.

Triggered events — error path

None — error returned only via the reply subject.


3.3 search-service

Search Messages

Subject: chat.user.{account}.request.search.messages Reply subject: auto-generated _INBOX.> (NATS request/reply)

Full-text search across messages the requester has access to. When roomIds is omitted (or empty), the search runs across all rooms the user can see; when set, it scopes to those rooms (the service still enforces per-user access).

Request body
Field Type Required Notes
searchText string yes The query.
roomIds string[] no Optional scope. Empty means "search every room the user has access to".
size number no Page size.
offset number no Pagination offset.
{
  "searchText": "rollout plan",
  "size": 20
}
Success response
Field Type Notes
total number Total hits matching the query.
results array Page of message hits.

MessageSearchHit:

Field Type Notes
messageId string
roomId string
siteId string
userId string Sender's internal user ID.
userAccount string Sender's account.
content string The message body.
createdAt string RFC 3339.
threadParentMessageId string Optional.
threadParentMessageCreatedAt string Optional. RFC 3339.
{
  "total": 42,
  "results": [
    {
      "messageId": "01970a4f8c2d7c9aQRST",
      "roomId": "01970a4f8c2d7c9aQ",
      "siteId": "siteA",
      "userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
      "userAccount": "alice",
      "content": "let's discuss the rollout plan tomorrow",
      "createdAt": "2026-05-06T07:55:00Z"
    }
  ]
}
Error response

See Error envelope.

Triggered events — success path

None — reply only.

Triggered events — error path

None — error returned only via the reply subject.


Search Rooms

Subject: chat.user.{account}.request.search.rooms Reply subject: auto-generated _INBOX.> (NATS request/reply)

Full-text search across rooms the requester is a member of (spotlight search).

Request body
Field Type Required Notes
searchText string yes The query.
scope string no "all" (default), "channel", or "dm". The value "app" is reserved and currently rejected.
size number no Page size.
offset number no Pagination offset.
{
  "searchText": "engineering",
  "scope": "channel",
  "size": 20
}
Success response
Field Type Notes
total number Total hits.
results array Page of room hits.

RoomSearchHit:

Field Type Notes
roomId string
roomName string
roomType string The internal letter code: p (channel), d (DM), etc. — different from the request scope values.
userAccount string The requester's account (echoed).
siteId string
joinedAt string RFC 3339. When the requester joined this room.
{
  "total": 3,
  "results": [
    {
      "roomId": "01970a4f8c2d7c9aQ",
      "roomName": "engineering-announcements",
      "roomType": "p",
      "userAccount": "alice",
      "siteId": "siteA",
      "joinedAt": "2026-05-01T10:00:00Z"
    }
  ]
}
Error response

See Error envelope. Returns an error when scope=app is used.

Triggered events — success path

None — reply only.

Triggered events — error path

None — error returned only via the reply subject.


4. Message Send

Send Message

Subject: chat.user.{account}.room.{roomID}.{siteID}.msg.send Reply subject: chat.user.{account}.response.{requestID} — the client must subscribe to chat.user.{account}.> (the user wildcard) to receive it. The {requestID} value is the requestId field from the request body.

This RPC uses the publish + async-reply pattern, not the standard NATS request/reply. The client publishes to the msg.send subject (no _INBOX.> reply expected). message-gatekeeper validates the request, publishes the canonical message to MESSAGES_CANONICAL, and replies to chat.user.{account}.response.{requestID} with the persisted Message (or an error envelope on failure).

The same subject and request body cover three send variants: plain message, thread reply, and quoted message. The variant is determined by which optional fields are set.

Request body

Field Type Required Notes
id string yes The message's ID. Must be 20-char base62. The client generates this and uses it for client-side optimistic rendering.
content string yes The message body. Must be non-empty and ≤ 20 KiB.
requestId string yes A 36-char hyphenated UUIDv7 the client generates. The async reply will be published to chat.user.{account}.response.{requestId}.
threadParentMessageId string no Set when posting a thread reply. Must be a valid 20-char base62 message ID. Pair with threadParentMessageCreatedAt.
threadParentMessageCreatedAt number no Required when threadParentMessageId is set. Milliseconds since Unix epoch (UTC).
quotedParentMessageId string no Set when posting a quoted message. The gatekeeper fetches the parent and embeds a snapshot in the persisted message; the client does not send the snapshot itself.
Plain message
{
  "id": "01970a4f8c2d7c9aQRST",
  "content": "morning team",
  "requestId": "01970a4f-8c2d-7c9a-abcd-e0123456789f"
}
Thread reply
{
  "id": "01970a4f8c2d7c9aQUVW",
  "content": "good morning",
  "requestId": "01970a4f-8c2d-7c9a-abcd-e0123456789a",
  "threadParentMessageId": "01970a4f8c2d7c9aQRST",
  "threadParentMessageCreatedAt": 1746518100000
}
Quoted message
{
  "id": "01970a4f8c2d7c9aQXYZ",
  "content": "agreed — adding context",
  "requestId": "01970a4f-8c2d-7c9a-abcd-e0123456789b",
  "quotedParentMessageId": "01970a4f8c2d7c9aQRST"
}

Success response

Delivered on chat.user.{account}.response.{requestId}. The body is the persisted Message.

Field Type Notes
id string
roomId string
userId string Sender's internal user ID.
userAccount string Sender's account.
content string The message body.
mentions array Optional.
createdAt string RFC 3339.
threadParentMessageId string Optional. Set for thread replies.
threadParentMessageCreatedAt string Optional. RFC 3339.
tshow boolean Optional.
type string Optional.
sysMsgData string Optional. Base64.
quotedParentMessage object Optional. Snapshot of the quoted message. See Message schema's QuotedParentMessage table.
{
  "id": "01970a4f8c2d7c9aQRST",
  "roomId": "01970a4f8c2d7c9aQ",
  "userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
  "userAccount": "alice",
  "content": "morning team",
  "createdAt": "2026-05-06T07:55:00Z"
}

Error response

Delivered on chat.user.{account}.response.{requestId}. See Error envelope. Common errors: "invalid message ID \"…\": must be a 20-char base62 string", "content must not be empty", "content exceeds maximum size of 20480 bytes", "user alice is not subscribed to room …", "validate thread parent fields: threadParentMessageCreatedAt is required when threadParentMessageId is set".

{ "error": "content must not be empty" }

Triggered events — success path

After a successful send, two downstream services fan out events. The set you receive depends on whether the room is a channel or a DM, and whether you're a mentioned user.

1. For channel rooms — chat.room.{roomID}.event

A RoomEvent published by broadcast-worker. Recipients: every client subscribed to the room (which includes the sender, since the sender is also a member).

Field Type Notes
type string Always "new_message".
roomId string
timestamp number Milliseconds since Unix epoch (UTC). Event publish time.
roomName string
roomType string channel, dm, etc.
siteId string
userCount number
lastMsgAt string RFC 3339.
lastMsgId string The new message's ID.
mentions array Optional.
mentionAll boolean Optional. true if the message mentioned @all or @here.
hasMention boolean Optional. Per-recipient flag (DM event only). Always absent on channel events.
message object Optional. The ClientMessage (see Message schema plus a sender Participant). Set for unencrypted rooms.
encryptedMessage object Optional. Raw roomcrypto.EncryptedMessage JSON. Set for encrypted (channel) rooms. Use the room's current key to decrypt.
{
  "type": "new_message",
  "roomId": "01970a4f8c2d7c9aQ",
  "timestamp": 1746518100123,
  "roomName": "engineering-announcements",
  "roomType": "channel",
  "siteId": "siteA",
  "userCount": 12,
  "lastMsgAt": "2026-05-06T07:55:00Z",
  "lastMsgId": "01970a4f8c2d7c9aQRST",
  "encryptedMessage": {
    "v": 3,
    "ciphertext": "<base64>",
    "nonce": "<base64>"
  }
}

2. For DM rooms — chat.user.{recipient}.event.room

A RoomEvent (same struct as above) published by broadcast-worker per DM participant. Recipients: each user subscribed to the DM. The hasMention field is set per-recipient. DM events use message (plaintext); they do not use encryptedMessage.

{
  "type": "new_message",
  "roomId": "alice___bob",
  "timestamp": 1746518100123,
  "roomName": "alice, bob",
  "roomType": "dm",
  "siteId": "siteA",
  "userCount": 2,
  "lastMsgAt": "2026-05-06T07:55:00Z",
  "lastMsgId": "01970a4f8c2d7c9aQRST",
  "hasMention": false,
  "message": {
    "id": "01970a4f8c2d7c9aQRST",
    "roomId": "alice___bob",
    "userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
    "userAccount": "alice",
    "content": "morning team",
    "createdAt": "2026-05-06T07:55:00Z",
    "sender": {
      "id": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
      "account": "alice",
      "engName": "Alice"
    }
  }
}

3. chat.user.{recipient}.notification — for desktop banner notifications.

A NotificationEvent published by notification-worker. Recipients: per the worker's policy — typically the DM partner for DMs, and any @-mentioned user for channel messages.

Field Type Notes
type string "new_message".
roomId string
message object The full Message. See Message schema.
timestamp number Milliseconds since Unix epoch (UTC).
{
  "type": "new_message",
  "roomId": "alice___bob",
  "message": {
    "id": "01970a4f8c2d7c9aQRST",
    "roomId": "alice___bob",
    "userId": "01970a4f8c2d7c9a01970a4f8c2d7c9a",
    "userAccount": "alice",
    "content": "morning team",
    "createdAt": "2026-05-06T07:55:00Z"
  },
  "timestamp": 1746518100123
}

Triggered events — error path

When validation fails, the gatekeeper publishes the error envelope to chat.user.{account}.response.{requestId} and no downstream events are emitted. The client should display the error and offer a retry.

{ "error": "content must not be empty" }

5. Error envelope reference

Every error response — over NATS reply subjects and HTTP — uses the same envelope:

{ "error": "<human-readable reason>" }
Field Type Notes
error string Human-readable, sanitized at the service boundary. Do not parse or pattern-match against the text.

NATS errors are sent on the standard reply subject (_INBOX.> for §3 methods, chat.user.{account}.response.{requestID} for §4) via natsutil.ReplyError. The reply body is the JSON object above.

HTTP errors (auth-service §2.2) use the same shape with an HTTP status code in the response line.

Clients should rely on the presence/absence of the error field — and on context (HTTP status, or whether a reply parses as a success-shape) — rather than on the error text.