Real-time collaborative code editing with Monaco, Yjs CRDTs, and room-based permissions.
collab_docs provides persistent, multi-user document editing. Documents are attached to chat rooms or standalone, edited with the Monaco code editor in the browser, and synchronized in real-time using Yjs CRDTs over Socket.IO. The server maintains in-memory Yjs documents with periodic persistence to SQLite. A full permission system controls access at owner/editor/viewer/room-member levels.
collab_docs.doh.yaml
├── collab_docs_server (nodejs)
│ ├── Yjs document lifecycle (in-memory + periodic persistence)
│ ├── Socket.IO sync protocol
│ ├── Permission system (4 groups)
│ ├── REST API (CRUD + page routes)
│ └── Programmatic API (createDoc, readDocContent, etc.)
└── collab_docs_client (browser)
├── CollabDocManager (full-page doc list + editor)
├── CollabDocManagerToolbar (compact toolbar for chat rooms)
├── CollabDocManagerEditor (Monaco + Yjs binding)
└── collab_shared (transport provider)
Depends on: express_router, user_host, collab_shared (browser)
Loaded by: chat.doh.yaml
Depended on by: collab_richtext
| File | Purpose |
|---|---|
collab_docs.doh.yaml |
Package manifest |
collab_docs_server.js |
Server: Yjs lifecycle, permissions, routes, socket events (~1146 lines) |
collab_docs_client.js |
Client: Monaco editor, doc manager UI (~1859 lines) |
collab_docs.css |
Editor and manager styles |
| Constant | Value | Description |
|---|---|---|
PERSIST_DEBOUNCE_MS |
5000 (5s) |
Debounce interval for saving Yjs state to DB |
GC_TIMEOUT_MS |
300000 (5 min) |
Unload in-memory docs with no active clients |
These are compile-time constants, not pod-configurable.
| Table | Type | Purpose |
|---|---|---|
collab_docs.documents |
Idea | Document metadata |
collab_docs.doc_state |
Idea | Yjs CRDT state (base64 encoded) |
Document metadata shape:
{
id: string, // UUID
title: string,
room_id: string | null, // Chat room ID or null for standalone
owner: string, // Username
editors: string[], // Usernames with write access
viewers: string[], // Usernames with read-only access
type: string, // 'plaintext' | 'richtext' | 'surface' | 'orchestrator' | 'agent_workflow' | 'tracker'
language: string, // Monaco language ID ('markdown', 'javascript', etc.) or 'json' for graph types
created_at: number,
updated_at: number
}
Document state shape:
{
id: string, // Document ID (matches documents table)
state: string // Base64-encoded Yjs state (Y.encodeStateAsUpdate output)
}
| Context | Condition |
|---|---|
collab_doc |
ctx?.documentId exists |
| Group | Condition | Permissions | Description |
|---|---|---|---|
collab_doc_owner |
2-arg: user.username === ctx.owner |
read:collab_doc, write:collab_doc, delete:collab_doc, share:collab_doc |
Document creator/owner |
collab_doc_editor |
2-arg: ctx.editors?.includes(user.username) |
read:collab_doc, write:collab_doc |
Explicitly shared editors |
collab_doc_viewer |
2-arg: ctx.viewers?.includes(user.username) OR room member/lobby wildcard |
read:collab_doc |
Read-only viewers and room members (fallback read access) |
collab_doc_room_member |
2-arg: room membership check | read:collab_doc, write:collab_doc |
All members of the document's room |
{ documentId, owner, editors, viewers, roomMembers }Doh.permit(user, action, context) against groupsThe collab_doc_room_member group grants read and write to any authenticated user who is a member of the room to which the document is attached (doc.room_id). Room membership is resolved at permit-check time by querying chat.room_members for the room.
The condition checks ctx.roomMembers?.includes(user.username). For the lobby room, roomMembers is the special wildcard array ['*'], and the condition treats this as matching all authenticated users — so all authenticated users can read and write lobby documents.
// collab_doc_room_member condition (simplified)
condition: (user, ctx) => {
if (ctx?.roomMembers?.includes('*')) return true; // lobby wildcard
if (ctx?.roomMembers?.includes(user?.username)) return true;
return false;
}
This means:
room_id) have no room member group — only explicit editors/viewers and the owner.| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/collab/docs |
Yes | Create document |
| GET | /api/collab/docs |
Yes | List documents (optional room_id filter) |
| GET | /api/collab/docs/:id |
Yes | Get document by ID |
| PUT | /api/collab/docs/:id |
Yes | Update metadata |
| DELETE | /api/collab/docs/:id |
Yes | Delete document |
POST /api/collab/docs
// Request
{ title: string, room_id?: string, editors?: string[], viewers?: string[], type?: string, language?: string }
// Response
{ doc: { id, title, room_id, owner, editors, viewers, type, language, created_at, updated_at } }
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /docs |
Yes | Document manager page |
| GET | /collab/:id |
Yes | Fullscreen editor page |
| Event | Direction | Payload | Response |
|---|---|---|---|
collab:doc:open |
Client → Server | { doc_id } |
cb({ doc_meta, readOnly, syncStep1, syncStep2, awarenessStates, clientCount }) |
collab:doc:create |
Client → Server | { title, room_id?, language?, type?, content? } |
cb({ doc }) |
collab:doc:list |
Client → Server | { room_id? } |
cb({ docs[] }) |
collab:doc:update_meta |
Client → Server | { doc_id, updates: { title?, language?, editors?, viewers? } } |
cb({ doc }) |
collab:doc:delete |
Client → Server | { doc_id } |
cb({ success }) |
collab:doc:close |
Client → Server | { doc_id } |
-- |
collab:doc:open response detail:
{
doc_meta: { id, title, room_id, owner, editors, viewers, type, language, created_at, updated_at },
readOnly: boolean, // User has read-only access
syncStep1: number[], // Yjs state vector (Uint8Array as array)
syncStep2: number[], // Full document state
awarenessStates: number[], // Remote cursor/presence states
clientCount: number // Active editors
}
| Event | Direction | Payload | Description |
|---|---|---|---|
collab:doc:sync |
Bidirectional | { doc_id, message: number[] } |
Yjs sync protocol messages |
collab:doc:awareness |
Bidirectional | { doc_id, message: number[] } |
Cursor/presence updates |
| Event | Payload | Trigger |
|---|---|---|
collab:doc:client_count |
{ doc_id, count } |
Client joins/leaves document |
collab:doc:meta_updated |
{ doc } |
Metadata changed (also feeds CollabDocStore in chat) |
collab:doc:deleted |
{ doc_id } |
Document deleted |
| Event | Direction | Payload |
|---|---|---|
collab:docs:join_lobby |
Client → Server | -- |
collab:docs:leave_lobby |
Client → Server | -- |
For real-time document list updates on the /docs manager page.
IMPORTANT: Always use socket.emit('collab:doc:list', ...) for listing documents, not Doh.ajaxPromise(). The socket handler routes correctly to the list operation; ajaxPromise may trigger creation instead.
When a client opens a document (collab:doc:open) and the document is not already in the activeDocs map:
Y.Doc and Awareness are created in memory.collab_docs.doc_state and applies it to the Yjs document.ydoc.on('update', ...) listener is registered to trigger persistence debouncing on every edit.activeDocs as { ydoc, awareness, clients: Map, persistTimer, gcTimer }.Every Yjs update triggers schedulePersist(docId). This debounces by PERSIST_DEBOUNCE_MS (5 seconds): if an update arrives within 5 seconds of the last scheduled save, the timer resets. When the timer fires, persistDocState runs:
Y.encodeStateAsUpdate(ydoc) serializes the full document.collab_docs.doc_state via ReplaceIntoDB.updated_at timestamp in collab_docs.documents is updated.Awareness state (cursors/presence) is never persisted. It is in-memory only and cleared when the document is garbage collected.
When a client closes a document (collab:doc:close) or disconnects, the client is removed from the document's clients map and scheduleGC(docId) is called. The GC timer fires after GC_TIMEOUT_MS (5 minutes). If the document still has no clients at that point:
persistDocState is called immediately to save any unsaved edits.ydoc.destroy() releases the Yjs document from memory.activeDocs.If any new client opens the document before the GC timer fires, the timer is cancelled.
The GC cycle in summary:
client opens doc → cancel GC timer, add to clients
client closes doc → remove from clients, start 5-min GC timer
5 min passes → persist final state, destroy ydoc, remove from activeDocs
next client opens → load from DB, create new ydoc
On server startup, ensureLobbyDocExists() checks for a document with ID lobby-welcome-doc. If it does not exist, it is created automatically with:
| Field | Value |
|---|---|
id |
'lobby-welcome-doc' |
title |
'Lobby Welcome' |
room_id |
'lobby' |
owner |
'system' |
editors |
[] (empty — room membership provides access) |
viewers |
[] |
type |
'plaintext' |
language |
'markdown' |
The initial Yjs content is pre-populated with a welcome markdown message. Because the document's room_id is 'lobby', the collab_doc_room_member group applies with the wildcard ['*'] room members array, granting all authenticated users read and write access. No explicit editor assignment is needed.
The system user (owner: 'system') is not a real account and cannot authenticate. Only room membership grants access to this document.
Exported via Doh.Globals.CollabDocs for use by other modules (e.g., bot integrations):
Doh.Module('my_module', ['collab_docs_server'], function() {
const docs = Doh.Globals.CollabDocs;
// Create a document
const doc = await docs.createDoc({
title: 'Meeting Notes',
content: '# Notes\n',
room_id: 'room-123',
owner: 'alice'
});
// Read/write content
const content = docs.readDocContent(doc.id);
const previous = docs.writeDocContent(doc.id, '# Updated\n');
// Undo/redo
docs.undoDocEdit(doc.id, 'system');
docs.redoDocEdit(doc.id, 'system');
// Query
const roomDocs = docs.listRoomDocs('room-123');
const single = docs.getDocById(doc.id);
const canEdit = docs.canAccess(user, doc, 'write');
});
| Function | Params | Returns | Description |
|---|---|---|---|
activeDocs |
-- | Map<string, entry> |
Live in-memory document registry (read-only access) |
getOrCreateActiveDoc(docId) |
string | { ydoc, awareness, ... } |
Get or load Yjs doc into memory (for direct Yjs manipulation) |
getDocById(docId) |
string | doc | null |
Fetch document metadata |
canAccess(user, doc, action) |
user, doc, string | boolean | Permission check |
createDoc(opts) |
{ title, content?, room_id?, owner, type?, language? } |
doc | Create document with optional content |
readDocContent(docId) |
string | string | null |
Text content, tracker JSON array, or graph JSON { nodes, edges } |
writeDocContent(docId, content, origin?) |
string, string, any | string | Replace content, returns previous. Graph types accept { nodes, edges } JSON |
undoDocEdit(docId, origin) |
string, any | boolean | Undo last edit |
redoDocEdit(docId, origin) |
string, any | boolean | Redo last undo |
listRoomDocs(roomId) |
string | doc[] |
All docs attached to a room |
| Type | Yjs Structure | readDocContent returns | Editor |
|---|---|---|---|
plaintext |
Y.Text('content') |
string | Monaco |
richtext |
Y.Text('content') |
string | TinyMCE |
tracker |
Y.Map('nodes') |
JSON array of nodes | Tracker UI |
surface |
Y.Array('items') + Y.Array('edges') + Y.Map('meta') |
JSON { nodes, edges } |
Surface canvas |
orchestrator |
Y.Array('nodes') + Y.Array('edges') + Y.Map('meta') |
JSON { nodes, edges } |
Orchestrator canvas |
agent_workflow |
Y.Array('nodes') + Y.Array('edges') + Y.Map('meta') |
JSON { nodes, edges } |
Agent builder canvas |
Graph types (surface, orchestrator, agent_workflow) share the same Yjs layout — arrays of node/item Y.Maps and edge Y.Maps plus a metadata map. The items key differs: surfaces use 'items', orchestrator and agent_workflow use 'nodes'.
The collab_docs bot tool group includes a graph_edit tool for graph document types. Instead of freehand content replacement (which edit_document provides for text types), graph documents are edited via discrete operations applied in a single Yjs transaction:
add_node — type, name, config (dimensions and position are automatic)update_node — node_id, name, partial config updateremove_node — node_id (also removes connected edges)add_edge — source_node_id, source_port, target_node_id, target_port (for workflow nodes); source_anchor, target_anchor (for canvas widgets)remove_edge — edge_idNode naming — Every node should be given a descriptive name (e.g. "Fetch User Data", "Validate Response"). Names appear in node headers and distinguish nodes of the same type.
Canvas widgets — In addition to workflow node types (orch_*, agent_*), graph_edit supports base Surface widget types: sticky_note, text_label, shape, frame, media, collab_embed, url_browser. These use anchor-based connections (top/right/bottom/left) instead of ports. Text fields (text, title) are automatically converted to Yjs collaborative types (Y.XmlFragment / Y.Text) on the server.
Auto-layout — After every graph_edit batch, the server runs a layout engine (graph_layout.js in collab_shared) that positions nodes using topological layering with actual type-specific dimensions. Large fan-out layers split into sub-columns. Agents do not need to calculate positions.
Operations can reference nodes created in the same batch using $N syntax (e.g., $0 for the first add_node result). All operations execute atomically — connected clients see the changes in real-time via Yjs sync.
| Pattern | Parent | Purpose |
|---|---|---|
CollabDocManager |
html |
Full-page document list with create/delete/open |
CollabDocManagerToolbar |
html |
Compact toolbar with doc tiles (used in chat room headers) |
CollabDocManagerEditor |
html |
Monaco editor with Yjs binding, language selector, title editing |
CollabDocEditor |
html |
Standalone editor wrapper |
CollabDocToolbar |
html |
Editor toolbar with title, language, share, delete, fullscreen |
CollabDocStatusBar |
html |
Sync state indicator, collaborator count, connection status |
CollabDocList |
html |
Document listing with type icons and active editor count |
CollabDocTile |
html |
Individual document tile in toolbar |
When a user enters a room:
chat_client.js calls docToolbar.loadDocs()collab:doc:list with room_id/docs — verify document list loads/collab/<doc-id> — verify fullscreen editor loadslobby-welcome-doc without being explicitly listed as editor