IntelliJ Platform Plugin SDK Help

Remote Procedure Calls (RPC)

This article walks through how remote calls (RPC) are set up in Split Mode and refers to code in the publicly available modular plugin template.

Modules Overview

The plugin is split into three modules: shared, frontend, and backend. The following sections explain how the module dependencies are configured.

Shared Module

The shared module defines the RPC interface. It needs the rpc and kotlinx.serialization plugins.

shared/build.gradle.kts:

plugins { id("rpc") id("org.jetbrains.kotlin.jvm") id("org.jetbrains.kotlin.plugin.serialization") } dependencies { intellijPlatform { intellijIdea(libs.versions.intellij.platform) } }

Frontend Module

The frontend module depends on :shared and needs the rpc plugin as well.

frontend/build.gradle.kts:

plugins { id("rpc") id("org.jetbrains.kotlin.jvm") id("org.jetbrains.kotlin.plugin.serialization") } dependencies { intellijPlatform { intellijIdea(libs.versions.intellij.platform) bundledModule("intellij.platform.frontend") // ... } compileOnly(project(":shared")) }

Backend Module

The backend module depends on :shared and requires intellij.platform.kernel.backend and intellij.platform.rpc.backend.

backend/build.gradle.kts:

plugins { id("rpc") id("org.jetbrains.kotlin.jvm") id("org.jetbrains.kotlin.plugin.serialization") } dependencies { intellijPlatform { intellijIdea(libs.versions.intellij.platform) bundledModule("intellij.platform.kernel.backend") bundledModule("intellij.platform.rpc.backend") bundledModule("intellij.platform.backend") } compileOnly(project(":shared")) }

Also declare the backend platform module dependency in backend/src/main/resources/modular.plugin.backend.xml:

<idea-plugin> <dependencies> <module name="intellij.platform.backend"/> <module name="intellij.platform.kernel.backend"/> <module name="modular.plugin.shared"/> </dependencies> <!-- ... --> </idea-plugin>

Implementing RPC Communication

RPC Interface

Introduce the RPC interface in the shared module.

ChatRepositoryRpcApi:

@Rpc interface ChatRepositoryRpcApi : RemoteApi<Unit> { companion object { suspend fun getInstance(): ChatRepositoryRpcApi { return RemoteApiProviderService.resolve( remoteApiDescriptor<ChatRepositoryRpcApi>() ) } } suspend fun getMessagesFlow(projectId: ProjectId): Flow<List<ChatMessageDto>> suspend fun sendMessage(projectId: ProjectId, messageContent: String) }

RPC interface must follow the rules:

  1. The interface must be annotated with @Rpc.

  2. All functions must be suspend.

  3. All parameters and return types must be @Serializable. They are essentially data transfer objects (DTOs) widely used in client-server application development.

    • Primitives, String, Flow, Deferred are serializable by default.

    • Enums are not serializable by default. Mark them as @Serializable explicitly.

    • Classes must be annotated with @Serializable and must contain only other serializable fields

For convenience, introduce suspend getInstanceAsync() so the frontend can easily acquire the instance.

RPC Backend Implementation

Add a class implementing the RPC interface in the backend module.

BackendChatRepositoryRpcApi:

class BackendChatRepositoryRpcApi : ChatRepositoryRpcApi { override suspend fun getMessagesFlow(projectId: ProjectId): Flow<List<ChatMessageDto>> { val backendProject = projectId.findProjectOrNull() ?: return emptyFlow() return BackendChatRepositoryModel.getInstance(backendProject) .getMessagesFlow() } override suspend fun sendMessage( projectId: ProjectId, messageContent: String ) { val backendProject = projectId.findProjectOrNull() ?: return return BackendChatRepositoryModel.getInstance(backendProject) .sendMessage(messageContent) } }

Implement RemoteApiProvider, which registers the RPC implementation with the platform.

BackendRpcApiProvider:

internal class BackendRpcApiProvider : RemoteApiProvider { override fun RemoteApiProvider.Sink.remoteApis() { remoteApi(remoteApiDescriptor<ChatRepositoryRpcApi>()) { BackendChatRepositoryRpcApi() } } }

Register the provider in the com.intellij.platform.rpc.backend.remoteApiProvider extension point.

backend/src/main/resources/modular.plugin.backend.xml:

<idea-plugin> <!-- ... --> <extensions defaultExtensionNs="com.intellij"> <platform.rpc.backend.remoteApiProvider implementation="com.example.BackendRpcApiProvider"/> </extensions> </idea-plugin>

Calling RPC from Frontend

ChatRepositoryRpcApi can be called on the frontend:

val chatRepositoryApi = ChatRepositoryRpcApi.getInstance() chatRepositoryApi.getMessagesFlow(project.projectId()) chatRepositoryApi.sendMessage(project.projectId(), messageContent)

Note that all getInstanceAsync(), getMessagesFlow(), and sendMessage() functions are suspend. They must be called in some coroutine context.

RPC Error Handling

Calling an RPC API may fail with an RpcClientException, for example, if there are communication problems, service is not ready, etc. For instance, this may happen if the client tries to execute the call while the backend is not fully initialized, if a network problem occurs, or if the backend is restarted while a call is being executed.

Such errors can be mitigated by using the durable wrapper function.

durable { ChatRepositoryRpcApi.getInstanceAsync() .getMessagesFlow(project.projectId()) .collect { valueFromBackend -> ... // process the message } }

It will retry the call in case discovery of the backend RPC implementation fails.

Serializable Types

All parameters and return types must be @Serializable.

General rules:

  • Primitives, String, Flow, Deferred are serializable by default.

  • Enums are not serializable by default and must be annotated with @Serializable.

  • For types like LocalDateTime, implement a custom KSerializer (see Data Transfer Objects).

Data Transfer Objects

Domain objects are not always directly serializable for transport over RPC.

In the example plugin, ChatMessage is the domain model used on both sides, but it contains LocalDateTime, which is not natively supported by kotlinx.serialization.

The solution is a dedicated DTO class in the shared module, for example, ChatMessageDto:

@Serializable data class ChatMessageDto( val id: String, val content: String, val author: String, val isMyMessage: Boolean, @Serializable(with = LocalDateTimeSerializer::class) val timestamp: LocalDateTime, val type: ChatMessage.ChatMessageType ) fun ChatMessageDto.toChatMessage(): ChatMessage { /* ... */ } fun ChatMessage.toChatMessageDto(): ChatMessageDto { /* ... */ }

Custom KSerializer implementations can handle types that are not natively serializable, for example, LocalDateTimeSerializer:

object LocalDateTimeSerializer : KSerializer<LocalDateTime> { private val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: LocalDateTime) { encoder.encodeString(value.format(formatter)) } override fun deserialize(decoder: Decoder): LocalDateTime { return LocalDateTime.parse(decoder.decodeString(), formatter) } }

The RPC interface uses the DTO, and each side converts to and from the domain model as needed:

  • Backend: converts ChatMessage -> ChatMessageDto before emitting via getMessagesFlow()

  • Frontend: converts ChatMessageDto -> ChatMessage after receiving via toChatMessage()

ID Types

To pass classes commonly used in IntelliJ Platform through RPC, use ID types and helper functions allowing to serialize and deserialize between the full and ID types:

Full Type

ID Type

Serialization

Deserialization

Project

ProjectId

Project.projectId()
Project.projectIdOrNull()

ProjectId.findProject()
ProjectId.findProjectOrNull()

VirtualFile

VirtualFileId

VirtualFile.rpcId()

VirtualFileId.virtualFile()

Editor

EditorId

Editor.editorId()
Editor.editorIdOrNull()

EditorId.findEditor()
EditorId.findEditorOrNull()

Icon

IconId

Icon.rpcId()
Icon.rpcIdOrNull()

IconId.icon()

Note that these objects are not fully serializable, so the frontend only receives parts of the backend object. If possible, use only IDs on the frontend and work with the full objects on the backend side.

RPC Examples

Subscribing to the Backend State

BackendChatRepositoryModel holds a MutableStateFlow of messages on the backend:

@Service(Service.Level.PROJECT) class BackendChatRepositoryModel { private val _messages = MutableStateFlow( listOf(/* initial messages */) ) fun getMessagesFlow(): Flow<List<ChatMessageDto>> { return _messages.map { messagesList -> messagesList.map(ChatMessage::toChatMessageDto) } } suspend fun sendMessage(messageContent: String) { // appends to _messages, triggering flow emissions } }

The BackendChatRepositoryRpcApi RPC implementation simply delegates to this service:

class BackendChatRepositoryRpcApi : ChatRepositoryRpcApi { override suspend fun getMessagesFlow(projectId: ProjectId): Flow<List<ChatMessageDto>> { val backendProject = projectId.findProjectOrNull() ?: return emptyFlow() return BackendChatRepositoryModel.getInstance(backendProject) .getMessagesFlow() } }

On the frontend, FrontendChatRepositoryModel subscribes to this flow and exposes it as a StateFlow:

@Service(Level.PROJECT) class FrontendChatRepositoryModel( private val project: Project, coroutineScope: CoroutineScope ) : ChatRepositoryApi { override val messagesFlow: StateFlow<List<ChatMessage>> = flow { durable { ChatRepositoryRpcApi.getInstanceAsync() .getMessagesFlow(project.projectId()) .collect { valueFromBackend -> val mappedValue = valueFromBackend.map { messageDto -> messageDto.toChatMessage() } emit(mappedValue) } } }.stateIn( coroutineScope, initialValue = emptyList(), started = SharingStarted.Lazily ) }

In the code above, messagesFlow is initialized with an empty list. Since services are initialized lazily, the first subscriber will trigger the RPC connection, but the state will be emptyList() until the first backend emission arrives.

If this matters for an implemented feature, make sure the service is initialized before its first use — for example, via subscribing to updates from the backend inside a dedicated ProjectActivity.

Pushing Events from Backend to Frontend

To push events from the backend to the frontend without an explicit request, use ApplicationRemoteTopic or ProjectRemoteTopic instead of a regular RPC call.

Example:

  1. Define topic and event in the shared module:

// shared module @Serializable data class NewMessageEvent( val projectId: ProjectId, val messageId: String ) val NEW_MESSAGE_TOPIC: ProjectRemoteTopic<NewMessageEvent> = ProjectRemoteTopic("chat.newMessage", NewMessageEvent.serializer())
  1. Send events from the backend module:

// backend module class BackendChatRepositoryModel { suspend fun sendMessage(messageContent: String) { val userMessage = chatMessageFactory.createUserMessage(messageContent) _messages.value += userMessage NEW_MESSAGE_TOPIC.send( NewMessageEvent(project.projectId(), userMessage.id) ) } }
  1. Handle events in the frontend module:

// frontend module class NewMessageEventListener : ProjectRemoteTopicListener<NewMessageEvent> { override val topic = NEW_MESSAGE_TOPIC override fun handleEvent(event: NewMessageEvent) { // React to the new message event } }
  1. Register the listener via extension point in modular.plugin.frontend.xml:

<extensions defaultExtensionNs="com.intellij"> <platform.rpc.projectRemoteTopicListener implementation="org.jetbrains.plugins.template.NewMessageEventListener"/> </extensions>

FAQ

What to do with AbstractMethodError?

If an error like this occurs:

java.lang.AbstractMethodError: Receiver class InterfaceApiClientStub does not define or inherit an implementation of the resolved method 'abstract void foo()' of interface InterfaceApi.

Make sure that all the functions in the interface are suspend.

How to efficiently transfer byte[]?

Wrap the data into a fleet.rpc.core.Blob for the sake of reducing the serialization overhead.

01 April 2026