Skip to main content
Every file Arg can edit together has a room: the live channel its editor uses to show you who else is there and to merge everyone’s edits. An app can join that room through document.collaborate() and use it for its own multiplayer - players walking around a map, cursors on a canvas, a shared vote - without running a server. A room carries two things, and they behave differently:

Getting a room

A room belongs to a file, and an app reaches the room of its document. There are two ways to give an app one, and they decide who is in the room and what it can hold:

Presence for every launch

Every .app launcher (the file that gives an app its name and icon in Apps) is that app’s room. Open the launcher - from Apps, the activity rail or Files - and document.current() describes it, with editor: "app" and readOnly: true, and collaborate() joins everyone else who opened the same launcher. The app’s relative paths still resolve beside its own source, not beside the launcher. This room carries presence only. The launcher’s bytes are its manifest, so document.write is refused and session.update does nothing. An .html or .tsx file opened directly, rather than through a launcher, has no document.

A shared file for shared state

For state that everyone edits and that lasts, register the app for a file type in its launcher, then have people open a file of that type:
/apps/farm.app
Now team.farm offers Farm in its App switcher, and everyone who opens team.farm with it is in the same room. Any extension works, including one Arg has no editor for: such a file opens in the plain text editor, which has a room. A different file is a different room, so team.farm and practice.farm are separate games. Formats with no room of their own - a PDF, a .docx - reject collaborate() with unsupported_capability.

Joining

Wrap the import in a dynamic import("@arg-ai/sdk").catch(() => null) if the page should also run where Arg is not hosting it, such as a share link, and fall back to a single-player mode there. session.readOnly is true in a launcher’s room, and in a file’s room when this viewer may not write the file (a viewer grant, a read-only Workspace access grant, a locked file). Presence still works, and updates still arrive; session.update does nothing.

Presence

session.awareness is the room’s presence. Each viewer has one entry, keyed by a numeric clientId:
user is filled in by Arg for every signed-in viewer - name, a stable colour, avatar - so you do not need auth.principal() to label someone. Everything else is yours.
Four things to get right:
  • Your own entry is in states(). Skip the one whose clientId is session.awareness.clientId.
  • Not everyone in the room is running your app. Someone with the same file open in its regular editor has a user and nothing else. Key what you draw on your own field.
  • Your fields are merged into the viewer’s entry, never replacing it. setLocalState({ player }) sets player and leaves the rest; setLocalState(null) clears only the fields your app published. user is never written, so an app cannot remove a person from the file’s collaborator list. Use a field name specific to your app, so it cannot collide with the editor’s own cursor fields.
  • Presence is relayed to every viewer in the room. Send on change, and cap a moving value at around ten updates a second; interpolate between them on the receiving side. There is no need to re-send an unchanged state to keep it alive.
When a viewer closes the file, loses access or disconnects, their entry disappears and onChange fires without it.

Shared state

Updates are Yjs update bytes. The SDK has no dependency on yjs, so import your own copy and connect it once:
Apply initialState before any live update - it is the whole room as one update. Pass session as the origin when applying the room’s updates, so the update listener can tell them from your own; sending the room’s updates back to it duplicates content. Only the format’s own content is saved to the file. The room keeps every shared type it is given, but what reaches disk is what the file’s editor writes. For a format that opens in the text editor - any extension Arg does not know - that is the Y.Text named content, which holds the file’s text. Two rules follow:
  • Data that must survive belongs in the file. For a custom format, keep it as JSON in doc.getText("content") and update it through the text, or write it with document.write(json, { expectRevision }) and let the room pick up the change.
  • A shared type of your own, such as doc.getMap("farm"), is shared live but never written to the file, and is not guaranteed to survive the room reloading from the file - which happens after anything writes the file outside the room (an agent, a sync, fs.write). Use it for state that may reset, such as a lobby or a round in progress.
For a format Arg has its own editor for, the shared types belong to that editor - a kanban board’s lanes, a document’s text. Read that editor’s shape rather than inventing a new one, or your writes will not appear in it and will not be saved.

Leaving

onClose fires when the room goes away under the app: room_closed (the file was closed), room_replaced (the editor reconnected into a new room - call collaborate() again) or document_changed (the app was moved to a different file). close() withdraws your presence fields and stops the updates.

Where it works

Web and the desktop app support rooms in .html, .tsx and .jsx apps. The iOS and Android apps do not yet: collaborate() reports unsupported_capability there, so keep the solo fallback. A published Site has no document and no room. Everything in document is listed in the SDK reference; reading and writing the document without a room is covered in Apps inside Arg.