Deal Rooms
Deal Rooms provide real-time collaboration for underwriting teams working on a submission. Each room is backed by a Cloudflare Durable Object that manages WebSocket connections, chat history, document annotations, and a shared CRDT notepad. The REST API handles room lifecycle and participant management, while the Durable Object handles everything that needs to happen in real time.
Room Lifecycle
Submission Received
│
▼
┌───────────────┐
│ active │ ◄── Created by underwriter, participants invited
└───────┬───────┘
│
┌────┴────┐
│ │
▼ ▼
┌────────┐ ┌────────┐
│archived│ │ closed │ ◄── Deal bound or declined
└────────┘ └────────┘
A deal room is always tied to a single submission. When a room is created, a Durable Object is instantiated using the submission ID as its name, ensuring one DO per submission. The creating user is automatically added as lead_underwriter with full permissions.
Participant Roles
Every participant in a deal room has a role that controls their capabilities within the room.
| Role | Description | Default Permissions |
|---|---|---|
lead_underwriter | Primary underwriter driving the deal | Edit, annotate, chat |
underwriter | Supporting underwriter | Edit, annotate, chat |
broker | External broker with controlled access | Annotate, chat |
actuary | Actuarial reviewer | Annotate, chat |
manager | Supervisory oversight | Annotate, chat |
viewer | Read-only observer | View only |
Permissions are granular per participant: canEdit, canAnnotate, and canChat can be toggled independently of role. External participants (brokers) can optionally be associated with an externalOrgId and externalOrgName.
API Endpoints
All endpoints are prefixed with /v1/deal-rooms and require authentication. Read access requires one of: underwriter, org_admin, superadmin, admin, producer, auditor. Write access requires: underwriter, org_admin, superadmin, admin.
Create a Deal Room
POST /v1/deal-rooms
Authorization: Bearer <token>
Content-Type: application/json
{
"orgId": "550e8400-e29b-41d4-a716-446655440000",
"submissionId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"title": "Acme Trucking GL Renewal",
"description": "Q2 2026 renewal, fleet expanded to 45 units",
"allowBrokerAccess": true
}
Response (201):
{
"data": {
"id": "a1b2c3d4-...",
"orgId": "550e8400-...",
"submissionId": "7c9e6679-...",
"durableObjectId": "do_abc123...",
"status": "active",
"title": "Acme Trucking GL Renewal",
"allowBrokerAccess": true,
"createdBy": "usr_01J8...",
"createdAt": "2026-03-24T14:00:00Z"
}
}
The system validates that the submission exists within the caller's organization before creating the room. Cross-organization access is denied unless the caller holds a superadmin or system role.
List Deal Rooms
GET /v1/deal-rooms?status=active
Returns all rooms for the caller's organization, ordered by most recent activity. Each result includes the joined submission's insuredName and lob for display. The status query parameter defaults to active and accepts active, archived, or closed.
Get Deal Room Details
GET /v1/deal-rooms/:id
Returns the room with its full participant list. Participants who have left (status left) are excluded. Each participant entry includes their name, email, role, permissions, notification preferences, and last-seen timestamp.
Add a Participant
POST /v1/deal-rooms/:id/participants
Content-Type: application/json
{
"userId": "usr_01J8...",
"role": "actuary",
"canEdit": false,
"canAnnotate": true,
"canChat": true
}
If the user was previously added and removed, their record is updated rather than duplicated (upsert on the room + user combination).
Update a Deal Room
PATCH /v1/deal-rooms/:id
Content-Type: application/json
{
"status": "closed",
"description": "Bound at $1.2M premium"
}
When status is set to closed, the system records closedAt and closedBy automatically.
WebSocket Collaboration
Real-time features are powered by a Durable Object (DealRoomDO) that each room's participants connect to via WebSocket.
Obtaining a WebSocket URL
GET /v1/deal-rooms/:id/ws
Returns a signed WebSocket URL with a short-lived JWT token (5-minute expiry). The token encodes the user's identity, organization, role, and the target Durable Object ID. The client connects using this URL directly.
{
"data": {
"wsUrl": "wss://api.openinsure.dev/deal-room/do_abc123?token=eyJ...",
"durableObjectId": "do_abc123",
"submissionId": "7c9e6679-..."
}
}
Connection Flow
- Client requests a signed WebSocket URL via
GET /v1/deal-rooms/:id/ws. - Client opens a WebSocket connection to the returned
wsUrl. - The Durable Object verifies the JWT token (checks signature, expiration, and DO ID match).
- On successful auth, the server sends an
initmessage with room state, participants, last 50 messages, and all annotations. - If a Yjs CRDT state exists (shared notepad), it is sent as a binary frame immediately after init.
Message Types
Clients send JSON messages over the WebSocket. Each message has a type discriminator.
| Type | Direction | Description |
|---|---|---|
init | Server to client | Room state, participants, recent messages, annotations |
chat | Bidirectional | Text message, file share, or annotation-linked comment |
typing | Client to server | Typing indicator (broadcast to others) |
annotation | Client to server | Create a document or field annotation |
awareness | Client to server | Cursor position and selection state |
reaction | Client to server | Emoji reaction on a message |
Sending a Chat Message
{
"type": "chat",
"content": "The loss runs look clean. Ready to quote.",
"richContent": {
"mentions": ["usr_01J8..."]
}
}
Chat messages support threaded replies via parentMessageId, file attachments via richContent.attachments, and annotation targeting via annotationTarget (to link a message to a specific document field or coordinate).
Creating an Annotation
{
"type": "annotation",
"targetType": "document",
"targetId": "doc_loss_runs_2025",
"position": { "x": 120, "y": 340, "width": 200, "height": 50 },
"content": "Prior carrier shows $80k in open reserves — verify"
}
Annotation target types: field, document, image, section. Annotations have a status lifecycle: open -> resolved or dismissed.
Typing Indicator
{
"type": "typing",
"isTyping": true
}
Server-Sent Events
The Durable Object broadcasts events to all connected clients (excluding the sender where appropriate):
participant_joined/participant_left— presence updates with full participant listchat— new message with sender info and timestamptyping— typing indicator from another userannotation_created/annotation_updated— annotation lifecycle eventsawareness— cursor and selection state from other usersreaction— emoji reaction added to a message
Shared Notepad (Yjs CRDT)
Binary WebSocket frames are treated as Yjs CRDT updates, enabling a collaborative notepad that supports concurrent editing without conflicts. The Durable Object relays binary frames to all other connected clients and persists the CRDT state to storage every 10 updates.
Message Persistence
The Durable Object stores the last 1,000 messages in memory and persists every message to PlanetScale asynchronously via ctx.waitUntil. Messages older than the in-memory buffer are available through the REST API.
Retrieve Chat History
GET /v1/deal-rooms/:id/messages?limit=50&offset=0
Returns messages in reverse chronological order with sender names joined from the users table. Pagination is controlled with limit (max 200) and offset.
Retrieve Annotations
GET /v1/deal-rooms/:id/annotations
Returns all annotations for the room, each with its target, position, status, creator, and resolution details if applicable.
REST API on the Durable Object
The Durable Object also exposes REST endpoints for server-to-server integration (used by the AI agents and background workers):
| Method | Path | Description |
|---|---|---|
| GET | /state | Room state summary |
| GET | /messages | All in-memory messages |
| POST | /messages | Post a message (no WebSocket) |
| GET | /annotations | All annotations |
| POST | /annotations | Create an annotation |
| PATCH | /annotations/:id | Update annotation status/content |
Organization Scoping
All deal room queries are scoped to the caller's organization. The orgId is enforced at the database query level, so a user in one organization cannot access rooms belonging to another. Cross-org roles (superadmin, system, service) bypass this scoping.