---
title: "Collab SDK"
description: "Real-time collaboration with operations, cursor tracking, and idle awareness for editors and canvases."
---

# @nolag/collab

Real-time collaboration with operations, cursor tracking, and idle awareness.

## Overview

Add real-time collaboration to any editor or canvas. Operations (insert, delete, replace, format, or custom domain types) are broadcast to all document participants and cached in memory on each client. Cursor positions are synchronised via a throttled ephemeral channel so other participants see where each user is working without generating durable storage traffic. Each client infers idleness for the other participants locally: a remote user who has sent no cursor update for `idleTimeout` is reported as `idle` on this client, without anything being broadcast. Each joined document is an independent collaboration session; join multiple documents for multi-tab or split-pane editors. Your app owns one core NoLag client and injects it into `NoLagCollab`; the wrapper attaches its behaviour to that connection.

### Key Features

- Five built-in operation types: insert, delete, replace, format, and custom
- In-memory operation cache per document (`maxOperationCache`, default 1000)
- Throttled ephemeral cursor sync with configurable interval
- Locally inferred idle detection for remote users after a configurable timeout
- Explicit status (`active`, `idle`, `viewing`) carried in room presence
- User online/offline presence via lobby
- Per-document user join/leave events
- Multiple documents per connection

## How It Works

`NoLagCollab` attaches to an injected `@nolag/js-sdk` client and maintains a lobby for user presence. Calling `joinDocument(name)` returns a `CollabDocument` that subscribes to two topics: `operations` for document operations and `_cursors` for cursor position updates. Publishers never receive their own messages: an operation you send is added to your local cache and returned to you, and `operation` fires only for other users' operations.

Outbound cursor updates are throttled to `cursorThrottle` (default 50 ms): the first call in a window is sent at once, later calls in the same window are coalesced and the last one is sent when the window ends. For every remote user, the document starts an idle timer when they join and resets it on each of their cursor updates; when it elapses the document emits `awarenessChanged` with `status: 'idle'` locally. Nothing is broadcast by that timer, and your own status never changes on its own. `setStatus()` is the only way to change the local user's status; it is sent through room presence, and other clients read it from `getUser()` and `getUsers()`. The app owns the socket lifecycle; the wrapper never opens or closes it.

| Topic | Purpose |
| --- | --- |
| `operations` | Document operations: insert, delete, replace, format, custom |
| `_cursors` | Cursor positions, not persisted |

A fresh subscribe or an ordinary reconnect never replays history, and this SDK never triggers replay; see [Replay](/docs/concepts/replay) for the one case where the broker replays messages. `getOperations()` returns only what this client has seen since it joined.

**Before you start.** Create an app from the `nolag-collab-sdk` blueprint. That seeds the rooms this SDK expects (`my-doc`) and the `online` lobby, and returns an app slug with a random suffix. That slug is the `appName` you pass to the wrapper. You can also do this in the portal: Apps, New App, pick the blueprint.

```typescript [Setup (server side, once)]
import { NoLagApi } from "@nolag/js-sdk";

const api = new NoLagApi(process.env.NOLAG_API_KEY); // nlg_live_...
const app = await api.apps.create({ name: "My App", blueprintId: "nolag-collab-sdk" });
console.log(app.slug); // e.g. "my-app-a3f9": this is your appName

const actor = await api.actors.create({ name: "web-client", actorType: "user" });
console.log(actor.accessToken); // shown once; keep it
```

## Installation

```bash [Terminal]
npm install @nolag/collab @nolag/js-sdk
```

**Shared connection.** One core NoLag client can back several wrapper SDKs at once, for example collaboration, chat, and notify on a single socket, as long as each wrapper uses a distinct `appName`. Each wrapper attaches its handlers on construction and releases them with `detach()`, and never touches the socket itself. Your app owns `connect()` and `disconnect()`.

## Quick Start

