OpsWarden WebSocket protocol¶
This document is the canonical contract between the Rust server, the web client, and the desktop client. It describes the protocol implemented on 24 July 2026.
Conventions¶
- Endpoint:
GET /ws, upgraded to WebSocket. - Frames are UTF-8 JSON objects with a mandatory string field named
type. - Every identifier is a UUID encoded as a string.
- Every date is a Unix timestamp in seconds.
nullmeans no expiry. - Unknown or malformed commands received after authentication are ignored.
- The server does not retain or replay WebSocket events.
Connection lifecycle¶
The first text frame must be:
The server verifies the token and its revocation status. A missing, malformed, invalid, or revoked token closes the connection. Ping, pong, and binary frames may precede authentication; any other text frame fails authentication.
After authentication, the connection is scoped to the user's current teams. One user may have several simultaneous connections. Each connection has its own incident watches, while presence lists contain distinct user IDs.
The client reconnects automatically. On each successful reopen it must:
- authenticate before sending another command;
- invalidate/refetch its active REST projections;
- replay every active
watchcommand.
This REST resynchronization is mandatory because events missed while disconnected are not replayed.
Client commands¶
watch¶
Starts watching an incident and contributes the authenticated user to its
presence roster. The server accepts the command only when the incident exists
and belongs to one of the connection's current teams. It then broadcasts
presence_update to all connections watching that incident.
unwatch¶
Stops this connection from watching the incident. Removing one's own watch is
always safe and requires no resource lookup. When the roster changes, the
server broadcasts presence_update to the remaining watchers.
status_typing¶
Emits user_typing only when the incident belongs to a current team and the
user's role has the can_signal_typing capability. The server stores no typing
state; consumers expire the signal locally.
refresh_teams¶
Reloads the authenticated user's memberships from persistent storage and replaces the connection's routing and authorization scope. The client sends it after a create, join, leave, kick, ban, or delete operation that can make the cached scope stale.
Delivery scopes¶
- Team: every live connection whose cached membership contains
team_id. - Incident watchers: every connection currently watching
resource_id. - Users: every live connection owned by one of the listed users.
Team membership and role authorization are enforced before the business action that creates an event. Presence and typing commands are authorized again in the WebSocket handler. Private messages are never broadcast to a team.
Server events¶
The payloads below are exact. Domain-only routing fields such as team_id are
not added to a frame unless shown.
Incident and timeline events¶
| Event | Exact payload fields | Emission condition | Delivery |
|---|---|---|---|
incident_created |
incident_id, severity |
An Incident is persisted, including Automation-created Incidents. | Team |
incident_state_changed |
incident_id, new_state, by |
An authorized transition changes the incident status. | Team |
incident_escalated |
incident_id, new_severity, by |
An authorized action raises severity. | Team |
incident_assigned |
incident_id, assigned_to, by |
An incident is assigned. | Team |
timeline_entry_added |
incident_id, entry: { entry_id, content, author, at } |
A timeline entry is persisted. | Team |
timeline_entry_edited |
incident_id, entry_id, new_content, edited_at |
An authorized edit is persisted. | Team |
reaction_added |
incident_id, entry_id, emoji, by |
A reaction is added to an entry. | Team |
reaction_removed |
incident_id, entry_id, emoji, by |
The caller's existing reaction is removed. | Team |
user_typing |
incident_id, user_id |
An authorized status_typing command is accepted. |
Team |
Example:
{
"type": "timeline_entry_edited",
"incident_id": "8e30dcad-f825-4670-b352-9347f8eedd11",
"entry_id": "309667af-4484-48bc-9f9f-baa81d6868e3",
"new_content": "Database failover completed",
"edited_at": 1784901600
}
Presence events¶
presence_update is resource-generic. Phase 1 currently emits it for incidents:
{
"type": "presence_update",
"resource_id": "8e30dcad-f825-4670-b352-9347f8eedd11",
"resource_type": "incident",
"watchers": ["48173ed2-d2d9-4ef6-89ca-5ec7af5c2895", "676366a3-3325-4367-8450-1620c081b4aa"]
}
It is emitted after a successful watch, unwatch, or watched connection close, and delivered only to the incident's remaining watchers.
team_presence_update is an OpsWarden extension:
{
"type": "team_presence_update",
"team_id": "40b07f0c-65a6-48bd-af73-a7e81a94c275",
"online_user_ids": ["48173ed2-d2d9-4ef6-89ca-5ec7af5c2895"]
}
It is emitted when a connection registers, unregisters, or refreshes its team scope, and is delivered only to that team's live connections.
Moderation events¶
{
"type": "member_kicked",
"team_id": "40b07f0c-65a6-48bd-af73-a7e81a94c275",
"member": "48173ed2-d2d9-4ef6-89ca-5ec7af5c2895",
"by": "676366a3-3325-4367-8450-1620c081b4aa"
}
member_kicked is emitted after an authorized kick removes the membership and
clears the member's incident assignments.
{
"type": "member_banned",
"team_id": "40b07f0c-65a6-48bd-af73-a7e81a94c275",
"member": "48173ed2-d2d9-4ef6-89ca-5ec7af5c2895",
"until": null,
"by": "676366a3-3325-4367-8450-1620c081b4aa"
}
member_banned is emitted whenever an authorized temporary or permanent ban is
persisted, including a pre-emptive ban of a non-member. until contains the
expiry timestamp for a temporary ban and is null for a permanent ban. Both
events use Team delivery. A just-removed member remains in the live
connection's cached team scope long enough to receive the event and refresh it.
Private messages¶
{
"type": "private_message_received",
"from": "48173ed2-d2d9-4ef6-89ca-5ec7af5c2895",
"to": "676366a3-3325-4367-8450-1620c081b4aa",
"content": "Can you review the mitigation?",
"at": 1784901600
}
The event is emitted after a message between two distinct users sharing at least one team is validated and persisted. It is delivered through the Users scope to exactly the sender and recipient, including all of their live connections. Co-members and other team connections receive nothing.
Release events¶
| Event | Exact payload fields | Emission condition | Delivery |
|---|---|---|---|
release_step_validated |
release_id, step, by |
An authorized release step is validated. | Team |
release_state_changed |
release_id, new_state |
The effective release state changes, including incident-driven block/unblock transitions. | Team |
Automation events¶
These payloads describe the currently implemented Phase 2 contract. They will change only through a matching update to this document, Rust serialization, TypeScript types, and tests.
| Event | Exact payload fields | Emission condition | Delivery |
|---|---|---|---|
rule_triggered |
service, rule_name, result, incident_id (null when no incident was created) |
A matching automation rule completes successfully. | Team |
rule_failed |
service, rule_name, error |
A rule matches but its reaction fails. | Team |
rule_triggered.result is incident_created when the reaction creates an
Incident and reaction_completed for a successful side-effect reaction.
rule_failed.error is the stable public domain error code also persisted on the
Automation Run; internal error text is not sent over WebSocket.
Contract change policy¶
Any protocol change must update, in the same change:
- this document;
server/src/adapters/ws/protocol.rsand its exact-shape tests;client-web/lib/ws.ts;- routing tests when a delivery scope changes;
- consumer/cache tests when client behavior changes.