Platform logo

JetBrains Platform

Plugin and extension development for JetBrains products.

IntelliJ Platform Plugins

The Dev Containers Story: Introducing EelApi for Plugin Authors

Modern development has shifted one old IDE paradigm significantly: Now, not only is it possible that a project is not hosted on the same physical or remote machine as your IDE instance, it could even be that both share the same host but are separated from one another inside isolated environments. If you are a plugin author, the practical implication is probably not abstract at all. Your plugin downloads a CLI tool, starts it, passes project paths to it, receives environment variables from it, or opens a TCP connection to something it starts. It works locally. Then a user opens the same project in WSL or a Dev Container, and suddenly the words “local path”, “current OS”, “localhost“, and “start a process” all need a little more precision.

First, a bit of context. EelApi is much easier to use once the reason for its existence is clear. If you are already in the middle of making a plugin work with WSL or Dev Containers and want the API details, jump to the Starting from a project section of this guide.

Why this API exists

The original IDE model was beautifully boring. The IDE, project files, SDKs, tools, environment variables, and processes all lived on the user’s machine. A Path pointed to a file the IDE could read. SystemInfo described the operating system on which your tools used to run. ProcessBuilder started the process in the right place because there was only one “place”.

Then projects grew large enough, and development environments became specialized enough, that the right machine for the project was sometimes a different machine entirely: a workstation in the office, a VM in the cloud, or a host prepared by the team. For that case, the natural solution is to move the smart part of the IDE close to the project: Run a full IDE backend near the files, SDKs, and tools, and connect to it from a lightweight frontend.

But WSL2 and containers created another kind of problem.

WSL2 brought the Linux project environment onto the same physical machine as the IDE, but kept it separate from the Windows process where the IDE runs. Project files could live in a Linux filesystem, tools had to run as Linux processes, and path strings had to make sense on the Linux side.

Dev Containers pushed that shape further. A container is isolated enough to have its own filesystem, binaries, environment variables, processes, and network namespace. At the same time, it is close enough to the host IDE that starting a full IDE backend inside it can be more machinery than the task requires.

That was the gap we started exploring: Can the IDE stay local, while project-side operations happen in the environment where the project actually lives?

The first response to this challenge was not a public API. It was an agent.

A small IntelliJ Platform agent (ijent) in the target environment gave the IDE a controlled channel to the filesystem, processes, ports, and platform information on the other side. We experimented with transports and RPC shapes, and an internal Kotlin-first API began to form around the requirements: suspending operations, structured lifetime, project-aware scopes, streaming data, and enough filesystem semantics to handle real WSL and container projects.

That API became EelApi.

EelApi is the IntelliJ Platform API for working with an execution environment: the local machine, a WSL distribution, a Docker container, or a Dev Container.

NIO Path can be taught to reach into another environment. Sometimes that is enough. But process execution, OS detection, target-side path strings, environment variables, networking, and optimized filesystem operations need the same missing concept: an explicit execution environment.

WSL as the first production case

WSL was the first large proof point for the underlying technology.

The IDE stayed on Windows, while the project lived in a Linux environment. That meant Linux filesystem semantics, symlinks, Linux SDKs, and Linux-side tools. At the same time, IntelliJ IDEA already had years of WSL-specific integration to build on.

The agent-backed filesystem channel let us improve that model from inside the platform. In IntelliJ IDEA 2024.3, WSL project support gained symlink support and switched IDE-to-WSL communication to Hyper-V sockets. In IntelliJ IDEA 2025.1, WSL projects had faster indexing than Windows ones, fully supported symlinks, and more seamless use of JDKs inside WSL.

The public EelApi was not finished at that point. But the channel underneath it was already doing real work, which is a much better test than a beautiful diagram.

From WSL to Dev Containers

Dev Containers were a different challenge.

With WSL, the platform already had a long history of WSL-specific integration: path recognition, process launching through WSL mechanisms, and dedicated bridges in different subsystems. The agent-backed filesystem channel could improve and replace parts of that existing stack.

Dev Containers did not have the same mature IntelliJ-specific substrate. Treating a containerized project as a local-like project meant building the environment access model around the agent from the start, which meant filesystem access, process execution, platform information, path translation, and network access had to come through the same channel.

