Open collaboration for IntelliJ and other desktop applications
We’re ready to finally roll out the OCT Service Process! This is a new addition to the Open Collaboration Tools (OCT) ecosystem designed to make integrating OCT much easier, no matter what development environment you’re using.
Previously, building an OCT integration for a non-web application was very difficult, as all lifecycle handling, communication logic, and encryption from the open-collaboration-protocol package had to be reimplemented, and a bridge to Yjs (normally handled by open-collaboration-yjs) had to be created.
The OCT Service Process encapsulates all this functionality, making it available to non-TypeScript applications. The Service Process is available either as a single executable application or as a Node.js npm package, which provides a stdin/stdout-based JSON-RPC API and makes it completely independent of the integrating environment. This allows integration with all kinds of non-web based IDEs and editors.
With the release of the Service Process, we are also releasing a first version of an OCT extension for IntelliJ. Beyond that, an Eclipse IDE plugin is already in the making.
In the following section, the IntelliJ integration is used as an example to show how to use the Service Process to integrate OCT with a non-TypeScript-based IDE or editor.
The following example assumes a basic understanding of OCT. For more information, please check out the Introducing Open Collaboration Tools presentation.
Next, we will cover how to:
- launch the Service Process and communicate with it
- create and join rooms
- handle the OCT-session lifecycle (initializing the workspace, inviting guests, etc)
- share documents and edits with other users
Example implementation
Launching and connecting to the Service Process
The Service Process can be launched using standard OS process handling (ProcessBuilder in this Java/Kotlin example). It offers two command-line arguments for configuration:
--server-addressfor defining which collaboration server to use--auth-tokenin case there is a locally saved authentication token.
currentProcess = ProcessBuilder().command(serviceProcessExecutablePath, "--server-address=${this.serverUrl}", "--auth-token=${savedAuthToken}").start()
You can connect to the Service Process through any JSON-RPC library that supports stream-based communication by using the process stdin/stdout streams. In the example below, we use org.eclipse.lsp4j.jsonrpc, which automatically creates objects for sending and receiving messages.
this.jsonRpc = Launcher.Builder<BaseMessageHandler.BaseRemoteInterface>()
.setClassLoader(OCTMessageHandler.OCTService::class.java.classLoader)
.setLocalServices(messageHandlers) // handles incoming messages
.setRemoteInterfaces(remoteInterfaces) // sendable messages
.setInput(currentProcess?.inputStream)
.setOutput(currentProcess?.outputStream)
// special handling for binary data (see later chapter)
.configureGson { gson ->
binaryDataTypes.forEach {
gson.registerTypeAdapter(it, BinaryDataAdapter(it))
}
}
.create()
this.jsonRpc?.startListening()
Communicating with the Service Process
With a connection like the one described above, you can either send any OCT message directly or send one of the service-process-specific messages. The difference is that for generic OCT messages, the last parameter has to be a target for the message. That means either "broadcast" or a peer ID when sending a request or notification to a specific user in the session. This is not required for service-process-specific messages.
As an example of generic OCT messages, we can take a look at the IntelliJ integration file system messages:
@JsonSegment("fileSystem")
interface FileSystemService: BaseRemoteInterface {
@JsonRequest
fun stat(path: String, target: String): CompletableFuture<FileSystemStat?>
@JsonNotification
fun change(event: FileChangeEvent, broadcast: String)
...
}
stat, for example, is a request that requires a target ID and is therefore called like service?.stat(toOctPath(path), getHostId(path)), while the change notification is used as a broadcast: service.change(FileChangeEvent(changes), "broadcast").
This leads to the data being sent in the following JSON-RPC format:
// Request to host for file stat
{
"jsonrpc": "2.0",
"id": 1,
"method": "fileSystem/stat",
"params": ["shared-folder/file.txt", "host-peer-id"]
}
// Broadcast notification
{
"jsonrpc": "2.0",
"method": "fileSystem/change",
"params": [{
"changes": [{"type": 1, "path": "shared-folder/file.txt"}]
}, "broadcast"]
}
Creating/joining a room and authentication
The Service Process supports two authentication flows: explicit authentication via a login request, or implicit authentication triggered by attempting to create or join a room while unauthenticated. In both cases, the Service Process responds with an authentication notification containing metadata that describes available authentication methods.
The OCT service interface exposes methods for room operations and session management:
interface OCTService: BaseRemoteInterface {
@JsonRequest
fun login(): CompletableFuture<String>
@JsonRequest(value = "room/joinRoom")
fun joinRoom(roomId: String): CompletableFuture<SessionData>
@JsonRequest(value = "room/createRoom")
fun createRoom(workspace: Workspace): CompletableFuture<SessionData>
@JsonRequest(value = "room/closeSession")
...
}
// handles messages from Service Process
class OCTMessageHandler(serverUrl: String, onSessionCreated: EventEmitter<CollaborationInstance>) :
BaseMessageHandler(serverUrl, onSessionCreated) {
@JsonNotification
fun authentication(token: String, metadata: AuthMetadata) {
service<AuthenticationService>().authenticate(serverUrl, token, metadata)
}
}
Authentication
When an authentication notification is received, the handler gets an authentication token and an AuthMetadata object containing:
- An array of
AuthProviderconfigurations, each with a method type, endpoint, and required fields - A
loginPageUrlfor browser-based authentication fallback - A
defaultSuccessUrlto redirect to after successful login
Two main provider types are supported:
| Type | Behavior |
|---|---|
"form" |
The app builds a UI from the provider’s field definitions and submits credentials to the specified endpoint |
"web" |
OAuth flow — redirect the user to the given endpoint with a token parameter for validation |
Starting the session
Once authenticated, createRoom() or joinRoom() returns a SessionData object containing:
- Room ID and room token
- Authentication token
- Workspace configuration
This data initializes the collaboration session and is used to begin sharing documents and awareness information with other participants.
For more information about service-process-specific messages, their parameters, and return types, you can look at the messages.ts file in the service-process section of the open-collaboration-tools repository.
Session lifecycle
After successfully creating or joining a room, the Service Process orchestrates a sequence of lifecycle notifications that establish the collaboration session. Understanding this flow is critical for properly initializing UI state, managing peer presence, and handling session termination.
Initial connection and peer identity
Once createRoom() or joinRoom() completes, the Service Process automatically establishes its internal connection. The first notification you’ll receive is peerInfo, which contains your own peer identity:
@JsonNotification
fun peerInfo(peer: Peer) {
// Store local peer ID, name, encryption/compression metadata
collaborationInstance.identity = peer
}
This Peer object includes your peer ID, name, email, and other metadata that is generally only important for the Service Process. So if there is no need for the UI to know the user’s identity, this can be ignored.
Join approval (host-controlled sessions)
If your session is configured to require approval for new guests, you will receive a joinRoomRequest() notification:
@JsonRequest(value = "room/joinSessionRequest")
fun joinRoomRequest(user: User): CompletableFuture<Boolean> {
// user contains name, email, authProvider
// Return true to accept, false to reject
// If accepted, the new peer will be added and peerJoined() notification follows
}
Session initialization (host sends InitData)
If you are the host (created the room), this can be ignored. The Service Process handles notifying the peers for you based on the workspace data provided when you created the room. This is only useful if you need awareness about your own peer ID.
If you are a guest (joined the room), you will receive an init() notification containing:
@JsonNotification
fun init(initData: InitData) {
// initData contains:
// - protocol: OCT protocol version
// - host: Peer object for the host
// - guests: Array<Peer> of other connected guests
// - workspace: Workspace configuration with name and folders
// - permissions: Map of permission flags (e.g., readonly: false)
// - capabilities: Map of supported capabilities
collaborationInstance.initPeers(initData)
}
This is your signal that the session is ready, and you can now initialize editor state, file system access, and the workspace view.
Peer join/leave notifications
As other peers connect or disconnect, you receive peer lifecycle events:
@JsonNotification
fun peerJoined(peer: Peer) {
// New peer connected; update presence UI, add to peer list
collaborationInstance.peerJoined(peer)
}
@JsonNotification
fun peerLeft(peer: Peer) {
// Peer disconnected; remove from presence UI, clean up peer state
collaborationInstance.peerLeft(peer)
}
Document and editor events
As peers open files, you receive editorOpened() notifications:
@JsonNotification
fun editorOpened(documentPath: String, peerId: String) {
// A peer opened a file; useful for "who's editing" indicators
}
From this point, peers can exchange document edits and cursor positions via updateDocument() and updateTextSelection() notifications (described in the next section).
Session closure
When the host closes the session or the Service Process terminates unexpectedly, all clients receive:
@JsonNotification
fun sessionClosed() {
// Session ended; close all open documents, clean up state
// Guests should typically close their projects
}
Synchronized text documents and binary data
Document synchronization is handled through four awareness messages. Unlike a typical document sync where the integrator manages conflict resolution, the Service Process takes over all Yjs merge handling internally. This means the integrator only needs to apply updates as they arrive. An important thing to note here is that updates must be applied in the order they are received to keep the shared document state consistent.
interface OCTService: BaseRemoteInterface {
@JsonNotification(value = "awareness/openDocument")
fun openDocument(type: String, documentUri: String, text: String): CompletableFuture<Unit>
@JsonNotification(value = "awareness/updateTextSelection")
fun updateTextSelection(path: String, textSelections: Array<ClientTextSelection>): CompletableFuture<Unit>
@JsonNotification(value = "awareness/updateDocument")
fun updateDocument(path: String, updates: Array<TextDocumentInsert>): CompletableFuture<Unit>
@JsonRequest(value = "awareness/getDocumentContent")
fun getDocumentContent(path: String): CompletableFuture<FileContent?>
}
When a user opens a file, openDocument registers it with the Service Process and provides the initial document text. From that point on, local edits are sent as updateDocument notifications containing an array of TextDocumentInsert objects — each describing a start offset, end offset, and replacement text. The same updateDocument message is also received from the Service Process when other peers make edits, and must be applied to the local document buffer.
// Sending a local edit
override fun documentChanged(event: DocumentEvent) {
if (sendUpdates) { // stops echoing back updates
octService.updateDocument(
toOctPath(event.path), arrayOf(
TextDocumentInsert(
event.offset,
event.offset + event.oldFragment.length,
event.newFragment.toString()
)
)
)
}
}
// Applying an incoming edit from a peer
fun updateDocument(path: String, updates: Array<TextDocumentInsert>) {
WriteCommandAction.runWriteCommandAction(project) {
listener?.sendUpdates = false // prevent echoing the change back
for (update in updates) {
document.replaceString(
update.startOffset,
update.endOffset ?: update.startOffset,
update.text.replace("\r\n", "\n") // normalize line endings
)
}
listener?.sendUpdates = true
}
}
A few things to note here:
- All paths in OCT messages are relative to the shared workspace root, in the form
{sharedFolder}/path/to/file. When hosting a session, local absolute paths need to be converted to this format before being sent. - OCT normalizes all line endings to
\nfor cross-editor compatibility, so any\r\nsequences in incoming updates need to be converted before applying them.
As an extra consistency check, getDocumentContent can be called after applying an update to retrieve the authoritative document state directly from Yjs. This is useful for detecting and correcting any divergence between the local buffer and the shared document.
Text selections and cursor positions are broadcast via updateTextSelection. This message serves a dual purpose: it is used both to send the local user’s caret position and to receive the positions of other peers for rendering cursor decorations. The selection payload also includes the file path, making it possible to track other users’ currently active positions and react to those changes.
Binary data handling
The Service Process also allows sharing binary data. For example, content such as FileContent from getDocumentContent is serialized as binary data rather than plain JSON. The Service Process encodes these values using MessagePack and wraps them in a Base64-encoded envelope object.
In our IntelliJ example, when configuring the JSON-RPC launcher, we use custom Gson type adapters for these types to handle encoding and decoding correctly:
.configureGson { gson ->
binaryDataTypes.forEach {
gson.registerTypeAdapter(it, BinaryDataAdapter(it))
}
}
The BinaryDataAdapter deserializes the Base64 payload back into the target type using a MessagePack-aware ObjectMapper, and serializes outgoing values the same way.
Ready to build your own integration
With this information, you should have everything you need to build your own OCT integration using the Service Process and its JSON-RPC interface. We covered process startup, authentication, room/session lifecycle, and document synchronization. Everything else, such as chat and virtual file system support, should be straightforward when looking at the OCT protocol.
If you have questions while implementing your integration, feel free to ask on the OCT GitHub Discussions page.
Happy Coding!
About the Author
Jonah Iden
From dev-Tools and IDEs, to apps, over legacy financial software and embedded systems, Jonah loves developing software and delving deep into complex systems. He came to TypeFox looking for new interesting challenges and is now taking deep dives into Theia and Sprotty. When not at work you can sometimes meet him on the smaller stages in north germany performing with his metal band.

