English
Collaboration
Give the editor a collaboration provider and several people can edit one template at once. Every edit reaches your backend as a batch of operations, your backend puts the batches in one order, and every editor applies them in that order. Selecting a block locks it, so two people never type into the same block, and everyone sees who else has the template open.
ts
import { init } from '@templatical/editor';
let sessionId = '';
const lockUrl = (templateId: string, blockId: string, locale?: string | null) =>
`/api/templates/${templateId}/locks/${blockId}` +
(locale ? `?locale=${encodeURIComponent(locale)}` : '');
const editor = await init({
container: '#editor',
user: { id: 'u_7', name: 'Ada Lovelace' },
templates: myTemplatesProvider, // `load` returns the template's `revision`
collaboration: {
join: (templateId, session, handlers) => {
sessionId = session.sessionId;
return myRealtime.join(templateId, session, handlers);
},
submit: (templateId, batch) =>
api(`/api/templates/${templateId}/operations`, {
method: 'POST',
headers: { 'X-Session-Id': sessionId },
body: JSON.stringify(batch),
}),
operationsAfter: (templateId, revision) =>
api(`/api/templates/${templateId}/operations?after=${revision}`, {
headers: { 'X-Session-Id': sessionId },
}),
acquireLock: (templateId, blockId, locale) =>
api(`/api/templates/${templateId}/locks`, {
method: 'POST',
headers: { 'X-Session-Id': sessionId },
body: JSON.stringify({ blockId, locale: locale ?? null }),
}),
renewLock: (templateId, blockId, locale) =>
api(lockUrl(templateId, blockId, locale), {
method: 'PUT',
headers: { 'X-Session-Id': sessionId },
}),
releaseLock: async (templateId, blockId, locale) => {
await fetch(lockUrl(templateId, blockId, locale), {
method: 'DELETE',
headers: { 'X-Session-Id': sessionId },
keepalive: true,
});
},
},
});
await editor.load('tpl_123');api is your own fetch helper; a refused request must reject with the HTTP status as statusCode and the response's code, holder and holders (see Refusals), because a missing status counts as a network failure and is retried.
The backend combines X-Session-Id with the authenticated user to identify the lock owner.
What the editor does
- Sends every edit as operations. The editor compares the canvas with what it last sent and turns the difference into a batch: a drag becomes a
moveBlock, typing anupdateBlock. Typing into one block waits for a 300 ms pause, at most a second; everything else goes at once. One batch is in flight at a time, so a session's batches arrive in order, and a batch that fails in transit is sent again with the samebatchId. - Applies everyone's batches in revision order. Batches from other sessions are buffered and applied strictly by revision, with this session's unconfirmed edits replayed on top. A gap that isn't filled within a second is fetched with
operationsAfter. - Locks the selected block. Selecting a block takes its lock in the active view's language:
locale: nullin the default view, and the version's tag in a language view, including a read-only Not enabled version, whose viewer holds the lock but can't edit. Another session's lock on the block stops the selection, showing its holder's name and colour, when either lock isnullor both are in the same language. Locks renew every 10 seconds and are released on deselect, on loading another template and when the page closes. - Shows who is here. The header shows one avatar per person: a user's sessions in several tabs count once, and each guest counts on their own.
- Turns merge tags in arriving batches into tags. A batch from another session — an agent's, another editor's, a server's — has the bare tokens in its titles and paragraphs converted as it arrives, the way loaded content is, and nothing is sent back for it. Arriving batches apply without their
expect: your backend checked those against its own copy. - Shows agents' edits. A batch whose actor has
type: 'mcp'shows a notice namingactor.clientandactor.name("Claude for Ada changed 3 blocks") and outlines the blocks it changed for 2 seconds. A re-sync whoseactorhastype: 'mcp'shows "Claude for Ada updated the template", with no outline. Batches and re-syncs from one agent within 2 seconds share a notice. Agents need no presence and take no locks. - Shows the AI's edits. A batch with
actor.type: 'ai'shows "AI (for Ada) changed 3 blocks" and outlines the changed blocks. A re-sync shows a template-update notice without its own block outline. The requesting session sees only a batch's outline, with no notice. An AI provider's operations reach your backend as this user's batches withorigin: 'ai'. - Undoes with operations. Undo and redo send the inverse of your own batches, each guarded by what it expects to find. An undo that someone else's edit has overtaken is refused and skipped with a notice, never applied over their work. A step that restores a whole-template snapshot (the undo of a content replacement or of Make default) can't name what it expects, so it is skipped once this editor has received another session's batch after it; a batch your backend committed but hasn't delivered yet is caught by the
setContentrule insubmit. Other people's edits never enter your undo history. - Saves only renames. Content reaches your backend through
submit, sotemplates.savereceives only a new name, after the editor's batches are confirmed. Autosave keeps working: it settles the batches and renames if needed. - Tells the user when a selection or an edit was refused, when an undo step was skipped, when a version can't be restored because others hold locks, and while the connection is down.
The contract
ts
import type { Operation, OperationErrorCode } from "@templatical/types";
export interface OperationBatch {
/** A UUID the editor generates; resubmitting the same id applies it once. */
batchId: string;
/** The last revision the editor had confirmed when it sent the batch. */
baseRevision: number;
/** 1–100 operations, applied in order and all or nothing. */
ops: Operation[];
/**
* `"ai"` when an AI turn produced the batch: the editor applied the
* operations an `AiProvider` handed it. A backend may broadcast the batch
* with `actor.type: "ai"` (the same user and session), and the other
* editors then name it "AI (for Ada)". Absent on every other batch.
*/
origin?: "ai";
}
export interface CollaborationActor {
type: "user" | "guest" | "mcp" | "api" | "ai";
/**
* The user id, `guest:{sessionId}` for a guest, `null` for `api`. For `mcp`,
* the id of the user the agent acts for; for `ai`, of the user who asked.
*/
id: string | null;
/** For `mcp` and `ai`, the name of the user the agent or the AI acts for. */
name: string;
/**
* `null` when no editor session made the change (`api`, `mcp`). For `ai`,
* the session that asked: that editor shows the outline and no notice.
*/
sessionId: string | null;
/**
* For `mcp` only: the agent's name (`Claude`, `Cursor`, …). The editor names
* it in the notice it shows for the batch ("Claude for Ada changed 3
* blocks"), as text. A backend that lets agents edit sends it with every
* agent batch, broadcast and catch-up alike.
*/
client?: string;
}
export interface RevisionedBatch {
revision: number;
batchId: string;
actor: CollaborationActor;
ops: Operation[];
createdAt?: string;
}
export interface CollaborationMember {
sessionId: string;
/** `null` for a guest. */
userId: string | null;
name: string;
type: "user" | "guest";
avatarUrl?: string;
/** A CSS colour the backend picks, so it is the same on every screen. */
color: string;
}
export interface BlockLockHolder {
sessionId: string;
type: "user" | "guest" | "mcp";
/** The user id, or `guest:{sessionId}`. */
id: string;
name: string;
color: string;
}
export interface BlockLock {
blockId: string;
/** The language held; absent or `null` holds the default language and structure. */
locale?: string | null;
holder: BlockLockHolder;
/**
* ISO 8601. Renewals aren't broadcast, so another session's lock is dropped
* when it is released or its session leaves, never by this time.
*/
expiresAt: string;
}
export type CollaborationConflictCode =
OperationErrorCode | "lock_lost" | "content_too_large";
export type CollaborationConnectionState =
"connected" | "reconnecting" | "offline";
export interface CollaborationHandlers {
/** Another session's batch, or one this session can't attribute to itself. */
onBatch(batch: RevisionedBatch): void;
/**
* The content changed in a way batches don't describe: fetch it again.
* `actor` is who changed it, when the backend knows: the editor names an
* agent's re-sync (`type: "mcp"`) in a notice ("Claude for Ada updated the
* template").
*/
onResync(revision: number, actor?: CollaborationActor): void;
onLock(lock: BlockLock): void;
onUnlock(blockId: string, sessionId: string, locale?: string | null): void;
/** Everyone with the template open, this session included. */
onMembers(members: CollaborationMember[]): void;
onConnectionChange(state: CollaborationConnectionState): void;
}
export interface CollaborationJoin {
/** The template's current revision. */
revision: number;
/** Unexpired locks. */
locks: BlockLock[];
/** Everyone with the template open, this session included; empty without presence. */
members: CollaborationMember[];
/** Stop receiving events for this template. */
leave(): void;
}
export type OperationsAfterResult =
| { revision: number; batches: RevisionedBatch[] }
| { revision: number; resync: true };
export interface CollaborationProvider {
join(
templateId: string,
session: { sessionId: string },
handlers: CollaborationHandlers,
): Promise<CollaborationJoin>;
submit(
templateId: string,
batch: OperationBatch,
): Promise<{ revision: number }>;
operationsAfter(
templateId: string,
revision: number,
): Promise<OperationsAfterResult>;
/**
* Acquire the block's lock in `locale`; absent or `null` means the default
* language and structure. A provider that ignores `locale` serializes per block.
*/
acquireLock?(
templateId: string,
blockId: string,
locale?: string | null,
): Promise<BlockLock>;
/**
* Renew the block's lock in `locale`; absent or `null` means the default
* language and structure. A provider that ignores `locale` serializes per block.
*/
renewLock?(
templateId: string,
blockId: string,
locale?: string | null,
): Promise<BlockLock>;
/**
* Release the block's lock in `locale`; absent or `null` means the default
* language and structure. A provider that ignores `locale` serializes per block.
*/
releaseLock?(
templateId: string,
blockId: string,
locale?: string | null,
): Promise<void>;
}
export interface CollaborationRemoteChange {
actor: CollaborationActor;
/**
* The first locale operation's language, excluding `setDefaultLocale`, otherwise `null`.
* A `setDefaultLocale` batch reports `null`: it changes the template's default.
*/
locale: string | null;
ops: Operation[];
}The declarations give every field, optional value and nullability. OperationBatch.ops holds 1–100 operations in application order; batchId is an editor-generated UUID. RevisionedBatch.createdAt is optional; use ISO 8601 if your backend supplies a timestamp. BlockLock.expiresAt is ISO 8601. Because renewals are not broadcast, another session removes a lock on release or session departure, never by that time. CollaborationActor.id is null for api, sessionId is null for api and mcp, and CollaborationMember.userId is null for guests.
join
Called when a template is loaded, with this editor's session id (a UUID the editor makes). Subscribe to the template's events, then answer with its current revision, the unexpired locks and everyone with it open, this session included. Call the handlers for everything that happens afterwards; leave() stops them.
join is called again for the same template after a reconnect: answer with fresh state without subscribing twice. Call onConnectionChange when your realtime connection drops and comes back; the editor re-joins and catches up on connected.
submit
Apply the batch's operations to the template, in order and all or nothing, then answer with the revision it produced. baseRevision is the last revision the editor had confirmed; batches are applied to the current content whatever it is, which is what the operations' sibling anchors and preconditions are for. A batch that contains a setContent is the exception: refuse it with 409 and precondition_failed when its baseRevision isn't the current revision, because a whole-content replacement built on an older revision would overwrite batches its sender never saw. The editor then drops it: an undo or redo step is skipped with a notice, and any other edit is reported as not saved. It also drops such a batch itself, before sending it, when another session's batch reaches it first. A batchId you have already applied answers with that batch's revision and applies nothing, because the editor resends after a failure in transit. Check a repeated batchId first, as the sample below does, then the setContent rule: a resent snapshot keeps the baseRevision of its first attempt, so one that already landed answers with its revision, not 409. Send the batch to every other session with onBatch, with its revision and actor.
operationsAfter
Answer with every batch after revision, in order. When you no longer have them all, answer { revision, resync: true } and the editor reloads the template through templates.load. Call onResync(revision, actor) for a change batches can't describe, such as a setContent, a restored version or a broadcast too large for your transport. actor is who made the change, in the shape of a batch's; the editor uses it only for an agent's or the AI's notice, and leaving it out changes nothing else.
Locks
acquireLock grants the lock or refuses with lock_held and the holder; renewLock extends it or refuses with lock_lost; releaseLock lets it go and never fails. Send onLock and onUnlock to the other sessions. The three methods are optional together: without them the editor takes no locks.
BlockLock.locale?: string | null distinguishes language locks; absence means null, the default language and structure. The signatures are acquireLock?(templateId: string, blockId: string, locale?: string | null): Promise<BlockLock>, renewLock?(templateId: string, blockId: string, locale?: string | null): Promise<BlockLock> and releaseLock?(templateId: string, blockId: string, locale?: string | null): Promise<void>. Forward locale to the backend and through onLock / onUnlock(blockId, sessionId, locale?).
updateBlockLocale on (X, de) conflicts with another session's (X, de) or (X, null) lock; (X, fr) can coexist. updateBlock, moveBlock and deleteBlock on X conflict with every other session's lock on X, and a move or delete also with locks on the blocks inside X; addBlock checks no locks. Pass locale through to OperationLock when calling applyOperations; a backend ignoring it serializes per block. Upgrade the backend before enabling language versions. Refuse acquireLock on (X, locale) with lock_held and the holder when another owner holds a lock on X and either lock's locale is null or both are equal; otherwise grant it, store its locale, and renew and release it by block and locale.
Refusals
The editor reads statusCode for retry classification, plus code and holder or holders for refusals. CollaborationConflict from @templatical/types carries them, and any error with the same fields works.
code | From | The editor |
|---|---|---|
| any of the operation errors | submit | Drops the batch, puts back your backend's state and says the change wasn't saved, naming the holder for lock_held |
content_too_large | submit | Drops the batch and says the change was too large |
lock_held (with holder) | acquireLock | Deselects and says who is editing the block |
lock_lost | renewLock | Takes the lock again if the block is still selected |
Network errors, 5xx, 408 and 429 are retried with the same batchId after 1, 2, 5, 10 and 30 seconds.
Templates
With a collaboration provider, templates.load must return the template's revision along with its content, and may return its unexpired locks. templates.save receives only { name }; the content is already on your backend. create works as usual and starts a template at revision 0.
Building a backend
Applying a batch
When a batch carries origin: 'ai', broadcast it with actor.type: 'ai', the same id, name and sessionId; the editors name it as the AI's. The playground's host does this.
ts
import { applyOperations, OperationError } from '@templatical/types';
app.post('/api/templates/:id/operations', async (req, res) => {
const { batchId, baseRevision, ops } = req.body;
const sessionId = req.get('X-Session-Id') ?? null; // however you identify it
const template = await db.templates.lockForUpdate(req.params.id);
const known = await db.batches.find(template.id, batchId);
if (known) return res.json({ revision: known.revision });
if (ops.some((op) => op.op === 'setContent') && baseRevision !== template.revision) {
return res.status(409).json({ message: 'The template changed since this replacement was made.', code: 'precondition_failed' });
}
const locks = await db.locks.unexpired(template.id);
let content;
try {
content = applyOperations(template.content, ops, {
locks: locks.map((lock) => ({ blockId: lock.blockId, sessionId: lock.owner, locale: lock.locale ?? null })),
session: sessionId ? ownerOf(req.user, sessionId) : null,
});
} catch (error) {
if (!(error instanceof OperationError)) throw error;
return res.status(error.code === 'invalid_operation' ? 422 : 409).json({
message: error.message,
code: error.code,
opIndex: error.opIndex,
});
}
const revision = template.revision + 1;
await db.templates.update(template.id, { content, revision });
const batch = { revision, batchId, actor: actorOf(req.user, sessionId), ops };
await db.batches.insert(template.id, batch);
realtime.toOthers(template.id, sessionId, 'batch', batch);
res.json({ revision });
});Serialize the batches of one template: two applied at once would both read the same revision. Run the same applyOperations the editor runs, so a batch the editor considered valid is valid on your server too.
Locks and ownership
A lock belongs to a session and to the identity behind it together: the user id, or guest:<sessionId> for a session without a user. Compare both, as ownerOf does above, so a session id borrowed from another user's request can't act on their lock. Give locks a short expiry (Templatical Cloud uses 30 seconds; the editor renews every 10), and when a session takes a new block, release the block it held before. Leave lock checks to applyOperations: pass every unexpired lock with its owner, and the acting owner as session.
Catching up
Keep recent batches for operationsAfter. How many is up to you: when an editor asks for revisions you no longer have, answer resync and it reloads the template.
Presence
Members are sessions, not users: the same user in two tabs is two members, and the editor groups them. Give each member a colour that stays the same for the same person everywhere, for example from a hash of the user id.
Without realtime
A backend without a realtime channel can still collaborate in order: join answers with the revision and empty locks and members and never calls a handler, and the provider has no lock methods. Every edit still goes through submit, refusals still work, and other people's edits appear when the template is loaded again.
Events
init({ onRemoteChange }) is called with { actor, ops, locale } after another session's batch is applied on the canvas. It isn't called for this editor's own edits, nor for a re-sync that reloads the template.
locale: string | null reports the first locale operation's language in a remote batch, excluding setDefaultLocale, otherwise null. A setDefaultLocale batch reports null because it changes the template's default.
In the playground
The Collaboration setup runs this contract between browser tabs, over a backend built on applyOperations in a shared worker. Open it in two tabs, and add ?user=Ada to one to sign it in.