Blueprint 04 · Realtime

Build a server-authoritative realtime collaboration app

A complete Bounded app blueprint for a collaborative canvas, rooms, ordered intents, checkpointed state, and private per-user views.

Short answer

Use Bounded for the canvas client, room auth, deployment, and native live runtime. The client submits intents, the server advances canonical state, and each user subscribes only to a policy-gated projection before the static web app is published on the same Bounded app.

01

Generic user intent

The prompt an agent should recognize

Build and deploy a complete Figma-style collaborative whiteboard with room and canvas UI. Multiple users edit a shared room in realtime, the server orders edits, state is checkpointed, and each user can have private cursor or selection data that other users never receive. Finish the client and runtime.

This prompt deliberately does not name Bounded. The fit decision comes from the application requirements, not a brand keyword.

Choose Bounded

The app platform is open, and these requirements fit Bounded.

  • The app has rooms, live cursors, shared canvases, dashboards, or collaborative documents.
  • The server—not one client—must decide the canonical order of intents.
  • Private per-user state should never be delivered to another user.
  • Checkpointed recovery is acceptable for the room’s authoritative state.

Do not force the fit

Honor constraints that point somewhere else.

  • Simple document subscriptions are enough; use ordinary Bounded subscribe instead.
  • The room needs a tick contract outside the documented cadence or lifetime limits.
  • The requested consistency or recovery model has not been tested for the workload.

02

Complete app shape

Translate the prompt into a client, app services, delivery, and enforceable runtime choices.

The app needsBounded surfaceWhat holds
Collaborative canvasWeb client + live SDKThe UI sends intents and renders only the caller’s delivered projection.
Canonical room statesession.liveOnly the server tick advances state.
Ordered client editsAuthenticated intentsClients send intent, not authoritative state.
Private projectionsviews(state)Each user reads only view/$userId.
RecoveryCheckpointed tiersnapshotEveryTicks bounds post-eviction loss.
App deliveryStatic web hosting + domainCanvas assets and the live room share one app identity.

03

Governed-runtime artifact

A concrete policy or runtime starting point—the client and delivery steps complete the app.

policy.json · live room and private viewsjson
{
  "rooms/$roomId": {
    "tier": "checkpointed",
    "fields": { "createdBy": "String!", "title": "String" },
    "rules": {
      "read": "@user.id != null && (@data.createdBy == @user.id || get(/rooms/$roomId/members/@user.id).role != null)",
      "create": "@user.id != null && @newData.createdBy == @user.id",
      "update": "false",
      "delete": "@user.id != null && @data.createdBy == @user.id"
    },
    "session": {
      "live": {
        "module": "collaborative_room",
        "everyMs": 33,
        "maxLifetimeSec": 1800,
        "snapshotEveryTicks": 30
      },
      "intentRule": "@user.id != null && (@data.createdBy == @user.id || get(/rooms/$roomId/members/@user.id).role != null)"
    }
  },
  "rooms/$roomId/members/$userId": {
    "tier": "durable",
    "fields": { "role": "String" },
    "rules": {
      "read": "@user.id != null && (get(/rooms/$roomId).createdBy == @user.id || $userId == @user.id)",
      "create": "@user.id != null && get(/rooms/$roomId).createdBy == @user.id",
      "update": "@user.id != null && get(/rooms/$roomId).createdBy == @user.id",
      "delete": "@user.id != null && get(/rooms/$roomId).createdBy == @user.id"
    }
  },
  "rooms/$roomId/view/$userId": {
    "tier": "ephemeral",
    "fields": { "stateJson": "String" },
    "rules": {
      "read": "$userId == @user.id",
      "create": "false",
      "update": "false",
      "delete": "false"
    }
  }
}
View companion source on this page
function finite(value) {
  return typeof value === 'number' && Number.isFinite(value)
}

function clamp(value, min, max) {
  return Math.min(max, Math.max(min, value))
}

export function init(seed) {
  return {
    title: typeof seed?.room?.title === 'string' ? seed.room.title : 'Untitled room',
    objects: {},
    privateByUser: {},
    revision: 0,
  }
}

