Command Palette

Search for a command to run...

UnylyUnyly
Browse all

Agentic Realtime

FreeMaintained

Live edits for people and agents on one document, with offline support.

GitHubEmbed

About

Live edits for people and agents on one document, with offline support.

README

npm version MIT license

Package · Source · Support

Live edits for people and agents on one document, with offline support.

Status: 0.6.0 preview. Most features below have an end-to-end test. Hibernation, the no-outbound-calls rule, and EU placement do not.

Install

npm install agentic-realtime

Features

  • A sync server on Cloudflare Durable Objects. One object holds one document.
  • The standard Yjs protocol (y-websocket), so a stock Yjs client can connect.
  • Presence relay: peers see each other's awareness state. The server sets the kind of each state from the grant, and the name when the grant has one. A client cannot change either. The first socket that announces a client id owns it. Only that socket, or a new socket of the same principal, can update or remove it.
  • A headless Node client (agentic-realtime/client) so an agent can join a document.
  • Browser offline support (agentic-realtime/browser): openDocument keeps an IndexedDB copy, reconnects with backoff, and merges offline edits. onPeers and peerLabel help any UI show peers, with agents marked as machine-made.
  • Version history: client.snapshot(name), snapshots(), and restore(name). A restore is a new edit by you. No stored update or snapshot changes.
  • A CLI and an MCP server, so an agent can join and edit with no code.
  • Storage in the object. Updates append to a log. Every 50 updates (set SYNC_COMPACT_EVERY, from 1 to 128) the log folds into one snapshot.
  • WebSocket hibernation. An idle document uses no compute.
  • Roles. Owners and editors write. Commenters and viewers only read. The server drops their updates and answers with a permission-denied message.
  • Author labels. Each update keeps the kind of principal that wrote it. The API lists the contributors and marks the machine-made ones.
  • Who wrote what. A log keeps which principal sent which content, also after compaction and inside each snapshot. blame shows it as runs of text, with agents marked as machine-made.
  • Delete and export. The owner or the host can export the full stored history of a document, and delete all of its data.
  • Live revocation. The host can revoke a principal or cap its role while it is connected. A timer asks the host again who each socket is.
  • Data residency. Set SYNC_JURISDICTION=eu to pin documents to the EU.
  • No outbound calls. The server loads no CDN assets and sends no telemetry.

Live demo

A demo Worker runs on workers.dev at https://agentic-sync-demo.hello-fd1.workers.dev. GET /health answers 200. Every document and admin route needs a bearer token, so the demo shows the sign-in rule and holds no public document. It runs examples/host with documents pinned to the EU.

To run your own copy, or to redeploy this one, you need a Cloudflare account and a token that may edit Workers. Run in the repository root:

export CLOUDFLARE_API_TOKEN=...   # keep it out of git and out of CI
npm ci
npm run deploy:demo

Then set the two secrets. HOST_TOKENS is described in the example host. Until you set both, every route except /health answers 500.

npx wrangler secret put HOST_TOKENS --config examples/host/wrangler.demo.jsonc
npx wrangler secret put SYNC_ADMIN_SECRET --config examples/host/wrangler.demo.jsonc

The deploy is a manual step. No CI job holds a secret. The config turns wrangler telemetry off.

Use the Worker

You own sign-in. Pass an authorize hook. It returns a grant, or null to refuse.

import { createSyncWorker } from "agentic-realtime/worker";
export { SyncRoom } from "agentic-realtime/worker";

export default createSyncWorker({
  authorize: async (request, docId) => {
    const user = await mySession(request); // your code
    if (!user) return null;
    return {
      principal: { id: user.id, kind: "human" },
      role: await myRoleFor(user, docId), // owner, editor, commenter, viewer
      name: user.displayName, // optional. Shown in presence in place of the client's name. Use the safe set for names.
    };
  },
});

authorize also gets the Worker env as its third argument, for the secrets of your sign-in. Copy wrangler.jsonc for the Durable Object binding and the migration. The default Worker refuses every document. To see a working host, run examples/host. It signs callers in with bearer tokens.