```typescript [TypeScript]
import { NoLag } from '@nolag/js-sdk'
import { NoLagCollab } from '@nolag/collab'

// The app owns one core client. In a browser, pass a token provider so the
// SDK can mint fresh short-lived client tokens from your backend.
const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token)

// Inject the client into the collab wrapper
const collab = new NoLagCollab({
  client,
  appName: APP_SLUG,     // the slug returned when you created the app
  username: 'Alice',
  idleTimeout: 30_000,   // report a remote user idle after 30 s without cursor activity
  cursorThrottle: 50,    // minimum ms between cursor broadcasts (default 50)
})

await client.connect()   // the app owns the connection
await collab.ready()     // wrapper setup complete

// Join the seeded document (each document is a separate collaboration session)
const doc = collab.joinDocument('my-doc')

// Operations

// React to operations from other users (your own are not echoed back)
doc.on('operation', (op) => {
  console.log(`${op.username} sent ${op.type} at ${op.position}`, op.content ?? op.data)
})

// insert, delete and replace use position, length and content
doc.sendOperation('insert', { position: 42, content: 'Hello, world!' })
doc.sendOperation('delete', { position: 42, length: 13 })
doc.sendOperation('replace', { position: 0, length: 5, content: 'Hi' })

// format and custom carry their payload in `data`
doc.sendOperation('format', { position: 10, length: 5, data: { bold: true } })
doc.sendOperation('custom', {
  data: { kind: 'highlight', color: '#ffcc00', range: { start: 0, end: 20 } },
})

// Operations this client has seen since joining (in-memory, synchronous)
const ops = doc.getOperations()
console.log(`${ops.length} operations cached`)

// Cursors

// Broadcast the local cursor (throttled). Use line/column for text editors,
// x/y for canvases, selection for ranges, path for multi-file documents.
doc.updateCursor({ line: 3, column: 8, selection: { start: 40, end: 55 } })

// Remote cursors from the local cache
const cursors = doc.getCursors()
console.log('Remote cursors:', cursors.length)

doc.on('cursorMoved', (cursor) => {
  renderCursor(cursor.userId, { line: cursor.line, column: cursor.column, color: cursor.color })
})

// Awareness

// Set your own status; it travels in room presence
doc.setStatus('viewing')  // 'active' | 'idle' | 'viewing'

// Fires when a remote user's idle timer elapses on this client
doc.on('awarenessChanged', ({ userId, status }) => {
  console.log(`User ${userId} is now ${status}`)
})

// A remote user's explicit status is read from the user record
console.log(doc.getUsers().map((u) => `${u.username}: ${u.status}`))

// Presence

collab.on('userOnline', (user) => console.log('Joined lobby:', user.username))
collab.on('userOffline', (user) => console.log('Left lobby:', user.username))

doc.on('userJoined', (user) => console.log('Joined document:', user.username))
doc.on('userLeft', (user) => console.log('Left document:', user.username))

// Teardown

// The wrapper releases its handlers; the app closes the socket.
collab.detach()
client.disconnect()
```

## API Reference

### NoLagCollab

#### Constructor Options

| Option | Type | Description |
| --- | --- | --- |
| `client` | `NoLagSocket` | **Required.** The injected core NoLag client the app owns and connects. |
| `username` | `string` | **Required.** Display name for the local user. |
| `appName` | `string` | The app slug used as the topic prefix. Pass the suffixed slug returned when you created the app; the default `'collab'` will not match a hosted app. |
| `avatar` | `string` | Optional avatar URL. |
| `color` | `string` | Optional cursor/highlight colour, sent with every cursor update. |
| `metadata` | `Record<string, unknown>` | Optional custom data attached to user presence. |
| `documents` | `string[]` | Documents to auto-join once the wrapper is ready. |
| `maxOperationCache` | `number` | Max operations cached per document (default `1000`). |
| `idleTimeout` | `number` | Ms without a cursor update before a remote user is reported idle on this client (default `60000`). |
| `cursorThrottle` | `number` | Minimum ms between cursor broadcasts (default `50`). |
| `debug` | `boolean` | Enable wrapper debug logging (default `false`). |

