agentleFS
sonichisonichi/sutando/skills/room-collab/SKILL.md

room-collab

Skill396 starsChanged 6 months agoMITView on GitHub

Read and write a room's LIVE collaborative surfaces — the document behind the Doc tab, the whiteboard, the kanban (Yjs/CRDT state, one surface per --kind). Use this when asked to write into, read, watch, or collaborate in any of a room's surfaces. NOT the same thing as `room_ops doc`, which is a room's Context-document folder — a different store entirely.

  • Reads credentials
  • Installs packages

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 2 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Every read remembers what you saw (per room and surface, under the workspace's state/room-collab/), so read --delta on your next visit …

    line 41
  2. Very specific:

    Use append to reply, not replace: your text lands where nobody else is typing, and the merge keeps everyone's characters.

    line 47
  3. Distinctive:

    Every surface has exactly the room's own ACL.

    line 75
  4. Very specific:

    Add --insecure only for a local rig with a self-signed certificate.

    line 101
  5. Distinctive:

    When something a person wrote is unclear, ask about it *there* instead of rewriting it or asking in the timeline where the words are out of …

    line 271

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Formerly room-doc. The name changed because the skill serves more than a document — markdown, whiteboard and kanban. The room-collab names lead (ROOM_COLLAB_TOKEN, AG2_ROOM_COLLAB_URL, /api/v1/room-collab); the room-doc spellings are still read and served for one release, and skills/room-doc/scripts/room_doc.py still runs (it forwards here).

Room Collab

A room's live collaborative surfaces: the document a person sees in the Doc tab and types into, the whiteboard, the kanban. Each is one CRDT state that several writers edit at once, merged change by change, with each other's presence visible. --kind names the surface; the document is the default.

Not to be confused with the Context-document folder

You wantUse
The live surfaces people co-edit (document, whiteboard, kanban, deck)this skill
A room's stored Context files (get/put/rm by name)room_ops context (formerly room_ops doc)

Two different stores. Writing to one never shows up in the other. This has already sent one agent to the wrong place, which is why the warning is here and not further down.

First contact — if you were @-mentioned and have never done this

P=skills/room-collab/scripts/room_collab.py
python3 $P presence '!room:server'                     # 0. which surfaces are live, who is in them
python3 $P read   '!room:server'                       # 1. the whole document — find the line that names you
python3 $P append '!room:server' $'\n\n@you — <your reply>'   # 2. answer UNDER it, signed
python3 $P read --delta '!room:server'                 # every later return: only what changed since you last read

Then say one line in the room ("replied in the doc") — the person who called you is watching the room, not the document. With the lane env loaded no flag is needed. If a step fails, doctor '!room:server' reports every setup step (deps, token, URL, connect, read, peers) one line each and names the one that broke; it is for that, not for reading.

Every read remembers what you saw (per room and surface, under the workspace's state/room-collab/), so read --delta on your next visit prints only the lines that appeared since — the way a person skims what is new before rereading. The first read of a surface is all new. --json carries delta and since alongside the usual fields.

Use append to reply, not replace: your text lands where nobody else is typing, and the merge keeps everyone's characters. replace is for editing a sentence you own.

Global flags go BEFORE the subcommand. --url, --kind, --name, --json belong to the program, not the command: room_collab.py --kind board read <room> works, room_collab.py read <room> --kind board is refused as "unrecognized arguments".

Requirements

pip install -r skills/room-collab/requirements.txt

Credential

The agent's ordinary relay token works. The one every agent already holds in channels/<lane>/.env as REMOTE_TASK_TOKEN (or AG2_REMOTE_TOKEN) opens a room's surfaces; the service resolves it to the agent's own Matrix id. No per-agent Matrix token and no extra grant are needed. A Matrix access token also works.

The relay token ships in two shapes, under the same variable names, on different installs: bare (secret) or compound (https://host/relay|secret). Both are accepted here — the value is inspected, never the name. Passed to anything else, the compound form must be split on |.

Every surface has exactly the room's own ACL. core-api distinguishes non-member (404) from below-write-power (403), while the WebSocket collapses every refusal into one close — from the client you can only see "refused".

Resolution order: --token, then $AG2_MATRIX_TOKEN, $ROOM_COLLAB_TOKEN ($ROOM_DOC_TOKEN still read), $MATRIX_ACCESS_TOKEN, $REMOTE_TASK_TOKEN, $AG2_REMOTE_TOKEN. The service URL comes from --url, then $AG2_ROOM_COLLAB_URL ($AG2_ROOM_DOC_URL still read), $AG2_API_ROOT, the origin of $REMOTE_TASK_URL, or the origin named inside a compound token. With the lane env loaded, an agent needs neither flag. The socket path defaults to /api/v1/room-collab; a URL that already names /api/v1/room-doc is kept as given while that alias is served.

Command line

P=skills/room-collab/scripts/room_collab.py
python3 $P read   '!room:server'                      # print the document
python3 $P read --delta '!room:server'                # only what is new since your last read
python3 $P peers  '!room:server'                      # who is present in THIS surface (opens it)
python3 $P presence '!room:server'                    # who is in EVERY surface, without opening any
python3 $P append '!room:server' 'text to add'        # add at the end
python3 $P replace '!room:server' 'old text' 'new'    # refuses if absent, never writes blindly
python3 $P comment '!room:server' 'the exact words' 'is this final?'   # a comment pinned to them
python3 $P --name mars read '!room:server'            # publish presence while connected

Add --insecure only for a local rig with a self-signed certificate.

Staying in a surface

A read or append connects, acts and leaves. To be in a surface the way a person is — told the moment something concerns you, with nobody pinging you in the room — hold it open:

python3 $P watch '!room:server'                                         # the text; hears your mxid, its localpart, --name
python3 $P watch '!room:server' --for 'Sutando (qingyun-001)'           # …plus the display name a summon writes for you
python3 $P --kind board  watch '!room:server' --for mars                # the whiteboard
python3 $P --kind kanban watch '!room:server' --for '@you:server'       # the board of cards
#   EVENT<TAB>mention<TAB>where=text<TAB>@mars can you take the second section?
#   EVENT<TAB>mention<TAB>where=board element=t7<TAB>ask @mars about this box
#   EVENT<TAB>assigned<TAB>where=kanban card=c3 column=todo<TAB>write the tests
#   EVENT<TAB>moved<TAB>where=kanban card=c3 from=todo to=doing<TAB>write the tests
#   EVENT<TAB>peer_joined<TAB>who=@qingyun:server name=qingyun

One line per event, after --settle seconds of quiet (default 1) — the server forwards one push per keystroke, and a person typing your name is a dozen pushes. It prints nothing until something concerns you and exits (rc 2, with the reason) only when the session ends: silence means "nothing yet", never "not watching". Run it under a monitor and act on each line; reply with append from another invocation, or from the library:

async with open_room_collab(url, room_id, token) as doc:
    async for ev in doc.events(["mars", "@you:server"]):   # every kind, one loop
        if ev["kind"] == "mention": ...                     # act, then doc.append(...)

A deploy closes every live connection (close 1012, measured: a held connection survived 25 minutes untouched and was ended only by a service restart). watch comes back on its own — RECONNECTING, then RECONNECTED — carrying its last snapshot, so a line that landed while it was down is still reported. A refusal (4403 and friends) is an answer about you and is not retried.

Your own writes are not reported. A bare name in prose ("for mars") is not a mention; the @ is what addresses you, and the summon always writes it. Not yet an event, because it needs the server's authorship record: someone editing a paragraph you wrote.

The whiteboard is a different surface

A room's board is a second surface — ?kind=board — and it holds a map of drawing elements, not text. The text commands refuse on it rather than answering: read on a board used to print an empty string, which is indistinguishable from an empty whiteboard, and append used to succeed while writing text no Excalidraw client ever reads.

python3 $P --kind board read  '!room:server'          # FIRST: what is already there, and where
python3 $P --kind board --json read '!room:server'    # …with x/y/width/height, to find free space
# A labelled box below whatever occupied y ≤ 400 — a box and its label are two elements:
python3 $P --kind board draw '!room:server' '[
  {"id":"w1","type":"rectangle","x":40,"y":460,"width":220,"height":80,"version":1},
  {"id":"w1t","type":"text","x":56,"y":488,"width":188,"height":24,"version":1,
   "text":"Worker 1","fontSize":20,"fontFamily":1,"textAlign":"left","verticalAlign":"top"}]'
python3 $P --kind board erase '!room:server' 'w1'     # marks isDeleted, the editor's own deletion
python3 $P --kind board peers '!room:server'          # presence is its own channel — works on any kind

Where a drawing lands. The board is usually not empty, and a drawing that lands on what is already there is unreadable together with it — two agents that both drew "at (0, 0)" produced two complete diagrams on top of each other. So draw looks first: if any element you send would overlap a live element someone else wrote, the whole batch is moved below the occupied space (only y changes, the batch keeps its shape). Coordinates that already sit in clear space are written exactly as given, and re-writing your own elements (same ids, higher version) never moves them. Pass --absolute when the coordinates are final and you mean to draw over something. read first if you want to choose the spot yourself. Use ids of your own (a prefix that is yours) — a write to an existing id is an edit of that element, not a new one.

An element needs id (equal to its key), a type the board draws, finite x/y/width/height/version. Everything else the editor reads — groupIds, stroke and fill, seed, boundElements — is filled in on write with the editor's own defaults, so a minimal element is selectable, not just drawn. A write lands only when it is newer (higher version, ties broken on versionNonce), the same rule the web client uses, so an agent and a person editing one board converge. Invalid elements are refused rather than written — the web client validates on read, so a bad one would be dropped by every viewer with no error anywhere.

Concurrent writes to one element are merged by Yjs on client id, which knows nothing about element versions — so the older version can win and a shape silently reverts. put_elements therefore arms an observer that re-asserts what this session wrote whenever a remote change lands on it; reconcile() is there for the rare case you want it by hand.

The kanban is the third surface

A room's board of cards — ?kind=kanban — holds two maps: columns and cards. When a person assigns you a card, the message you receive already says what to run; this is what it does:

python3 $P --kind kanban read   '!room:server'                       # every column, its cards, who holds them
python3 $P --kind kanban add    '!room:server' 'write the tests'      # into todo, unassigned
python3 $P --kind kanban add    '!room:server' 'review #12' --column doing --assign '@you:server'
python3 $P --kind kanban move   '!room:server' card-1a2b done          # done is a column, not a flag
python3 $P --kind kanban assign '!room:server' card-1a2b '@you:server' # '' for nobody
python3 $P --kind kanban erase  '!room:server' card-1a2b               # a tombstone, never a removal
python3 $P --kind kanban watch  '!room:server' --for '@you:server'     # assigned / moved / unassigned, as they happen

Taking a card is move … doing (or whatever the column is called — read shows the ids). Finishing it is move … done. A fresh board has no columns until someone opens it; add seeds the panel's own three (todo, doing, done) so both sides agree which is which.

What the panel enforces, silently: a card must carry all of id, column, order, text, assignee, updated, by — a card missing one is not refused, it is filtered out and never appears. assignee is an mxid (@x:server) or '' for nobody; a display name is stored but dispatches nobody. order and updated are integers. by is the writer's mxid and must be the same string on every write — the panel tie-breaks concurrent writes on it. This client refuses anything the panel would drop, and signs writes with --user-id or $AG2SPACE_USER_ID.

Two writers moving one card: the later updated wins, ties break on by, the same rule as the panel — so a move you make is a newer version, and an older one you re-send writes nothing. A card whose column no longer exists is shown by both sides under "no column", not lost.

Collaborating, rather than submitting

For anything beyond one edit, import the library and hold the connection:

from room_collab_client import open_room_collab

async with open_room_collab(url, room_id, token) as doc:
    await doc.set_presence("mars")       # otherwise you edit invisibly
    print(doc.text, doc.peers)
    await doc.append("...")
    await doc.replace("old sentence", "new sentence")

Three things that matter more than they look:

  1. Publish presence. Without it you are editing a document where nobody can see you — the person sharing it sees text appear from nowhere. Pass user_id (this agent's mxid) to get an avatar: the roster resolves faces by id, never by display name, so without it you appear by name with no face. Each append/insert/replace also places your caret at the write's end, so the editor draws where you last wrote, in your colour, like a person's.
  2. One connection per agent per surface. Each connection is a separate peer: open a new one per edit and you appear in the presence list several times, as several people. Hold the context manager open instead.
  3. Send deltas, not the document. append/insert/replace put only the change on the wire, which is why a human typing in the same paragraph loses nothing. Rewriting the whole text would be a last-writer-wins overwrite.

Who wrote what

python3 $P --with-authors read '!room:server'

Prints, above the text, which Yjs client id belongs to which account and whether it is a person or an agent (and whose agent). The document records this on the server as writes land; an agent cannot claim authorship, only read it (verified 2026-09-20). Use it to decide whether a paragraph is a human's to leave alone or another agent's to continue.

Commenting on a passage, rather than editing it

When something a person wrote is unclear, ask about it there instead of rewriting it or asking in the timeline where the words are out of sight:

python3 $P comment '!room:server' 'option A is cheap' 'cheap in money, or in time?' --mention '@qingyun:server'
python3 $P comment '!room:server' 'option A is cheap' '…' --nth 1     # the second occurrence
python3 $P comment '!room:server' 'option A is cheap' '…' --dry-run   # show the message, post nothing

The quote must be the exact words as they stand in the document (up to 2000 characters), and it must be unique — or say which occurrence with --nth (0 is the first). The command refuses rather than guessing. What it posts is an ordinary room message — > the quoted words, a blank line, your text — carrying the anchor a web client pins the comment to, so the person sees it beside the passage and anyone in a plain client still reads it as a sentence. --mention writes the mxid into the text, which is what makes it a real mention; an agent among them is called.

Posting goes through the agent-room-ops skill installed beside this one (room_ops.py say --extra-content); without it the command says so, and --dry-run gives you the exact message to post another way.

What a refusal means

The service accepts the socket and only then closes with a code, because a close before accept cannot carry one. So these arrive as a failure to open, not as an HTTP status:

CloseMeaning
4400The room id is malformed. Not "a room that exists and is empty". (verified 2026-09-20)
4404The surface kind is malformed. (verified 2026-09-20)
4403Refused or withdrawn: not authorized for documents, or membership/write power changed. It can also mean core-api was briefly unreachable, so one 4403 is not proof of revocation. (verified 2026-09-20)
HTTP 401 "bearer is not a valid Matrix user session"The service has no record of this agent's token — a provisioning gap on that deployment (the local rig, typically), not a room permission. A different problem from 4403; ask whoever runs that deployment. (verified 2026-09-20)

Each row carries the date it was last measured against the service, and tests/room-collab-skill-claims-expire.test.py fails once a row is older than 30 days: a sentence about what the service refuses is an observation with a shelf life, not a rule. Re-measure and move the date; do not delete the date.

A refusal is raised, never returned as an empty document — if it were, "this room does not exist" and "this document has no content" would look identical.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.