Route Use
GET /api/v1/docs/:id/ws Upgrade to a WebSocket. Speaks the Yjs protocol.
GET /api/v1/docs/:id Read the role, jurisdiction, update count, compaction count, and contributors.
GET/POST /api/v1/docs/:id/snapshots List snapshots, or save one (editors only).
GET /api/v1/docs/:id/snapshots/:name Read a snapshot as a Yjs update.
GET /api/v1/docs/:id/attribution Read who sent which content. Any role may read it.
GET /api/v1/docs/:id/snapshots/:name/attribution The same log, as of that snapshot.
GET /api/v1/docs/:id/export Stream everything stored for the document as JSON lines. Owners only.
DELETE /api/v1/docs/:id Delete all data of the document. Owners only. The body is {"confirm": "<id>"}.
GET /api/v1/admin/docs/:id/export, DELETE /api/v1/admin/docs/:id The same two calls for the host, with the host secret.
GET /health Check that the Worker runs.

A document id matches [A-Za-z0-9_-]{1,128}. Ids are case sensitive.

An agent kind is one of agent_of_human, agent_of_agent, agent, or user_token. Only human is not machine-made.

Revoke access while people are connected

A grant is checked when a socket opens. Two paths change it after that.

Host calls. Set SYNC_ADMIN_SECRET (at least 32 bytes) as a Worker secret. The host then calls the admin routes with Authorization: Bearer <secret>. Keep the secret in your backend. Never give it to an agent.

import { revokePrincipal, setPrincipalRole } from "agentic-realtime/sdk";

const host = { server: "https://sync.example.com", secret: env.SYNC_ADMIN_SECRET };
await setPrincipalRole(host, "notes", "person-1", "viewer"); // read-only at once
await revokePrincipal(host, "notes", "person-1"); // closes every socket, code 4003
await setPrincipalRole(host, "notes", "person-1", null); // lifts the cap or the revoke

A cap only narrows. The principal gets the narrower of the grant and the cap. A revoked principal gets 403 ACCESS_REVOKED on every route, also for snapshots, until the host lifts the revoke. The cap lives in the document's storage. A cap is keyed by principal id, so keep ids unique across kinds. A principal id matches the agentic-identity rule: 1 to 128 ASCII characters, first an ASCII letter, digit, _ or -, then ASCII letters, digits, ., _ or -. One document holds up to 1000 role caps and up to 10000 bans, counted apart, so a full table of role caps never stops a revoke. Past the ban limit, a revoke still closes the sockets, but it answers 409 CAP_LIMIT because no ban is stored. A cap of owner narrows nothing, so the server drops it. That also lifts a revoke, the same as null. Without a valid secret, the admin routes answer 503 ADMIN_DISABLED. A Worker of your own can call the same methods through the Realtime port, or by Durable Object RPC on SyncRoom.

Timer. Export a room class that knows how to ask your host:

import { createSyncRoom } from "agentic-realtime/worker";

export const SyncRoom = createSyncRoom({
  // The principal's current role, or null for no access.
  reauthorize: async (principal, docId) => myRoleFor(principal.id, docId),
});

Every 60 seconds (set SYNC_REAUTH_INTERVAL_SECONDS, 1 to 3600) the room asks once for each connected principal, up to 20 calls at a time, each with a 5 second limit. The new role can only narrow the role granted at connect, so the timer never widens access. It applies to all sockets of that principal. A null, a throw, a timeout, or an unknown role closes them with code 4003. A host outage therefore ends the sessions. It does not keep them open. A host that answers slower than 5 seconds has the same effect, and every client then reconnects at once, so size your host for that.

Join a document from Node

import { SyncClient } from "agentic-realtime/client";

const client = await SyncClient.connect({
  url: "wss://sync.example.com/api/v1/docs/notes/ws",
  headers: { authorization: `Bearer ${token}` },
  presence: { name: "Summary bot" },
});
await client.edit((doc) => doc.getText("body").insert(0, "Hello"));
console.log(client.peers); // the other people and agents
await client.disconnect();