| Method | Returns | Description |
| --- | --- | --- |
| `ready()` | `Promise<void>` | Resolves once wrapper setup completed. Join methods throw before this resolves. |
| `detach()` | `void` | Release this wrapper's handlers and topics; terminal, never closes the socket. |
| `joinDocument(name, opts?)` | `CollabDocument` | Subscribe to a document and return it. Synchronous; returns the existing instance if already joined. `opts.filters` limits the `operations` subscription to those filter values. |
| `leaveDocument(name)` | `void` | Unsubscribe from a document and release its resources. |
| `getDocuments()` | `CollabDocument[]` | All currently joined documents. |
| `getOnlineUsers()` | `CollabUser[]` | Users currently present in the `online` lobby. |

### NoLagCollab Events

| Event | Payload | Description |
| --- | --- | --- |
| `connected` | none | Wrapper setup completed on a live connection. |
| `disconnected` | `reason: string` | Connection closed. |
| `reconnecting` | none | The core client is attempting to reconnect. |
| `reconnected` | none | Connection restored; document membership and presence are restored automatically. |
| `error` | `error: Error` | A transport or protocol error occurred. |
| `userOnline` | `user: CollabUser` | A user joined the lobby. |
| `userOffline` | `user: CollabUser` | A user left the lobby. |

### CollabDocument

| Method | Returns | Description |
| --- | --- | --- |
| `sendOperation(type, opts?)` | `CollabOperation` | Broadcast an operation and return it. `type` is `'insert' \| 'delete' \| 'replace' \| 'format' \| 'custom'`; `opts` is `{ path?, position?, length?, content?, data?, filter?, filters? }`. Anything else belongs in `data`. |
| `getOperations()` | `CollabOperation[]` | Operations cached on this client since it joined, in timestamp order (up to `maxOperationCache`). |
| `updateCursor(opts)` | `void` | Broadcast the local cursor. `opts` is `{ x?, y?, line?, column?, selection?, path? }`. Throttled to `cursorThrottle`. |
| `getCursors()` | `CursorPosition[]` | Remote cursor positions from the local cache. |
| `setStatus(status)` | `void` | Set the local user's status: `'active' \| 'idle' \| 'viewing'`. Sent through room presence. |
| `getUsers()` | `CollabUser[]` | Remote users present in this document, with their current `status`. |
| `getUser(userId)` | `CollabUser \| undefined` | One remote user by ID. |
| `setFilters(values)` / `addFilters(values)` / `removeFilters(values)` | `void` | Change which operation filter values this document receives. An empty set restores the wildcard subscription. |

### CollabDocument Events

| Event | Payload | Description |
| --- | --- | --- |
| `operation` | `CollabOperation` | Another user broadcast an operation. |
| `cursorMoved` | `CursorPosition` | Another user's cursor moved. Delivered via the ephemeral channel. |
| `userJoined` | `user: CollabUser` | A user joined this document. |
| `userLeft` | `user: CollabUser` | A user left this document. |
| `awarenessChanged` | `{ userId, status: 'idle' }` | A remote user's idle timer elapsed on this client. Explicit `setStatus()` changes by other users do not fire this event; read them from `getUser()`. |

### Types

| Type | Shape |
| --- | --- |
| `CollabOperation` | `{ id, type, path?, position?, length?, content?, data?, userId, username, timestamp, isReplay }` |
| `CursorPosition` | `{ userId, username, color?, x?, y?, line?, column?, selection?: { start, end }, path?, timestamp }` |
| `CollabUser` | `{ userId, actorTokenId, username, avatar?, color?, status, metadata?, joinedAt, isLocal }` |
| `UserStatus` | `'active' \| 'idle' \| 'viewing'` |