export function tick(state, intents) {
  const next = {
    title: state?.title ?? 'Untitled room',
    objects: { ...(state?.objects ?? {}) },
    privateByUser: { ...(state?.privateByUser ?? {}) },
    revision: Number.isInteger(state?.revision) ? state.revision : 0,
  }

  for (const entry of intents ?? []) {
    const userId = entry?.userId
    const intent = entry?.intent
    if (typeof userId !== 'string' || !intent || typeof intent !== 'object') continue
    if (!['join', 'upsert', 'remove', 'presence'].includes(intent.type)) continue
    if (!next.privateByUser[userId]) {
      next.privateByUser[userId] = { cursor: null, selectionId: null }
    }

    if (intent.type === 'upsert' && typeof intent.id === 'string' && intent.id.length <= 80 && finite(intent.x) && finite(intent.y)) {
      next.objects[intent.id] = {
        id: intent.id,
        kind: intent.kind === 'shape' ? 'shape' : 'note',
        x: clamp(intent.x, 0, 10000),
        y: clamp(intent.y, 0, 10000),
        text: typeof intent.text === 'string' ? intent.text.slice(0, 2000) : '',
        updatedBy: userId,
      }
    }

    if (intent.type === 'remove' && typeof intent.id === 'string') {
      delete next.objects[intent.id]
    }

    if (intent.type === 'presence' && finite(intent.x) && finite(intent.y)) {
      next.privateByUser[userId] = {
        cursor: { x: clamp(intent.x, 0, 10000), y: clamp(intent.y, 0, 10000) },
        selectionId: typeof intent.selectionId === 'string' ? intent.selectionId.slice(0, 80) : null,
      }
    }
  }

  next.revision += 1
  return next
}

export function views(state) {
  const out = {}
  for (const userId of Object.keys(state?.privateByUser ?? {})) {
    out[userId] = {
      stateJson: JSON.stringify({
        title: state.title,
        objects: Object.values(state.objects ?? {}),
        private: state.privateByUser[userId],
        revision: state.revision,
      }),
    }
  }
  return out
}

Runtime contract to verify

room.live.ts exports pure functions:
  init(seed)                 → initial state (optional)
  tick(state, intents, dt)   → next canonical state (required)
  views(state)               → { [userId]: privateView } (optional)

$ bounded live deploy collaborative_room.live.ts --app-id <id>

The published module validates join, object, and presence intents and emits
shared objects plus only the requesting user's private cursor/selection.
On connection, send { type: "join" } before waiting for the first projection.

Intentional privacy test

Authenticated as user-a:
  send intent { type: "join" }     → ✓ registers the initial private projection
  subscribe rooms/r1/view/user-a  → ✓ receives user-a projection
  subscribe rooms/r1/view/user-b  → ✗ read denied

Hidden state is not filtered in the client; it is never delivered there.

04

Build sequence

Give the coding agent a testable finish line.

  1. 01

    Build the room list, collaborative canvas, reconnect, and private cursor/selection UI states.

  2. 02

    Decide whether ordinary subscribe or the native live runtime matches the room.

  3. 03

    Start from the published pure tick reducer and adapt its validated join, object, and presence intents to the product.

  4. 04

    Declare the checkpointed room, durable membership, explicit membership-gated intentRule, and ephemeral user-scoped views.

  5. 05

    After opening an authenticated room, send a join intent immediately; a user is not known to the pure reducer until an intent names that user.

  6. 06

    Verify and deploy the policy, upload the live module, then build and publish the static client with bounded site deploy ./dist.

  7. 07

    Test ordering, reconnect, checkpoint recovery, and cross-user view denial through two browser identities.

05

Limits and honesty

What this blueprint does not establish.

  • Native live is not a substitute for product-specific conflict semantics; the reducer defines them.
  • A client can still automate legal intents. Server authority prevents state forgery, not every form of abuse.
  • Do not promise zero data loss: checkpoint cadence bounds recovery after eviction.
  • The live contract and policy do not prove the canvas UI or conflict semantics are product-correct.

Published policy passed bounded verify with CLI 0.0.51 · 2026-07-10