connect resolves when the document matches the server. edit and flush resolve when the server has handled your changes. If your role is read-only, the server drops the edit and client.denied holds its reason.

Your presence is { name, color, cursor }. The default color comes from your name and has at least 3:1 contrast on a white page and on a dark page (#121212). peerColor(name, "light" | "dark") gives a color with 4.5:1 text contrast on one page. No single color has 4.5:1 on both. client.peers keeps a peer's color only if it is #rrggbb with 3:1 on both pages. Otherwise it uses the peer's default. You do not set a kind. The server adds the kind of your grant to your presence, so people always see an agent as an agent. Each peer in client.peers carries that kind, and machine is true for every kind except a person. A name has 1 to 40 characters. It starts with a letter or a digit. It has only letters, digits, marks, single spaces, and ’ . _ ' ( ) -. It has no leading, trailing, or double space, and no run of more than 3 combining marks. It has no bidi controls, zero-width characters, or other invisible characters. The client throws for any other name (it first converts a name to NFC). The server drops an unsafe name from the presence it relays, and peers show Unknown. The socket stays open. The set does not stop look-alike letters from other scripts. Show a name in an isolated run (<bdi> or dir="auto"), because right-to-left letters are allowed. Check a name with isSafePeerName(name.normalize("NFC")). Use any Yjs document type on client.doc.

Pass away: { idleAfterSeconds: 120 } with presence to tell peers if you are active, idle, or hidden. Call client.markActive() on input and client.setHidden(true | false) when the page hides or shows. In a browser, watchPageAway(client) does both, and openDocument calls it when you pass away. A hidden page reads as hidden, not idle. A peer that reports no state, such as an agent, has away: "unknown" in client.peers and in the SDK's listPeers. The server drops a state that is not one of the three. The client needs Node 22 or later. It reconnects only if you pass reconnect: {}.

Use the CLI

export AGENTIC_SYNC_URL=https://sync.example.com
export AGENTIC_SYNC_TOKEN=...   # sent as a bearer token
agentic-realtime doc apply --doc notes --root body --text "Hello"
agentic-realtime doc get --doc notes
agentic-realtime doc export --doc notes  # owners only; JSON array
agentic-realtime doc delete --doc notes --confirm notes  # permanent
# Host operator environment: set AGENTIC_SYNC_ADMIN_SECRET.
agentic-realtime admin export --doc notes
agentic-realtime admin role --doc notes --principal person-1 --role viewer
agentic-realtime admin revoke --doc notes --principal person-1
agentic-realtime admin delete --doc notes --confirm notes
agentic-realtime doc snapshot --doc notes --name before-review
agentic-realtime doc restore --doc notes --name before-review
agentic-realtime doc presence --doc notes
agentic-realtime doc blame --doc notes --root body

Commands print JSON. Most commands join the document, do one job, and leave. Export, delete, and host commands use HTTP without joining. doc snapshot without --name lists the snapshots. A refused edit exits with 1 and prints the reason. --name sets the name you show in presence, except for snapshot and restore, where it is the snapshot name. The server takes your role, your kind, and your author label from the token. The CLI has no --kind flag. doc apply edits text roots and empty roots only. It refuses a rich-text, map, or array root and exits with 1, so it never deletes a document.

Use the MCP server

agentic-realtime mcp serves these tools over stdio: doc_get, doc_export, doc_delete, doc_apply, doc_snapshot, doc_snapshots, doc_restore, doc_blame, and doc_presence. Set AGENTIC_SYNC_URL, AGENTIC_SYNC_TOKEN, and optionally AGENTIC_SYNC_NAME in the server environment. A tool call never carries a credential. doc_apply returns an error for a root that is not text. The same operations are in agentic-realtime/sdk.

Export and delete a document

An owner, or the host with the host secret, can take a copy of a document and delete it.

import { deleteDocument, exportDocument } from "agentic-realtime/sdk";

const records = await exportDocument({ server, token: ownerToken }, "notes");
await deleteDocument({ server, token: ownerToken }, "notes"); // cannot be undone

exportDocumentAsHost and deleteDocumentAsHost take { server, secret } instead. exportDocument returns every record at once, so it holds the whole export in memory. For a large history, use exportDocumentStream or exportDocumentStreamAsHost. They yield one record at a time, as the response arrives. The request starts when you ask for the first record. A refusal is thrown then, not when you call the function:

import { exportDocumentStream } from "agentic-realtime/sdk";

for await (const record of exportDocumentStream({ server, token: ownerToken }, "notes")) {
  // handle one record, then go on to the next
}

An export is JSON lines, one record each, sent as the room reads its storage:

type Holds
meta format (agentic-realtime-export), version, exportedAt, compactions, and the base.
state base64: the whole document as one Yjs update.
roots Each root type as plain JSON.
authors Everyone who wrote, with a machine label.
caps The host's caps and bans: principalId and cap, a role or revoked. It can name principals who never wrote, and owners can read it.
attribution The log of who sent which content.
update One update that compaction has not folded yet: seq, at, author, base64.
snapshot A named snapshot: name, at, bytes, author, base64, and its attribution. base64 and attribution are null when the stored state or log is missing.

A delete closes every socket with code 4004. It removes the updates, the base, the snapshots, the attribution logs, the caps and bans, and the timer. The document id then starts empty, and a new writer starts a new history. A delete needs confirm with the document id in its body, so a stray call does nothing.

The CLI doc export --doc <id> and MCP doc_export use the owner token from AGENTIC_SYNC_TOKEN. Both return a JSON array and hold the full export in memory. Other roles get OWNER_ONLY. The MCP tool has a read-only hint.

SyncClient has exportDocument() and deleteDocument() for owners. They use the document URL and credentials from connect or open. Call flush() before export to include local edits. Export does not join or write to the document. The host backend can call revokePrincipal(secret, principalId) and setPrincipalRole(secret, principalId, role). Pass null to lift a cap or ban. The secret is passed per call and is not stored on the client.

Owners can run doc delete --doc notes --confirm notes or call MCP doc_delete with { "doc": "notes", "confirm": "notes" }. A mismatch fails before the request. The Worker checks the effective owner role for both export and delete.

Host operators set AGENTIC_SYNC_ADMIN_SECRET in the CLI environment. Then use:

agentic-realtime admin export --doc notes
agentic-realtime admin role --doc notes --principal person-1 --role viewer
agentic-realtime admin revoke --doc notes --principal person-1
agentic-realtime admin role --doc notes --principal person-1 --role null
agentic-realtime admin delete --doc notes --confirm notes

Each command prints JSON. Each subcommand accepts --help. Secrets have no CLI flag. For MCP, the operator must also set AGENTIC_SYNC_ENABLE_ADMIN=1. This enables admin_export, admin_delete, admin_revoke, and admin_role. Tool arguments use doc, principalId, role (a known role or null), and confirm for delete. The schemas check principal ids and roles. The Worker checks host authority. An invalid or missing host secret fails closed. Normal MCP sessions omit these tools. Run this mode only in a trusted host operator session. An agent must not hold the host secret. Tool calls never carry credentials.

The SDK has typed bound clients. createDocumentClient({ server, token }) has exportDocument, exportDocumentStream, and deleteDocument. createAdminClient({ server, secret }) has the same methods plus revokePrincipal(docId, principalId) and setPrincipalRole(docId, principalId, role). These clients call the existing route functions. They add no authorization layer.

See who wrote what

Yjs names each piece of content by a client id and a clock. The room keeps a log of ranges: which principal (id and kind) sent each run of those names. It folds contiguous writes of one client into one range. It saves the log with every compaction, and each named snapshot keeps a copy.

import { blame } from "agentic-realtime/sdk";

const spans = await blame({ server, token }, "notes", "body");
// [{ text: "Hi ", author: { id: "p1", kind: "human", machine: false } },
//  { text: "bot", author: { id: "a1", kind: "agent_of_human", machine: true } }]
await blame({ server, token }, "notes", "body", "before-review"); // as of a snapshot

author is null for text that no range names. getAttribution returns the raw log: authors, ranges, dropped, and lost. The machine label comes from the kind, never from storage.

Work offline in the browser

import { openDocument, peerLabel } from "agentic-realtime/browser";

const { client } = await openDocument({
  url: "wss://sync.example.com/api/v1/docs/notes/ws?ticket=...",
  storageName: "notes",
  presence: { name: "Ana" },
});
client.onPeers((peers) => render(peers.map(peerLabel)));

openDocument loads the IndexedDB copy first. It then connects and reconnects after a drop, with delays that double from 500 ms up to 30 s. onPeers works in any UI framework. peerLabel names an agent as machine-made.

Protocol

The WebSocket carries the y-websocket messages: sync (type 0), awareness (1), auth (2), and query awareness (3). A read-only role gets an auth permission-denied reply, and the server drops its update. The server answers in order, so its reply to a new sync request proves it has all earlier updates. flush relies on that. A restore is a normal update.

Swap the server

The Worker talks to the server through the Realtime interface. It has eleven methods: connect, meta, createSnapshot, listSnapshots, getSnapshot, revoke, updateRole, roleFor, erase, exportHistory, and attribution. The Cloudflare adapter implements it with Durable Objects. Another server, such as a Node Yjs server, could implement it. See the changelog for what 0.2.0 added. The wire protocol helpers in agentic-realtime (readMessage, AwarenessRelay) are plain TypeScript with no Cloudflare code.

Run on Firebase

RoomStorage is a small key/value port: get, list by prefix, put, putAll, delete, and deleteAll. It matches the way the room uses Durable Object storage. The package ships an in-memory adapter and agentic-realtime/firestore. The Firestore adapter stores values of the same shape as the room writes: updates, snapshots, authorship logs, and role caps and bans. The room does not use the port yet, so nothing runs on Firebase today.

import { createFirestoreStorage } from "agentic-realtime/firestore";
import { exportStorage } from "agentic-realtime";

const storage = createFirestoreStorage({
  projectId: "my-project",
  docId: "doc-1",
  fetch: globalThis.fetch,
  token: () => getAccessToken(), // your service account flow
});
await storage.put("cap:principal-1", "revoked");
const lines = exportStorage(storage); // a stream of JSON lines
await storage.deleteAll(); // erase the document

The adapter uses the Firestore REST API with fetch only. It has no Firebase SDK and no secret. You pass the fetch and a token, or a function that returns one. Leave the token out and set baseUrl to http://<FIRESTORE_EMULATOR_HOST> for the emulator. An entry lives at agentic_realtime/<doc>/kv/<key>, where <doc> and <key> are base64url text. So the adapter keeps the case of the document id and of every key. It refuses an empty key and a key that is not well-formed text.

How it is tested: one contract suite, runStorageContract, runs against the in-memory adapter and the Firestore adapter. By default, the Firestore run uses a fake of the REST calls in src/firestore/fake.ts. It accepts only the real URL shapes. It follows Firestore for byte order, query cursors, atomic commits, and the string-value and id size limits. It checks string values only, not field names or the document name, and it does not model the 10 MiB request cap.

With FIRESTORE_EMULATOR_HOST set, the same suite runs against the real Firestore emulator. The emulator run passed on the Firestore emulator 1.19.8. Start it with firebase emulators:start --only firestore, or run the emulator jar with Java. CI uses the fake, because it has no Java.

This port moves the storage only. The room still reads and writes Durable Object storage directly, and the WebSocket server still runs in a Durable Object. A Node server on Firebase must bring its own socket layer.

Limits of the adapter:

  • A key is up to 1000 bytes of well-formed text. It is never empty.
  • One value stays under 1 MB as JSON text. Bytes grow by a third as base64, so binary data stays under about 750 KB. A larger snapshot or authorship log, which the room allows, does not fit.
  • One putAll takes up to 500 entries. This is the adapter's own limit. Firestore caps a commit at 10 MiB.
  • Update keys count up, and Firestore indexes key. Firestore warns that ids or indexed values that rise in sequence can limit the write rate of a collection. A single-field index exemption for value saves index cost.

Exports

Each entry point lists its exports below. A name marked (type) exists only at compile time.

agentic-realtime

Name What it is
VERSION The package version.
PRINCIPAL_KINDS, PrincipalKind (type) The principal kinds: human, agent_of_human, agent_of_agent, agent, user_token.
ROLES, Role (type) The roles, from most to least rights: owner, editor, commenter, viewer.
Principal (type), Grant (type), Authorize (type), Contributor (type), DocMeta (type), SnapshotInfo (type), Realtime (type) Types. Grant is what authorize returns. Realtime is the port that a server implements.
DOC_ID_PATTERN, SNAPSHOT_NAME_PATTERN, MAX_SNAPSHOTS The rules for a document id, a snapshot name, and the 50-snapshot limit.
canWrite(role) True for owner and editor.
isMachineKind(kind) True for every kind except human.
assertGrant(value) Checks a value from authorize and returns it as a Grant. Throws when it is malformed.
MESSAGE_SYNC, MESSAGE_AWARENESS, MESSAGE_AUTH, MESSAGE_QUERY_AWARENESS The message types 0 to 3 of the wire protocol.
syncStep1Message, updateMessage, awarenessMessage, permissionDeniedMessage Build one wire message as bytes.
readMessage, Incoming (type) Read one client message against a Y.Doc. It returns the reply, a denial, an awareness update, or a query. It applies a writer's update to the doc, unless refuse names a reason. It throws on a malformed message.
AwarenessRelay, AwarenessEntry (type) Keeps the presence state of a document and relays changes. It enforces the presence limits.
encodeAwarenessUpdate, decodeAwarenessUpdate Convert presence entries to and from the wire format.
AWAY_STATES, AwayState (type), isAwayState, readAwayState The states active, idle and hidden. readAwayState returns unknown for any other value.
AwayTracker, AwayOptions (type) Tracks activity and sets idle after idleAfterSeconds.
LIGHT_BACKGROUND, DARK_BACKGROUND, TEXT_CONTRAST, GRAPHIC_CONTRAST The two page colors, #ffffff and #121212, and the contrast minimums: 4.5 for text and 3 for graphics.
contrastRatio(a, b), isHexColor(value), peerColor(key, mode?), safePeerColor(color, key), ColorMode (type) Color helpers. mode is light, dark or both. safePeerColor keeps a #rrggbb color that passes on both pages. Otherwise it uses peerColor.
MAX_NAME_LENGTH, FALLBACK_NAME, isSafePeerName(name), assertSafePeerName(name), displayPeerName(name) The rules for a presence name. displayPeerName returns Unknown for an unsafe name. assertSafePeerName throws for one.
restoreState(doc, state, origin?) Restores a state into a live document as a new edit.
AttributionLog, AttributionRange (type), Attribution (type), BlameSpan (type), blameText, DEFAULT_MAX_RANGES The authorship log and its types. blameText splits a text root into runs with their author, as blame does. DEFAULT_MAX_RANGES is 4000.
createMemoryStorage(), exportStorage(storage), RoomStorage (type) An in-memory RoomStorage for tests. exportStorage streams the storage as JSON lines. RoomStorage is the key/value port.

agentic-realtime/sdk

Host and agent calls over HTTP. See the sections above for the flows.

Name What it is
createDocumentClient, createAdminClient Typed bound owner and host clients.
Target (type), AdminTarget (type) Types. Target is { server, token?, name?, headers? }. Export and delete forward headers; token takes precedence. AdminTarget is { server, secret }.
EDIT_MODES, EditMode (type) The text edit modes: append, replace, insert.
withDoc(target, docId, job) Joins a document, runs job with the connected client, and always leaves. getDoc, applyText, the snapshot calls, blame without a snapshot and listPeers use it.
getDoc(target, docId) Reads the roots of a document as plain JSON.
applyText(target, docId, edit) Writes text into a text root. edit has root, text, and optional mode and at. It refuses a root that is not text.
createSnapshot, listSnapshots, restoreSnapshot Save a named snapshot, list them, and restore one.
getAttribution(target, docId, snapshot?) Returns the authorship log, now or as of a snapshot.
blame(target, docId, root, snapshot?) Returns the runs of a text root with their author.
listPeers(target, docId, waitMs?) Joins the document, waits for the others to announce, and returns the peers.
exportDocumentStream, exportDocument, exportDocumentStreamAsHost, exportDocumentAsHost, ExportRecord (type) Export the stored history. ExportRecord is one JSON line. The Stream forms yield one record at a time.
deleteDocument, deleteDocumentAsHost Delete all data of a document. This cannot be undone.
revokePrincipal(target, docId, principalId), setPrincipalRole(target, docId, principalId, role) Host calls that close, or cap, the access of a principal.

agentic-realtime/client

Name What it is
SyncClient The headless client. SyncClient.connect(options) resolves when the document matches the server. SyncClient.open(options) returns at once. With reconnect, it keeps trying while offline.
ConnectOptions (type), Presence (type), Peer (type), Status (type) Types. Status is connecting, connected or disconnected.

agentic-realtime/browser

Name What it is
openDocument(options) Opens a document with an IndexedDB copy, connects, and reconnects.
peerLabel(peer) A label for a peer. It marks every machine-made peer, which is every kind except human.
watchPageAway(client, page?) Feeds page visibility and input into the away tracker. It returns a function that stops the watch.
OpenedDocument (type), PageLike (type) Types. OpenedDocument holds the client, the persistence, and stopWatching.

agentic-realtime/worker

Name What it is
createSyncWorker(options) Makes the Worker. Pass authorize. The default export refuses every document.
SyncEnv (type), SyncWorkerOptions (type) Types for the Worker bindings and options.
SyncRoom, createSyncRoom({ reauthorize }) The Durable Object class. createSyncRoom adds the re-authorization timer.
createDurableObjectRealtime(namespace, jurisdiction?) The Realtime port on Durable Objects.
Reauthorize (type), Jurisdiction (type) Types. Jurisdiction is eu or fedramp.
ROLES, assertGrant, Authorize (type), Grant (type), Principal (type), Role (type) The same names as the main entry, so a Worker can import them from one place.

agentic-realtime/firestore

Name What it is
createFirestoreStorage(options) A RoomStorage on Firestore over REST. See Run on Firebase.
FirestoreStorageOptions (type), FirestoreError The options type, and the error that the adapter throws.
MAX_VALUE_BYTES The limit of one stored value as JSON text, 1,000,000 bytes. Bytes go in as base64, so they take about a third more.
prefixEnd(prefix) The first string after every string with that prefix, or null when there is none.

Limits

  • One snapshot or update must stay under 2 MB (Durable Object storage limit). The server closes a connection that sends a message over 1 MiB. It refuses an update that would take the document past 1.5 MiB, and answers with a permission-denied message.
  • Presence limits: one socket owns up to 8 client ids, all sockets of one principal together own up to 16, and one document holds up to 256 presence entries. One state is up to 8192 characters. A socket that breaks a limit, sends a state that is not a JSON object, or uses an id that belongs to another principal gets closed with code 1008.
  • A full document refuses new presence entries. The socket stays open and document sync keeps working. The server sends a permission-denied message with the reason presence is full. Entries that already exist keep updating.
  • A new socket of the same principal can take over a client id. The server closes the old socket with code 4001. This lets a client reconnect before the server notices that the old connection is dead.
  • A jurisdiction is part of the object id. Set it before the first write. Changing it later points at an empty document.
  • A snapshot is a full copy of the state. A document keeps at most 50.
  • Restore replaces each root type (text, map, array, XML) and keeps text formatting. The CLI and MCP tools edit text roots. Use the client for others.
  • The IndexedDB tests run on fake-indexeddb in Node, not in a real browser.
  • Presence lives in memory. After a hibernation the server forgets it until the clients send their next heartbeat, within about 15 seconds.
  • The attribution log keeps up to 4000 ranges (set SYNC_ATTRIBUTION_MAX_RANGES, 4 to 5000), and also stays under about 1.5 MB with the ids of its authors. Each new client id starts a range, so a client that reconnects often uses them fast. Past the limit the oldest ranges go, dropped counts them, and their text shows as unattributed. The log is not a signed record.
  • A restore matches text by item in the Yjs history, never by letter. A piece that the live text and the snapshot both hold keeps its author, and a restore that only removes formatting changes no author. Text that a restore brings back is new text by the person who restored it. An undo that brings back deleted text does the same.
  • Yjs holds an update that needs missing content, and applies it inside the next update. The room refuses such an update and closes the socket with code 1008. It also refuses an update that writes under the client id of another principal. This check forgets a client id once the size limit drops its ranges. A client that keeps its Y.Doc and signs in as another principal writes under the old client id, so the room refuses it. Make a new Y.Doc when the principal changes.
  • lost: true in the log means the saved log was damaged. The document still loads. Text from before the damage has no author.
  • The log names who sent an insert. It does not name who deleted. It covers text roots. Content that a document held before this version has no range until someone rewrites it. A snapshot made before this version has no log, and its attribution answers 404.
  • The author of a range is the principal that sent the update. A principal can send a range of ids that nobody has used yet. The first range recorded for an id wins, so a later sender cannot take over text that is already there.
  • A delete removes the room's storage. It cannot remove copies that other parties hold: an export, a snapshot a client downloaded, the offline copy in a browser, your backups, and your host's logs. A client with an offline copy writes it back when it reconnects, so make authorize refuse a deleted document, or revoke its principals. Record a delete in your own audit log.
  • An export is not a point in time. A write during the stream may or may not be in it. Compaction has folded older updates into the state, so an export lists only the updates since the last compaction. The snapshots and the attribution log keep who wrote what. An export holds the content in clear text, so treat the file as personal data.
  • Revocation by the host call is immediate. Revocation by the timer takes up to one interval. Without createSyncRoom, there is no timer. A room with open sockets from before you deployed createSyncRoom starts its timer at the next connection.
  • A client with reconnect keeps retrying after code 4003 and gets 403 each time, with growing delays. Stop it in your UI when you revoke.
  • The local wrangler dev cannot place objects in a jurisdiction. Test placement on a deployed Worker.
  • If you sign in with cookies, check the Origin header in authorize.

Develop

Run these on a machine you trust. The end-to-end test starts wrangler dev.

npm ci
npm run ci:local

License

MIT.

from github.com/pooriaarab/agentic-sync

Install Agentic Realtime in Claude Desktop, Claude Code & Cursor

Recommended · one command, every IDE
unyly install agentic-realtime

Installs into Claude Desktop, Claude Code, Cursor & VS Code — handles npx, uvx and build-from-source repos for you.

First time? Get the CLI: curl -fsSL https://unyly.org/install | sh

Or configure manually

Run in your terminal:

claude mcp add agentic-realtime --env AGENTIC_SYNC_TOKEN="" --env AGENTIC_SYNC_URL="" --env CLOUDFLARE_API_TOKEN="" -- npx -y agentic-realtime

Step-by-step: how to install Agentic Realtime

FAQ

Is Agentic Realtime MCP free?

Yes, Agentic Realtime MCP is free — one-click install via Unyly at no cost.

Does Agentic Realtime need an API key?

Yes, it requires environment variables: AGENTIC_SYNC_TOKEN, AGENTIC_SYNC_URL, CLOUDFLARE_API_TOKEN. Unyly injects them into the config during install.

Is Agentic Realtime hosted or self-hosted?

Self-hosted: the server runs locally on your machine via the install command above.

How do I install Agentic Realtime in Claude Desktop, Claude Code or Cursor?

Open Agentic Realtime on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.

Changes

Versions and requested access over time.

  • New version published

Related MCPs

Compare Agentic Realtime with

Not sure what to pick?

Find your stack in 60 seconds

Author?

Embed badge for your README

Browse similar

All development MCPs