Collaboration¶
Two ways to work together. Pick by whether your team wants to run a server.
| Live | Git | |
|---|---|---|
| Needs a server | yes, one you run | no |
| See edits as they happen | yes | no |
| See other people's cursors | yes | no |
| Works offline | queues your edits, and catches up on theirs | yes |
| Setup | one service, one shared secret, a token each | none |
OntoBoard hosts nothing. There is no ontoboard.org to sign in to, no account we hold, and no server we operate. Live collaboration means a server your team runs — on a lab machine, a VM, wherever your ontology already lives. That is a deliberate choice: ontologies under development are often unpublished, and a research group should not have to send theirs through somebody else's service to work on them together.
If nobody wants to run one, git mode is not a downgraded fallback. It is how OBO ontologies are actually built, and the ODK repository the plugin scaffolds is already set up for it.
Git mode¶
Nothing to configure. Leave the collaboration fields empty and work as you would on any shared repository: edit, commit, push, pull, resolve conflicts in the diff.
What you lose is only the live part — you will not see a colleague's cursor, and their edits arrive when you pull rather than as they type. Everything else is unchanged, including the canvas, the sidecar layout file and the ODK pipeline.
The sidecar (<ontology>.ontoboard.json) is designed for this: layout lives beside the
ontology, not inside it, so a merge conflict in a diagram never touches an axiom. Commit it if
you want to share arrangements, or add it to .gitignore if diagrams are personal. Either
works.
Live mode¶
1. Run the server¶
The collaboration service is in collab/. It is a small Node server and needs
nothing but Node to run:
cd collab
npm install
export SECRET_KEY="$(openssl rand -hex 32)" # do this once, keep it
BRIDGE_PORT=1235 node server.mjs
One person runs it - on a laptop for a working session, or on a host the team can reach - and everyone points their plugin at it. It is the only part of OntoBoard that is not inside Protégé.
It opens two ports, and the difference matters:
| Port | Speaks | Used by |
|---|---|---|
| 1234 | Yjs / Hocuspocus (binary CRDT) | a browser client, if you write one |
| 1235 | JSON over WebSocket | the Protégé plugin |
Both serve the same documents — server.mjs attaches the bridge to
Hocuspocus through openDirectConnection, so a Protégé user and a browser user are in one
session rather than two that happen to look alike. Point the plugin at 1235. Pointing it at
1234 fails in a confusing way, because a Yjs endpoint accepts the connection and then never
understands a word - the plugin would sit on "Connecting…" indefinitely with nothing in any log to
explain it. So the plugin refuses a ws://…:1234 address outright and says which port to use
instead. That refusal was documented here from the start and only became real in 1.51.0; before
that, an address on 1234 was accepted and hung.
The service holds cursors and an operation log. It stores no ontology, so it needs no database, no volume and nothing else running beside it.
2. Keep the secret¶
SECRET_KEY signs every token and verifies every connection. One service reads it — the one
you just started — so there is nothing to keep in step, but whoever mints tokens must use the
same string.
Since 1.86.0 the server refuses to start without one. It used to fall back to
change-me-in-production, a string published in this repository, silently: a server started
without the variable looked exactly like one started correctly, and anybody who had read the
source could mint a token for it.
If a token is signed with a different secret the plugin reports invalid token: invalid
signature, which is accurate and still sends people looking in the wrong place. It almost
always means the token was minted against a different value than the running server has.
3. Mint a token¶
There is no login service. A token is a JWT signed with your SECRET_KEY, and its sub claim
is the name that appears beside the person's cursor — so mint one per person:
cd collab
npm run --silent mint-token -- "Ada Lovelace" # 30 days
npm run --silent mint-token -- "Ada Lovelace" --days 7 # or as long as you want
The token goes to stdout and everything else to stderr, so > ada.txt gives a file you can send
— with --silent, which is why it is there. Without it npm run writes four lines of its own
banner to stdout and they land in the file ahead of the token. node mint-token.mjs "Ada Lovelace"
is the same thing without npm in the way.
Send each person their own: two people sharing a token appear as two cursors with one name.
New in 1.93.0. This used to be a
node -e "require('jsonwebtoken').sign(…)"one-liner here and nothing else — 90 characters of quoting that a Windows shell mangles differently from a POSIX one, which mints an unusable token in silence ifSECRET_KEYhappens to be empty. Worse, the plugin's own dialog said the token came "from the web application", retired six releases earlier, so the one field nobody can guess pointed at a product that no longer existed. The dialog now names this command, and a test checks that what it mints is what the bridge accepts.
Choose the expiry deliberately. --days is yours to set; there is no server-side default any
more. A Protégé session left open past it is refused the next morning, and the plugin stops rather
than retrying — retrying a rejected token would only loop against your server. It shows
invalid token: jwt expired; mint a new one and reconnect.
4. Configure each person's plugin¶
Settings live at OntoBoard → Share → Collaboration… in Protégé's menu bar. Connecting is separate: open the OntoBoard tab and choose Collaborate from the ⋮ menu at the right of the board's toolbar. It is in that menu rather than on the bar because the bar did not fit — measured at 1261px wanted against 857 available, at which the button was laid out past the right edge and never painted at all.
| Field | Value |
|---|---|
| Server address | ws://your-server:1235 (or wss:// behind TLS) |
| Board id | any string, the same for everyone — for example the ontology's short name |
| Access token | the token you minted in step 3 |
| Your colour | your cursor and selection colour |
There is no "your name" field. The name comes from the token's sub claim, because the bridge
stamps every operation with the authenticated user rather than trusting what a client says it
is called — a box that could not change anything would be a box that lied.
The board id is not created anywhere in advance; the first person to use one makes it exist. Everyone typing the same string is in the same session.
About the token checkbox. Unticked — the default — the token lives only in memory for that Protégé session. Ticked, it is written to Protégé's preferences as plain text, readable by anything running as you:
| Windows | the registry, under HKEY_CURRENT_USER\SOFTWARE\JavaSoft\Prefs |
| macOS | a plist under ~/Library/Preferences |
| Linux | a file under ~/.java/.userPrefs |
The dialog names the location for the machine it is running on, which is the difference between consenting to store a credential and clicking past a checkbox. Unticking it later erases what was stored rather than leaving it behind.
5. Check it worked¶
The status bar shows Collaborating as <you> on <board>. Move your mouse over the canvas and a
colleague should see your cursor with your name on it. Select a class and they should see a ring
appear around it on their board.
What travels, and what does not¶
Live mode shares an operation log, not the ontology file. Each edit becomes an operation, and
the vocabulary is the eighteen types in OntologyOperation.TYPES. OWL is much larger than that,
so some edits cannot be expressed.
Travels: classes created, renamed or deleted; individuals created and renamed; properties
created and deleted; subclass axioms; existential and universal restrictions (the arrows the
canvas draws); rdfs:label changes; annotations — editorial notes, definitions, provenance — as
updateAnnotation; literals; and the board's own sticky notes and frames.
Does not travel: deleting an individual — there is no removeIndividual in the vocabulary,
so the local delete happens and the peer keeps the individual. Nor do equivalence and
disjointness, property characteristics, property chains, owl:hasKey, negative property
assertions, global domain and range, datatype definitions, SWRL rules, imports, or
ontology-level annotations. Moving a node does not travel either: positions are sent for a
node when it is created, and a later drag is local.
The plugin does not hide this. Anything it cannot share is counted, and the status bar shows
N changes not shared on the right with the most recent reason in its tooltip — for example "an
EquivalentClasses axiom, which the shared session has no way to express". If you are working on
axioms outside that list, use git for those and treat live mode as being for the shape of the
ontology rather than its full logic.
Two things 1.93.0 fixed about that notice. It was written into the same label as the connection status, so the next
Connectederased it — and an automatic reconnect produces one, so a green light could sit over a session in which three of your axioms had gone nowhere. It now has its own place in the status bar, which nothing else writes, and it lasts the whole session. And moving a node was in the "does not travel" list above while the product said nothing at all: an unshareable axiom was counted, but arranging thirty classes produced no count and no message. Dragging a node while connected now says so once, in the board's own status line.
This is a limitation of the shared vocabulary, not of the plugin. Widening it means adding the
type to collab/bridge.mjs and OntologyOperation.TYPES together — both, or the new type is
refused at one end and the edit never arrives.
Who is editing what¶
Since 1.104.0. A peer who has changed an axiom on a term within the last five minutes is shown as editing it — "Bob is editing Calzone" — so you find out before you start on the same term, not after.
It is advisory, and that is not a shortcoming to be fixed later. Protégé owns the edit model: a change happens through its views, its undo stack and its keyboard, and OntoBoard hears about it afterwards. A lock this plugin could not enforce would be worse than none, because somebody would rely on it. Reporting in time is the whole of what is deliverable here, and it prevents the collision the only way anything in this position can.
Three properties worth knowing, because they are what make it trustworthy:
- Observed, not declared. The claim is made by actually editing, not by announcing an intention. Nothing to switch on, nothing to remember to release.
- It dies with its holder. It rides the presence heartbeat, which expires after ten seconds, so a peer who closes Protégé, loses the network or crashes stops claiming anything. That is structural rather than handled — a lock outliving its holder is the failure that makes locking hated, and here it cannot happen.
- It expires in place. Five minutes after the last edit, so a window left open over lunch stops speaking for somebody. The frame is rebuilt on every beat rather than composed once, which is what makes that true at the holder rather than only at the viewer.
Selection is not editing. What a peer has clicked already travelled and is already drawn as a ring round the node. Treating that as a claim would make every click look like an edit, which would make the whole signal worthless.
An older bridge shows no badges rather than wrong ones. The claim travels as one extra field on the presence message; a bridge that does not know about it drops it, and absence is treated as no information rather than as "nobody is editing".
Things that will confuse you once¶
Your name comes from the token, not from the plugin. The bridge reads it from the token's
sub claim at connect and ignores anything a client claims afterwards, so two people sharing one
account appear as two cursors with the same name. Use an account each — and note that the colour
is read once at connect too, so changing it means reconnecting.
Two windows on one account do see each other's edits: echo is filtered per connection, not per account, so one person working across a desktop and a laptop works as expected.
A cursor that vanishes is not a disconnection. The bridge expires a peer after ten seconds of silence. The plugin re-sends your position every three seconds even when you are not moving, so this should not happen; if cursors do disappear while people are clearly still working, look for something dropping WebSocket frames — an idle-timeout on a reverse proxy is the usual culprit.
Any malformed message closes the connection. The bridge answers a frame it cannot parse by saying why and hanging up. The plugin reconnects automatically for anything that looks like an outage, with the delay doubling up to thirty seconds, and does not reconnect when the server gave a reason — retrying a rejected token would just be a loop against your server. The status bar says which of the two happened.
An outage does not lose your edits, in either direction — since 1.88.0. Up to 200 of your own operations are held while disconnected and sent when the connection returns, keeping their original timestamps so the merge engine still orders them correctly. Beyond 200 the oldest are dropped, and the count is reported rather than hidden.
Coming back the other way, the bridge sends you the whole session log when you join or rejoin, so you also receive what happened while you were away. Before 1.88.0 only the first half was true: your edits queued, and the other direction did not exist — a peer that dropped off received only what happened after it returned, and a peer joining a session in progress received nothing at all, with nothing on screen to say its board was incomplete.
The log is sent uncapped. The document is ephemeral — it holds one session's operations and dies with the server — so a cap would reintroduce the same quiet loss for the oldest edits. Duplicates are expected and harmless: you are sent your own earlier operations too, and the plugin deduplicates by operation id across reconnects.
If an incoming change cannot be read, you are told. A frame that arrives shaped like an operation but will not decode is dropped — there is nothing to apply — and the status bar says so, with a count. It used to share a branch with "a message type this version does not understand", which is deliberately ignored for forward compatibility, and was logged where nobody would see it. The two now differ: an unknown type stays silent, a lost edit does not.
Deleting a class deletes more than the class. A remote removeClass retracts the
declaration and every axiom mentioning it, which is what Protégé's own delete does. Leaving
those behind would strand axioms pointing at an undeclared class and leave the two copies
disagreeing about more than the one entity. Protégé's undo will put it back.
Behind TLS¶
wss:// works. One caveat worth knowing before you rely on it: the bundle imports
javax.net.ssl as optional, because a mandatory import for a package Protégé's OSGi container
might not export would stop the whole plugin loading. The trade is that if a container does not
export it, wss:// fails when you connect rather than when you install. ws:// is unaffected.
Nothing seen so far on Protégé 5.5 or 5.6 hits this, but a connection that fails on wss://
and works on ws:// is worth reporting rather than working around.
Terminate TLS at your proxy and forward to 1235, and make sure the proxy does not close idle WebSocket connections — nginx defaults to 60 seconds, which is shorter than a pause for thought:
location /collab-bridge {
proxy_pass http://127.0.0.1:1235;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
If it does not work¶
Work down this list; it is ordered by how often each one is the answer.
- Is the port 1235? Not 1234. The plugin refuses 1234 outright (since 1.51.0), but a proxy in front might not.
- Was the token signed with the running server's
SECRET_KEY? This is the most common cause of a token that looks valid and is not. The message isinvalid token: invalid signature. - Has the token expired? Whatever
--daysyou gave when you minted it, 30 by default. The message saysjwt expired; mint another. - Is everyone using the same board id? It is free-form text, so a typo makes a second, empty board rather than an error.
- Is the service actually up? The terminal running
node server.mjsprints a line per join:[bridge] alice joined 'my-board'. No line means the connection never got that far. A refused one prints[collab] Refused a connectionwith the reason. - What does Protégé say?
~/.Protege/logs/protege.log, searching forontoboard.
The plugin is built to fail with a reason rather than a spinner: every state — connecting, connected, retrying in Ns, refused because X, N changes not shared — appears in the status bar along the bottom of the board, with the unshared count kept in its own corner so a reconnect cannot wipe it. If you see a state that does not explain itself, that is a bug worth reporting.
For maintainers¶
The plugin is a second client of the same session, not a separate system. The pieces:
| Piece | Where |
|---|---|
| Wire format | CollabMessages ↔ bridge.mjs |
| Socket and reconnection | CollabClient |
| Loop guard, apply and publish | CollabSession |
| OWL ↔ operations | OperationMapper |
| Cursor rules | PeerCursors; painting in PeerCursorLayer |
The interop contract is asserted by
bridge-interop.test.mjs, which reads and writes
the shared array the way a Yjs client does. That file exists because the two ends once
disagreed about whether operations were stored as JSON strings or objects, and every unit test
on both sides passed while no operation could cross in either direction. If you change the
storage format, change it there too.
Both ports must demand the same credential. They share one Y.Doc — the bridge attaches to
Hocuspocus through openDirectConnection — so a port that admits anonymous clients is a way
around the port that does not. Until 1.86.0 the Hocuspocus handler returned an "anonymous"
"viewer" for a missing or invalid token, and Hocuspocus authenticates a connection whenever
that handler returns rather than throws, so it accepted everyone. The two decisions now share
one function, hocuspocusAuth beside authenticate in bridge.mjs, so they cannot drift
apart again.
See also feature parity and limitations.