---
title: Zusammenarbeit
description: Gemeinsames Bearbeiten in Echtzeit über Ihr eigenes Backend — geordnete Operationen, Präsenz und Block-Sperren.
---

# Zusammenarbeit

Geben Sie dem Editor einen Collaboration-Provider, und mehrere Personen können ein Template gleichzeitig bearbeiten. Jede Änderung erreicht Ihr Backend als Batch von [Operationen](/de/api/operations), Ihr Backend bringt die Batches in eine Reihenfolge, und jeder Editor wendet sie in dieser Reihenfolge an. Wer einen Block auswählt, sperrt ihn, sodass nie zwei Personen in denselben Block tippen, und alle sehen, wer das Template sonst noch geöffnet hat.

```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` ist Ihr eigener Fetch-Helfer; bei einer abgelehnten Anfrage muss der Fehler den HTTP-Status als `statusCode` sowie `code`, `holder` und `holders` der Antwort enthalten (siehe [Ablehnungen](#ablehnungen)), weil ein fehlender Status als Netzwerkfehler zählt und die Anfrage erneut versucht wird.

Das Backend kombiniert `X-Session-Id` mit dem authentifizierten Nutzer, um den Besitzer der Sperre zu bestimmen.

## Was der Editor macht

- **Er schickt jede Änderung als Operationen.** Der Editor vergleicht den Canvas mit dem, was er zuletzt geschickt hat, und macht aus dem Unterschied einen Batch: Aus Ziehen wird ein `moveBlock`, aus Tippen ein `updateBlock`. Tippen in einen Block wartet auf eine Pause von 300 ms, höchstens eine Sekunde; alles andere geht sofort. Es ist immer nur ein Batch unterwegs, sodass die Batches einer Sitzung der Reihe nach ankommen, und ein Batch, der unterwegs scheitert, wird mit derselben `batchId` erneut geschickt.
- **Er wendet die Batches aller in Revisionsreihenfolge an.** Batches anderer Sitzungen werden gepuffert und strikt nach Revision angewendet, die noch unbestätigten eigenen Änderungen darüber. Eine Lücke, die sich nicht innerhalb einer Sekunde schließt, holt er mit `operationsAfter`.
- **Er sperrt den ausgewählten Block.** Wer einen Block auswählt, nimmt seine Sperre in der Sprache der aktiven Ansicht: `locale: null` in der Standardansicht und das Tag der Version in einer Sprachansicht, auch in einer schreibgeschützten Version mit **Nicht aktiviert**, deren Betrachter die Sperre hält, aber nichts bearbeiten kann. Die Sperre einer anderen Sitzung auf dem Block verhindert die Auswahl und zeigt Name und Farbe der haltenden Person, wenn eine der beiden Sperren `null` ist oder beide dieselbe Sprache haben. Sperren werden alle 10 Sekunden erneuert und beim Abwählen, beim Laden eines anderen Templates und beim Schließen der Seite freigegeben.
- **Er zeigt, wer da ist.** Die Kopfzeile zeigt einen Avatar pro Person: Die Sitzungen eines Nutzers in mehreren Tabs zählen einmal, jeder Gast zählt für sich.
- **Er macht aus Merge-Tags in ankommenden Batches Tags.** Ein Batch einer anderen Sitzung – eines Agenten, eines anderen Editors, eines Servers – bekommt die nackten Tokens in Titeln und Absätzen beim Ankommen umgewandelt, so wie [geladene Inhalte](/de/guide/merge-tags#tokens-in-geladenen-inhalten), und dafür wird nichts zurückgeschickt. Ankommende Batches werden ohne ihr `expect` angewendet: Das hat Ihr Backend schon gegen seine eigene Kopie geprüft.
- **Er zeigt Änderungen von Agenten.** Ein Batch, dessen Actor `type: 'mcp'` hat, zeigt einen Hinweis mit `actor.client` und `actor.name` („Claude für Ada hat 3 Blöcke geändert“) und umrandet die geänderten Blöcke für 2 Sekunden. Ein Resync, dessen `actor` `type: 'mcp'` hat, zeigt „Claude für Ada hat die Vorlage aktualisiert“, ohne Rahmen. Batches und Resyncs eines Agenten innerhalb von 2 Sekunden teilen sich einen Hinweis. Agenten brauchen keine Präsenz und nehmen keine Sperren.
- **Zeigt KI-Änderungen.** Ein Batch mit `actor.type: 'ai'` zeigt „AI (for Ada) changed 3 blocks“ und umrahmt die geänderten Blöcke. Ein Re-Sync zeigt einen Template-Aktualisierungshinweis ohne eigenen Block-Rahmen. Die anfragende Sitzung sieht beim Batch nur den Rahmen und keinen Hinweis. Die Operationen eines [KI-Providers](/de/backend/ai) erreichen Ihr Backend als Batches dieser Person mit `origin: 'ai'`.
- **Er macht mit Operationen rückgängig.** Rückgängig und Wiederholen schicken die Umkehrung Ihrer eigenen Batches, jeweils abgesichert durch das, was sie vorzufinden erwarten. Ein Rückgängig-Schritt, den die Änderung eines anderen überholt hat, wird abgelehnt und mit einem Hinweis übersprungen, nie über deren Arbeit angewendet. Ein Schritt, der einen Snapshot des ganzen Templates wiederherstellt (das Rückgängigmachen einer Inhaltsersetzung oder von Als Standardsprache festlegen), kann nicht angeben, was er erwartet; er wird übersprungen, sobald dieser Editor danach einen Batch einer anderen Sitzung empfangen hat. Einen vom Backend schon angenommenen, aber noch nicht zugestellten Batch fängt die `setContent`-Regel in [`submit`](#submit) ab. Änderungen anderer landen nie in Ihrem Verlauf.
- **Er speichert nur Umbenennungen.** Der Inhalt erreicht Ihr Backend über `submit`, deshalb erhält `templates.save` nur noch einen neuen Namen, nachdem die Batches des Editors bestätigt sind. Autosave funktioniert weiter: Es wartet die Batches ab und benennt bei Bedarf um.
- **Er sagt es dem Nutzer**, wenn eine Auswahl oder eine Änderung abgelehnt wurde, wenn ein Rückgängig-Schritt übersprungen wurde, wenn eine Version nicht wiederhergestellt werden kann, weil andere Sperren halten, und solange die Verbindung weg ist.

## Der Vertrag

```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[];
}
```

Die Deklarationen geben alle Felder, optionale Werte und Nullbarkeit an. `OperationBatch.ops` enthält 1–100 Operationen in Anwendungsreihenfolge; `batchId` ist eine vom Editor erzeugte UUID. `RevisionedBatch.createdAt` ist optional; wenn Ihr Backend einen Zeitstempel liefert, verwenden Sie ISO 8601. `BlockLock.expiresAt` ist ISO 8601. Da Verlängerungen nicht gesendet werden, entfernt eine andere Sitzung eine Sperre erst bei Freigabe oder beim Verlassen der Sitzung, nicht anhand dieses Zeitstempels. `CollaborationActor.id` ist für `api` `null`, `sessionId` für `api` und `mcp` `null`; `CollaborationMember.userId` ist bei Gästen `null`.

### `join`

Wird aufgerufen, wenn ein Template geladen ist, mit der Sitzungs-ID dieses Editors (einer UUID, die der Editor erzeugt). Abonnieren Sie die Events des Templates und antworten Sie dann mit seiner aktuellen Revision, den nicht abgelaufenen Sperren und allen, die es geöffnet haben, diese Sitzung eingeschlossen. Rufen Sie für alles, was danach passiert, die Handler auf; `leave()` beendet das.

Nach einem Reconnect wird `join` für dasselbe Template erneut aufgerufen: Antworten Sie mit frischem Zustand, ohne doppelt zu abonnieren. Rufen Sie `onConnectionChange` auf, wenn Ihre Echtzeitverbindung abbricht und zurückkommt; bei `connected` tritt der Editor erneut bei und holt auf.

### `submit`

Wenden Sie die Operationen des Batches auf das Template an, der Reihe nach und alles oder nichts, und antworten Sie mit der Revision, die dabei entstanden ist. `baseRevision` ist die letzte Revision, die der Editor bestätigt hatte; Batches werden auf den aktuellen Inhalt angewendet, wie auch immer er gerade aussieht, und genau dafür gibt es die Nachbar-Anker und Vorbedingungen der Operationen. Die Ausnahme ist ein Batch mit einem `setContent`: lehnen Sie ihn mit `409` und `precondition_failed` ab, wenn seine `baseRevision` nicht die aktuelle Revision ist, denn eine Ersetzung des ganzen Inhalts auf Grundlage einer älteren Revision würde Batches überschreiben, die der Absender nie gesehen hat. Der Editor verwirft ihn dann: Ein Rückgängig- oder Wiederholen-Schritt wird mit einem Hinweis übersprungen, jede andere Änderung als nicht gespeichert gemeldet. Einen solchen Batch verwirft er auch selbst, bevor er ihn sendet, wenn ihn der Batch einer anderen Sitzung zuerst erreicht. Eine `batchId`, die Sie schon angewendet haben, antwortet mit der Revision dieses Batches und wendet nichts an, denn der Editor schickt nach einem Fehler unterwegs erneut. Prüfen Sie eine wiederholte `batchId` zuerst, wie im Beispiel unten, und erst dann die `setContent`-Regel: Ein erneut gesendeter Snapshot behält die `baseRevision` seines ersten Versuchs; einer, der schon angewendet wurde, antwortet also mit seiner Revision statt mit `409`. Schicken Sie den Batch mit seiner Revision und seinem Actor über `onBatch` an jede andere Sitzung.

### `operationsAfter`

Antworten Sie mit jedem Batch nach `revision`, der Reihe nach. Haben Sie nicht mehr alle, antworten Sie `{ revision, resync: true }`, und der Editor lädt das Template über `templates.load` neu. Rufen Sie `onResync(revision, actor)` für eine Änderung auf, die Batches nicht beschreiben können, etwa ein `setContent`, eine wiederhergestellte Version oder einen Broadcast, der für Ihren Transport zu groß ist. `actor` ist, wer die Änderung gemacht hat, in der Form eines Batch-Actors; der Editor nutzt ihn nur für den Hinweis zu einem Agenten oder zur KI, und ohne ihn ändert sich sonst nichts.

### Sperren

`acquireLock` gewährt die Sperre oder lehnt mit `lock_held` und der haltenden Person ab; `renewLock` verlängert sie oder lehnt mit `lock_lost` ab; `releaseLock` gibt sie frei und scheitert nie. Schicken Sie `onLock` und `onUnlock` an die anderen Sitzungen. Die drei Methoden sind nur gemeinsam optional: Ohne sie nimmt der Editor keine Sperren.

`BlockLock.locale?: string | null` unterscheidet Sprachsperren; fehlend bedeutet `null`, Standardsprache und Struktur. Die Signaturen sind `acquireLock?(templateId: string, blockId: string, locale?: string | null): Promise<BlockLock>`, `renewLock?(templateId: string, blockId: string, locale?: string | null): Promise<BlockLock>` und `releaseLock?(templateId: string, blockId: string, locale?: string | null): Promise<void>`. Leiten Sie die Locale an das Backend und über `onLock` / `onUnlock(blockId, sessionId, locale?)` weiter.

`updateBlockLocale` an (X, de) kollidiert mit (X, de) oder (X, null) einer anderen Sitzung; (X, fr) kann parallel bestehen. `updateBlock`, `moveBlock` und `deleteBlock` auf X kollidieren mit jeder Sperre einer anderen Sitzung auf X, Verschieben und Löschen auch mit Sperren auf den Blöcken in X; `addBlock` prüft keine Sperren. Übergeben Sie `locale` in `OperationLock` an `applyOperations`; ein Backend ohne Locale-Auswertung serialisiert pro Block. Aktualisieren Sie das Backend vor dem Aktivieren von [Sprachversionen](/de/guide/localized-templates#compatibility). Lehnen Sie `acquireLock` auf (X, `locale`) mit `lock_held` und der haltenden Person ab, wenn ein anderer Besitzer eine Sperre auf X hält und die Locale einer der beiden Sperren `null` ist oder beide gleich sind; andernfalls gewähren Sie sie, speichern ihre `locale` und erneuern und lösen sie nach Block und Locale.

### Ablehnungen

Der Editor liest `statusCode` für die Entscheidung über Wiederholungen sowie `code` und `holder` oder `holders` für Ablehnungen. `CollaborationConflict` aus `@templatical/types` trägt sie, und jeder Fehler mit denselben Feldern funktioniert.

| `code` | Von | Der Editor |
|---|---|---|
| einer der [Operations-Fehler](/de/api/operations#fehler) | `submit` | Verwirft den Batch, stellt den Stand Ihres Backends wieder her und sagt, dass die Änderung nicht gespeichert wurde, bei `lock_held` mit der haltenden Person |
| `content_too_large` | `submit` | Verwirft den Batch und sagt, dass die Änderung zu groß war |
| `lock_held` (mit `holder`) | `acquireLock` | Wählt ab und sagt, wer den Block bearbeitet |
| `lock_lost` | `renewLock` | Nimmt die Sperre erneut, wenn der Block noch ausgewählt ist |

Netzwerkfehler, 5xx, 408 und 429 werden mit derselben `batchId` nach 1, 2, 5, 10 und 30 Sekunden erneut versucht.

## Templates

Mit einem Collaboration-Provider muss `templates.load` neben dem Inhalt die `revision` des Templates liefern und kann seine nicht abgelaufenen `locks` liefern. `templates.save` erhält nur `{ name }`; der Inhalt ist schon auf Ihrem Backend. `create` funktioniert wie gewohnt und beginnt ein Template bei Revision 0.

## Ein Backend bauen

### Einen Batch anwenden

Trägt ein Batch `origin: 'ai'`, senden Sie ihn mit `actor.type: 'ai'` und derselben `id`, demselben `name` und derselben `sessionId` weiter; die Editoren kennzeichnen ihn als Änderung der KI. Der Host des Playgrounds macht das so.

```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 });
});
```

Wenden Sie die Batches eines Templates nacheinander an: Zwei gleichzeitig angewendete würden dieselbe Revision lesen. Verwenden Sie dasselbe `applyOperations` wie der Editor, damit ein Batch, den der Editor für gültig hielt, auch auf Ihrem Server gültig ist.

### Sperren und Besitz

Eine Sperre gehört einer Sitzung und der Identität dahinter zugleich: der Nutzer-ID oder `guest:<sessionId>` für eine Sitzung ohne Nutzer. Vergleichen Sie beides, wie `ownerOf` oben, damit eine aus der Anfrage eines anderen Nutzers übernommene Sitzungs-ID nicht auf dessen Sperre wirken kann. Geben Sie Sperren eine kurze Laufzeit (Templatical Cloud verwendet 30 Sekunden; der Editor erneuert alle 10), und wenn eine Sitzung einen neuen Block nimmt, geben Sie den zuvor gehaltenen frei. Überlassen Sie die Sperrprüfung `applyOperations`: Übergeben Sie jede nicht abgelaufene Sperre mit ihrem Besitzer und den handelnden Besitzer als `session`.

### Aufholen

Behalten Sie die letzten Batches für `operationsAfter`. Wie viele, entscheiden Sie: Fragt ein Editor nach Revisionen, die Sie nicht mehr haben, antworten Sie mit `resync`, und er lädt das Template neu.

### Präsenz

Mitglieder sind Sitzungen, nicht Nutzer: Derselbe Nutzer in zwei Tabs sind zwei Mitglieder, und der Editor fasst sie zusammen. Geben Sie jedem Mitglied eine Farbe, die für dieselbe Person überall gleich bleibt, zum Beispiel aus einem Hash der Nutzer-ID.

## Ohne Echtzeit

Ein Backend ohne Echtzeitkanal kann trotzdem geordnet zusammenarbeiten: `join` antwortet mit der Revision und leeren `locks` und `members` und ruft nie einen Handler auf, und der Provider hat keine Sperr-Methoden. Jede Änderung geht weiter über `submit`, Ablehnungen funktionieren weiter, und Änderungen anderer erscheinen, wenn das Template neu geladen wird.

## Events

`init({ onRemoteChange })` wird mit `{ actor, ops, locale }` aufgerufen, nachdem der Batch einer anderen Sitzung auf dem Canvas angewendet wurde. Für die eigenen Änderungen dieses Editors wird es nicht aufgerufen, ebenso wenig für einen Resync, der das Template neu lädt.

`locale: string | null` meldet die Sprache der ersten Locale-Operation eines fremden Batches, ausgenommen `setDefaultLocale`, sonst `null`. Ein `setDefaultLocale`-Batch meldet `null`, da er die Standardsprache des Templates ändert.

## Im Playground

Das Setup **Collaboration** führt diesen Vertrag zwischen Browser-Tabs aus, über ein Backend, das in einem Shared Worker auf `applyOperations` aufbaut. Öffnen Sie es in zwei Tabs und hängen Sie an einen `?user=Ada` an, um ihn anzumelden.

[Im Playground öffnen](https://play.templatical.com/scenes/collaboration)
