# Real-time reference

Presence, Yjs, canvas, voice agents, cron, jobs, and cursor color helpers.

The real-time hooks beyond records and messaging - presence, collaborative editing, canvas, voice agents, cron, jobs, and helpers.

```ts
import {
  // Yjs
  useYjsText, useYjsField, useYjsRoom,
  // Canvas
  useCanvas,
  // Presence
  usePresence, usePresenceRoom,
  // Cron monitor
  useCronMonitor,
  // Background jobs
  useJobs,
  // User colors
  DEFAULT_USER_COLORS, getUserColor,
  // Low-level sync primitives
  createEncoder, createDecoder, encodeSyncStep1, encodeSyncStep2, encodeUpdate,
  handleSyncMessage, Awareness, encodeAwarenessMessage, handleAwarenessMessage,
} from 'deepspace'
```

## Yjs hooks

### `useYjsText(collection, recordId, fieldName)`

Collaborative plain text bound to a record field.

```ts
function useYjsText(
  collection: string,
  recordId: string,
  fieldName: string,
): {
  text: string
  setText: (value: string) => void
  synced: boolean
  canWrite: boolean
}
```

### `useYjsField(collection, recordId, fieldName)`

Lower-level Yjs binding. Returns the raw `Y.Doc` and `Awareness` so you can build any Yjs type (`Y.Map`, `Y.Array`, `Y.XmlFragment`, etc.) on top of it. Not generic - the hook doesn't model the field shape.

```ts
function useYjsField(
  collection: string,
  recordId: string,
  fieldName: string,
): {
  doc: Y.Doc
  awareness: Awareness
  synced: boolean
  canWrite: boolean
  /** Increments on every local or remote Y.Doc update - useful as a render trigger. */
  updateCount: number
}
```

Build whichever Yjs type you need off `doc`:

```tsx
const { doc, synced } = useYjsField('documents', docId, 'tasks')
const list = useMemo(() => doc.getArray<Task>('tasks'), [doc])
```

### `useYjsRoom(docId, fieldName)`

Standalone Yjs document not tied to a record. Useful for ephemeral collaboration sessions. Opens a direct WebSocket to a dedicated `YjsRoom` DO at `/ws/yjs/:docId`.

```ts
function useYjsRoom(docId: string, fieldName: string): {
  doc: Y.Doc
  awareness: Awareness
  text: string
  setText: (value: string) => void
  synced: boolean
  canWrite: boolean
}
```

`text` / `setText` are bound to the `Y.Text` at `fieldName`. Use `doc` directly if you need a different type.