In IntelliJ IDEA 2025.3, this appeared as an option to open a Dev Container project in the same IDE window. In IntelliJ IDEA 2026.1, this became the default Dev Container workflow: The project opens in the local IDE without starting a full IDE backend inside the container.

This is what we mean by native Dev Container support in this post: local IDE, a container-side IntelliJ Platform agent, and project-environment operations routed through the execution environment’s API.

In IntelliJ IDEA, this now covers the main workflows expected from a Dev Container project. The remaining work is about broadening this support across more IDEs, more language stacks, more platform subsystems, and more third-party plugin scenarios.

A note on API status

Before we get into the code examples, here’s one practical detail you should note: Most of the EelApi surface is currently marked @ApiStatus.Experimental.

For plugin authors, that does not mean “please ignore this until later”. It means this is the direction to try when your plugin targets WSL or Dev Containers, while some source-level adjustments are still possible during stabilization. We expect those changes to be limited, as the same model has already been exercised in production for WSL support and native Dev Container support in IntelliJ-based IDEs.

Starting from a project

Most plugin code should start from Project.

If a project is open in the IDE, the platform has already established the environment needed to work with it. For local projects, that environment is the local machine. For WSL projects, it is the WSL distribution. For a Dev Container project opened in native mode, it is the container.

import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.platform.eel.provider.toEelApi

val descriptor = project.getEelDescriptor()
val eel = descriptor.toEelApi()

You can also start from a Path:

import com.intellij.platform.eel.provider.getEelDescriptor

val descriptor = path.getEelDescriptor()

This is useful when the path itself is the thing you are working with. Be more careful with arbitrary paths, though. Treating a path as an environment is not always just string parsing; when you actually use the descriptor, the platform may need to start, deploy, or connect to an IntelliJ Platform agent, and some environments may not be available. An already-open project is the safest anchor because, without access to the working environment, the project will not open in that mode.

Descriptor and machine

EelDescriptor answers: “Through which route do I access this environment?”

Use the descriptor when you need to perform an operation in the environment or convert between Path and EelPath.

EelMachine answers: “Which underlying machine, container, or distribution is this really?”

Several descriptors may point to the same machine. For example, WSL paths through \\wsl$ and \\wsl.localhost can refer to the same WSL distribution. Use EelMachine as a cache key when you manage shared resources, such as connection pools, long-lived services, per-environment state, or reusable tunnels.

Most plugin code should start with EelDescriptor. Reach for EelMachine when you are deliberately sharing something across multiple access paths to the same environment.

Core EelApi examples

Through a single EelApi instance, you get:

  • A file-system view of the environment, with NIO Path operations routed there.
  • Process execution inside the environment.
  • TCP tunnels into and out of it.
  • Platform and OS detection for the environment.

The examples below use the current experimental API shape. They show the intended model and the building blocks we recommend using today, but small API adjustments are still possible before stabilization.

Plug a tool into the environment

Suppose your plugin manages versions of a CLI tool. When the project is in a Dev Container, the tool must be the Linux version, and it must exist in the container filesystem.

A Path that points into the environment can be used with standard NIO operations:

import com.intellij.platform.eel.fs.createTemporaryDirectory
import com.intellij.platform.eel.getOrThrow
import com.intellij.platform.eel.provider.asNioPath
import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.platform.eel.provider.toEelApi
import java.nio.file.Files
import java.nio.file.StandardCopyOption

val eel = project.getEelDescriptor().toEelApi()

val remoteDir = eel.fs.createTemporaryDirectory()
  .prefix("my-plugin-")
  .deleteOnExit(true)
  .getOrThrow()
  .asNioPath()

val remoteBinary = Files.copy(
  localBinary,
  remoteDir.resolve(localBinary.fileName),
  StandardCopyOption.REPLACE_EXISTING,
)

Here, remoteDir is still a java.nio.file.Path, but it points to the project environment. Files.copy, Files.write, and other NIO operations are routed through the environment-aware filesystem provider.

There is also an internal helper that transfers local content to a remote environment in a single step. An equivalent public helper is planned alongside the stabilization of EelApi.

Run a tool inside the environment

