Skip to main content

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.

RoleDescriptionDefault Permissions
lead_underwriterPrimary underwriter driving the dealEdit, annotate, chat
underwriterSupporting underwriterEdit, annotate, chat
brokerExternal broker with controlled accessAnnotate, chat
actuaryActuarial reviewerAnnotate, chat
managerSupervisory oversightAnnotate, chat
viewerRead-only observerView 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

  1. Client requests a signed WebSocket URL via GET /v1/deal-rooms/:id/ws.
  2. Client opens a WebSocket connection to the returned wsUrl.
  3. The Durable Object verifies the JWT token (checks signature, expiration, and DO ID match).
  4. On successful auth, the server sends an init message with room state, participants, last 50 messages, and all annotations.
  5. 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.

TypeDirectionDescription
initServer to clientRoom state, participants, recent messages, annotations
chatBidirectionalText message, file share, or annotation-linked comment
typingClient to serverTyping indicator (broadcast to others)
annotationClient to serverCreate a document or field annotation
awarenessClient to serverCursor position and selection state
reactionClient to serverEmoji 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 list
  • chat — new message with sender info and timestamp
  • typing — typing indicator from another user
  • annotation_created / annotation_updated — annotation lifecycle events
  • awareness — cursor and selection state from other users
  • reaction — 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):

MethodPathDescription
GET/stateRoom state summary
GET/messagesAll in-memory messages
POST/messagesPost a message (no WebSocket)
GET/annotationsAll annotations
POST/annotationsCreate an annotation
PATCH/annotations/:idUpdate 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.