`awareness` is a [y-protocols `Awareness`](https://github.com/yjs/y-protocols) instance pre-wired to the same WebSocket. Calls to `awareness.setLocalState(...)` or `awareness.setLocalStateField('cursor' | 'selection' | 'user' | …, value)` fire an `MSG_AWARENESS` frame to peers; remote states arrive on the awareness `'change'` / `'update'` events and are visible via `awareness.getStates()`. Pass it to an editor binding (e.g. `@tiptap/extension-collaboration-cursor`) or wire your own cursor/selection UI - see the [collaborative editing guide](/guides/collaborative-editing#cursors-and-selections) for a worked example.

The `/ws/yjs/:docId` route is token-required and docs-aware: 401 without a verified JWT, 403 without read access. With the docs feature installed, roles resolve from `documents.ownerId` / `editors` / `collaborators`; without it, any authenticated caller is treated as `member`. See the [security model](/concepts/architecture#security-model-websocket-identity) for the full path.

For the raw protocol constants (`MSG_AWARENESS`, `encodeAwarenessMessage`, `handleAwarenessMessage`) used by this hook internally, see [Low-level sync primitives](#low-level-sync-primitives) below.

## `useCanvas(roomId)`

Connects to a `CanvasRoom` DO.

```ts
function useCanvas(roomId: string): {
  shapes: CanvasShapeClient[]
  /** All connected users' viewports (including self). */
  viewports: ViewportClient[]
  connected: boolean
  /** RBAC gate. False until the server AUTH frame lands; stays false for viewers. */
  canWrite: boolean
  addShape:    (shape: Partial<CanvasShapeClient>) => void
  moveShape:   (shapeId: string, x: number, y: number) => void
  resizeShape: (shapeId: string, width: number, height: number, x?: number, y?: number) => void
  updateShape: (shapeId: string, props: Record<string, unknown>) => void
  deleteShape: (shapeId: string) => void
  setViewport: (viewport: Omit<ViewportClient, 'userId'>) => void
  undo:        () => void
  redo:        () => void
}
```

All mutation methods are fire-and-forget - they encode and send a typed message and return `void`. `viewports` is an array, not a `Map`.

Use `canWrite` to disable shape/draw controls for viewers - shape mutations (`addShape`, `moveShape`, `resizeShape`, `updateShape`, `deleteShape`, `undo`, `redo`) **silently no-op when `canWrite` is `false`**. `setViewport` is exempt and stays open for viewers (viewport broadcasts are presence-like, not stateful writes).

Each shape:

```ts
type CanvasShapeClient = {
  id: string
  type: string                     // free-form: 'rect', 'circle', 'text', etc.
  x: number
  y: number
  width: number
  height: number
  rotation?: number
  props: Record<string, unknown>   // app-specific payload
  createdBy: string
  createdAt: string
  updatedAt: string
}

type ViewportClient = {
  userId: string
  x: number
  y: number
  width: number
  height: number
  zoom: number
}
```

## Presence hooks

### `usePresence(options?)`

Online/offline status derived from `lastSeenAt` heartbeats on the users collection.

```ts
function usePresence(options?: { timeoutMs?: number }): {
  users: RoomUser[]
  isOnline:    (userId: string) => boolean
  getLastSeen: (userId: string) => string | null
}
```

The hook also sends a heartbeat every 60 seconds so the server refreshes the caller's `lastSeenAt`. Default `timeoutMs` is 5 minutes.

### `usePresenceRoom(scopeId)`

High-frequency ephemeral state (cursors, typing, viewport). Connects to a dedicated `PresenceRoom` DO.

```ts
function usePresenceRoom(scopeId: string): {
  peers: PresencePeerClient[]   // excludes self
  connected: boolean
  updateState: (state: object) => void   // merges
}

type PresencePeerClient = {
  userId: string
  userName: string
  userEmail: string
  userImageUrl?: string
  joinedAt: string
  state: Record<string, unknown>
}
```

`scopeId` is any string. Common patterns: `canvas:${canvasId}`, `thread:${channelId}`, `doc:${docId}`.

## `useCronMonitor(roomId)`

Admin/monitor stream for the `CronRoom` DO. Pass the scaffold's `SCOPE_ID` (`app:` + the immutable `APP_ID` from `src/constants.ts`) for the app's default cron room - scope ids key to the app id, never the mutable app name.

```ts
function useCronMonitor(roomId: string): {
  tasks: CronTaskState[]
  history: CronHistoryEntry[]
  connected: boolean
  /** RBAC gate. False until the server AUTH frame lands; stays false for read-only viewers. */
  canWrite: boolean
  lastError: string | null
  trigger: (taskName: string) => Promise<CronMutationResult>
  pause:   (taskName: string) => Promise<CronMutationResult>
  resume:  (taskName: string) => Promise<CronMutationResult>
}

type CronMutationResult =
  | { ok: true; taskName: string; requestId: string }
  | {
      ok: false
      reason: 'read_only' | 'not_connected' | 'unknown_task' | 'failed'
      error?: string
    }

type CronTaskState = {
  name: string
  /** Required. Null when the task is configured via `schedule` instead. */
  intervalMinutes: number | null
  /** Required. Null when the task is configured via `intervalMinutes` instead. */
  schedule: string | null
  /** Required. Null when the task has no explicit timezone. */
  timezone: string | null
  paused: boolean
  lastRunAt: string | null
  nextRunAt: string | null
}

type CronHistoryEntry = {
  taskName: string
  startedAt: string
  completedAt: string | null
  success: boolean
  durationMs: number
  error?: string
}
```

`trigger`, `pause`, and `resume` return receipts. Await and inspect `ok` whenever subsequent UI depends on the mutation. Read-only and disconnected calls resolve locally with typed failure reasons; unknown tasks and execution failures come from the room. `lastError` exposes the most recent general error frame.

**Members and admins can fire owner-billed tasks by default.** The Cron DO authorizes `trigger` / `pause` / `resume` off the role the WebSocket route resolves. The scaffolded `/ws/cron/:roomId` resolves each authenticated caller's **current app role** via `resolveAppRole`; anonymous connections have no role, and `CronRoom` enforces viewers and anonymous connections as read-only. So `canWrite` is `true` for members and admins, `false` for everyone else. For admin-only writes, customize the route's role resolver to return a writer role only for admins - and disable the controls client-side by `canWrite` so the buttons match server policy. See the [scheduled jobs guide](/guides/scheduled-jobs#monitor-and-trigger-from-the-ui-usecronmonitor).

## `useJobs(roomId)`

Enqueue and monitor durable background jobs on the `JobRoom` DO. Pass the scaffold's `SCOPE_ID` (`app:` + the immutable `APP_ID` from `src/constants.ts`) for the per-app job room - scope ids key to the app id, never the mutable app name. The hook subscribes via WebSocket, so `jobs` re-renders on every state change - no polling - and auto-reconnects on socket drop.

```ts
function useJobs(roomId: string): {
  jobs: JobView[]
  connected: boolean
  enqueue(
    type: string,
    payload?: unknown,
    opts?: { maxAttempts?: number },
  ): Promise<string>                       // resolves with the jobId
  getJob(id: string): JobView | undefined
  cancel(id: string): void
  retry(id: string): void
}

type JobView = {
  id: string
  type: string
  status: 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled'
  payload?: unknown
  result?: unknown
  error?: string
  /** 0..1 - present while live. */
  progress?: number
  progressMessage?: string
  attempts: number
  maxAttempts: number
  enqueuedAt: string
  startedAt?: string
  completedAt?: string
  enqueuedBy?: string
}
```

`enqueue` resolves once the server acks (10 s timeout, also rejects on socket close). `cancel(id)` is best-effort - it always flips the DB row to `canceled`, and if the cancel reaches the isolate running the job, `ctx.signal` fires so any in-flight `fetch(...)` you wired with `signal: ctx.signal` aborts cleanly. `retry(id)` re-queues a failed or canceled job.

The handler runs in your `AppJobRoom`'s `onJob(job, ctx)` - see [Background jobs](/guides/background-jobs) for the worker-side pattern and the `enqueueJob` helper for HTTP routes / cron / actions.

**`enqueue` / `cancel` / `retry` require a verified member or admin write role by default.** `JobRoom`'s `authorizeWrite` defaults to member/admin, the scaffolded `AppJobRoom` resolves each caller's current app role (rejecting anonymous connections), and the DO re-checks the role on **every mutation**, not just at connect - a denied call rejects with a write-access error. Client-side gating is UX; `authorizeWrite` is the security boundary. Tighten it to admin-only for jobs that spend owner credits, and route deliberately public producers through an app-owned HTTP action that calls `enqueueJob` server-side. See [Background jobs](/guides/background-jobs#who-can-enqueue) for both patterns.

## `useVoiceAgent(options?)`

A managed voice session against OpenAI's Realtime API over WebRTC - the SDK's
voice surface. There is no LiveKit room hook; for multi-party audio/video
rooms use the [`livekit/*` endpoints](/guides/livekit) with LiveKit's own
client libraries.

```tsx
const voice = useVoiceAgent({
  instructions: 'You are a helpful cooking assistant.',
  onToolCall: async (name, args) => runTool(name, args),
})
```

Options: `instructions`, `voice`, `tools` (OpenAI tool definitions passed
through to the session), `maxMinutes` (hard cap - the hook stops the call),
and `onToolCall(name, args)` (called when the model invokes a tool; the
return value is sent back).

Returns `{ status, start(overrides?), stop(), isMuted, toggleMute,
isAgentSpeaking, transcript, error, pc, dataChannel }` - `status` is
`'idle' | 'connecting' | 'live' | 'ended' | 'error'`, `transcript` is an
array of `{ role: 'user' | 'assistant', content }`, and `pc`/`dataChannel`
are escape hatches to the underlying `RTCPeerConnection` for advanced use.

## User colors

```ts
// 12-color palette of cursor/avatar tints
const DEFAULT_USER_COLORS: readonly string[]

// Deterministic hash: same userId → same color
function getUserColor(userId: string, palette?: string[]): string
```

Use for cursor dots in `usePresence` / `usePresenceRoom`, avatar fallbacks, and "who's typing" pills.

## Low-level sync primitives

For building custom hooks against a DeepSpace Yjs DO (rare):

```ts
createEncoder, createDecoder
toUint8Array, writeVarUint, writeVarUint8Array, readVarUint, readVarUint8Array
encodeSyncStep1, encodeSyncStep2, encodeUpdate
handleSyncMessage
Awareness, encodeAwarenessMessage, handleAwarenessMessage
getMessageType
MSG_SYNC, MSG_AWARENESS, MSG_SYNC_STEP1, MSG_SYNC_STEP2, MSG_SYNC_UPDATE
```

Most apps never use these directly. Use the higher-level hooks (`useYjsText`, etc.) unless you're building a custom binding.

## See also

* [Collaborative editing guide](/guides/collaborative-editing)
* [Presence and cursors guide](/guides/presence-and-cursors)
* [Canvas guide](/guides/canvas)
* [Scheduled tasks guide](/guides/scheduled-jobs)
* [Background jobs guide](/guides/background-jobs)