Use EelApi.exec, not ProcessBuilder, when the process belongs to the project environment.

import com.intellij.platform.eel.provider.asEelPath
import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.platform.eel.provider.toEelApi
import com.intellij.platform.eel.provider.utils.readAllBytes
import com.intellij.platform.eel.spawnProcess

val eel = project.getEelDescriptor().toEelApi()

val process = eel.exec.spawnProcess(remoteBinary.asEelPath().toString())
  .workingDirectory(projectRoot.asEelPath())
  .args("--version")
  .eelIt()

val exitCode = process.exitCode.await()
val stdout = process.stdout.readAllBytes().toString(Charsets.UTF_8)

Use EelPath for the working directory. If an argument is a path that the target process will read, pass the target-side path string.

Pass environment variables and target-side paths

Build tools are a good example because they carry paths both as arguments and as environment variables. Suppose a plugin needs to start Maven in the project environment with a specific JDK and a custom settings file.

import com.intellij.platform.eel.provider.asEelPath

val javaHomeInTarget = jdkHome.asEelPath().toString()

val settingsXmlInTarget = mavenSettingsXml.asEelPath().toString()

val process = eel.exec.spawnProcess(mavenExecutable.asEelPath().toString())
  .workingDirectory(projectRoot.asEelPath())
  .env(mapOf("JAVA_HOME" to javaHomeInTarget))
  .args("-s", settingsXmlInTarget, "test")
  .eelIt()

For a Linux container, JAVA_HOME should look like this:

/usr/lib/jvm/java-21-openjdk

not like a host-side routing path. The same rule applies to Go-specific paths such as GOROOT, GOPATH, or GOMODCACHE, and to more specialized variables such as LD_PRELOAD: If the process in the environment will read the variable value as a path, give it the path as seen from that environment.

Detect the target platform

Do not use SystemInfo to choose the binary for the project environment. SystemInfo describes the IDE host. Use eel.platform for the environment where the project-side tool will run.

val classifier = when {
  eel.platform.isWindows -> "windows-x64"
  eel.platform.isMac -> "macos-aarch64"
  eel.platform.isPosix -> "linux-x64"
  else -> error("Unsupported environment")
}

This matters when the IDE host is macOS or Windows, but the project runs inside a Linux container.

Connect to ports across the boundary

If IDE-side code needs to talk to a service listening inside the environment, do not assume that localhost means the same thing on both sides.

For a one-shot connection, connect through EelTunnelsApi. In this example, localhost is resolved inside the project environment:

import com.intellij.platform.eel.getConnectionToRemotePort
import com.intellij.platform.eel.withConnectionToRemotePort
import java.io.IOException

eel.tunnels.getConnectionToRemotePort()
  .hostname("localhost")
  .port(servicePort.toUShort())
  .withConnectionToRemotePort(
    errorHandler = { error -> throw IOException("Cannot connect to service in the project environment", error) },
  ) { connection ->
    connection.sendChannel.send(requestBytes)
    val response = connection.receiveChannel.receive(8192)
    handleResponse(response)
  }

For an existing IDE-side library that only knows how to connect to a local TCP port, create a proxy. The proxy listens on the IDE host and forwards traffic to the service in the environment:

import com.intellij.platform.eel.eelProxy
import com.intellij.platform.eel.provider.localEel
import com.intellij.platform.eel.provider.utils.acceptOnTcpPort
import com.intellij.platform.eel.provider.utils.connectToTcpPort
import kotlinx.coroutines.launch

val proxy = eelProxy()
  .acceptOnTcpPort(localEel.tunnels, port = 0u)
  .connectToTcpPort(eel.tunnels, host = "localhost", port = servicePort.toUShort())
  .eelIt()
val localPort = proxy.acceptor.boundAddress.port.toInt()
val proxyJob = scope.launch {
  proxy.runForever()
}

The IDE-side code can now connect to 127.0.0.1:$localPort. Keep the proxy lifetime explicit: Cancel the job when the service, run configuration, debug session, or tool window no longer needs the tunnel.

There is a matching direction for accepting connections inside the environment and forwarding them back to the IDE host. That is useful when a process in the container needs to call back into an IDE-side service.

Understanding paths

The examples above use both Path and target-side path strings. This is the part that is easiest to get subtly wrong, so it deserves its own pass.

A java.nio.file.Path is the IDE/JVM-side path. It is the form that IntelliJ Platform APIs and standard Java file APIs can accept.

For WSL on Windows, this path is familiar:

\\wsl.localhost\Ubuntu\home\user\project
\\wsl$\Ubuntu\home\user\project

The target-side path is what Linux tools inside WSL see:

/home/user/project

These forms map naturally: The UNC path identifies the WSL distribution and the Linux path inside it. EEL-aware file access can recognize the WSL path, associate it with the WSL environment, and route file operations through the agent-backed channel.

Docker and Dev Containers need a synthetic routing path because the host OS does not have a normal native path to files inside a running container.

On Windows, a Dev Container routing path may look conceptually like this:

//devcontainer.ij/devcontainer-abc@np~.~pipe~docker_engine/workspaces/app

or in a Windows-style display:

\\devcontainer.ij\devcontainer-abc@np~.~pipe~docker_engine\workspaces\app

On Linux or macOS, the same idea uses a Unix-style synthetic root:

/$devcontainer.ij/devcontainer-abc@/workspaces/app
/$devcontainer.ij/devcontainer-abc@u~var~run~docker.sock/workspaces/app

The prefix identifies a Dev Container routing path. The part before the inner path identifies the container and Docker endpoint. The inner path is the path inside the container:

/workspaces/app

The routing path is meaningful to IntelliJ Platform and JetBrains Runtime file APIs. It is not a normal host filesystem path that arbitrary host processes should be expected to understand.

So the rule is:

  • Use Path for IntelliJ Platform APIs and Java file operations.
  • Use EelPath or path.asEelPath().toString() for paths passed to processes running inside the environment.
  • Do not hand synthetic Docker routing paths to unrelated host tools.

What about existing local-centric APIs?

The IntelliJ Platform has many APIs that were originally designed for the local-machine model. Some of them have learned enough about routed paths to keep working in WSL and Dev Container projects.

NIO Path is the most important example: If the Path belongs to WSL or a Dev Container, standard operations such as Files.exists, Files.copy, or Files.newInputStream can be routed to the environment-aware filesystem provider.

There is also a compatibility layer for older java.io.File code in IDEs running on JetBrains Runtime. JBR can route File operations through the corresponding NIO implementation, so a File created from a routed path can reach the same environment-aware filesystem provider and, for WSL or Dev Containers, the IntelliJ Agent underneath. Treat this as compatibility for existing code; for new code, it is preferable to use Path, as it has a modern, provider-aware Java filesystem API and composes directly with Eel APIs like asEelPath() and asNioPath().

GeneralCommandLine can also participate in this model. In supported cases, the executable path or working directory can let the platform choose the right environment for process execution.

The limit is important, as these APIs do not reinterpret every string as a path. Command-line arguments, environment variables, configuration files, and protocol messages may contain paths, too. If the target process will read the value, convert it to the target-side form explicitly with asEelPath().

The same applies to host information. SystemInfo and System.getenv() describe the IDE process on the host machine. They are not a description of the WSL distribution or container where the project-side process will run.

How this relates to remote development and Split mode

Remote development uses separate IDE processes – a lightweight frontend and a full backend near the project. Split mode is the plugin architecture for that world: Plugin code may need frontend, backend, and shared parts.

EelApi answers a different question: Where does this operation run?

If your plugin needs code to run in different IDE processes, you still need the remote development plugin model. If your plugin needs to run a CLI, inspect target OS information, convert paths, access files, or open a port in WSL or a Dev Container, EelApi is the environment API you should look at.

For IntelliJ IDEA Dev Containers in native mode, Split mode is no longer required just to make project-environment operations happen in the container. Where native mode is not available, remote development remains the option for opening and working with Dev Container projects.

Future plans

We plan to stabilize the public API surface, publish dedicated documentation, and provide migration guidance for plugin authors.

The same direction continues inside IntelliJ-based IDEs: broader Dev Container coverage, more subsystem adoption, and fewer places where plugin authors need to know whether a project is local, WSL, or containerized.

Feedback from real plugins is especially useful now, while the public surface is still experimental enough to adjust.