Skip to main content

Plugin Architecture

IdeGYM's plugin system lets you extend the framework at three distinct integration points, all from a single package. A plugin can participate in image building (what goes into the container), server routing (what endpoints the server exposes at runtime), and client operations (what typed methods the Python client gains). Each integration point is optional — a plugin only needs to implement the parts it uses.

Table of Contents​


Overview​

The central module is api/src/idegym/api/plugin.py. It defines PluginBase, BuildContext, and both registries (@image_plugin, @server_plugin). The api package is a lightweight shared dependency that image builder, server, and client all import without creating cycles.


Integration Points​

A class participates in integration points by implementing specific methods or registering with a decorator:

Integration PointHowWhere consumed
Image building@image_plugin("name") + apply() / render()Image.to_spec() in image builder
Server routing@server_plugin + get_server_router()server/main.py at startup
MCP upstreamget_mcp_upstream() on a PluginBase subclassImage.to_spec() auto-writes config
Client operationsidegym.plugins.client entry pointIdeGYMServer.__init__

All methods have no-op defaults, so implementing only one integration point is perfectly valid.


Image Build Plugins​

Image build plugins emit Dockerfile fragments. They are registered with @image_plugin and subclass PluginBase.

PluginBase​

from idegym.api.plugin import BuildContext, PluginBase, image_plugin


@image_plugin("my-plugin")
class MyPlugin(PluginBase):
# Pydantic fields — validated, serializable to/from YAML
message: str
path: str = "/tmp/hello.txt"

def apply(self, ctx: BuildContext) -> BuildContext:
"""Update the shared build context. Called before render()."""
return ctx.updated(labels={**ctx.labels, "my.label": self.message})

def render(self, ctx: BuildContext) -> str:
"""Return a Dockerfile fragment. Called after this plugin's apply()."""
return f"RUN echo {self.message!r} > {self.path}"

BuildContext​

BuildContext is an immutable dataclass threaded through the plugin pipeline. Each plugin's apply() method receives the context output by the previous plugin and returns a new one.

FieldTypeDefaultDescription
basestr—Base image reference
current_userstr"root"Active user, without a group; updated by the user plugin
current_groupOptional[str]NoneGroup of current_user; None means its own-named group
homestr"/root"Active user's home directory
project_rootstr"/root/work"Project root inside the container; read it rather than deriving it from home
requestOptional[DownloadRequest]NoneDownload request set by the project plugin
labelsdict[str, str]{}OCI image labels accumulated by plugins
context_pathstr"."Docker build context directory
extrasdict[str, Any]{}Arbitrary inter-plugin state

Updating the context:

# Replace a single field
new_ctx = ctx.updated(current_user="appuser", home="/home/appuser")

# Add a key to extras (for passing state to downstream plugins)
new_ctx = ctx.with_extra("my.plugin.setting", "value")

# Merge multiple extras at once
new_ctx = ctx.with_extras({"my.key": 1, "other.key": 2})

# Read extras in render()
value = ctx.get_extra("my.plugin.setting", default="fallback")

Plugin Pipeline​

Image.to_spec() processes plugins one at a time: for each plugin, apply() runs first to update the context, then render() is called immediately with the updated context to produce the Dockerfile fragment. The next plugin then receives the context as left by the previous one.

BuildContext(base=...)
→ plugin[0].apply(ctx) → ctx_0
→ plugin[0].render(ctx_0) → fragment_0
→ plugin[1].apply(ctx_0) → ctx_1
→ plugin[1].render(ctx_1) → fragment_1
→ ...
→ assemble Dockerfile → ImageBuildSpec

[!NOTE] Because apply() and render() are interleaved, plugin order matters. A plugin's render() sees only the context set by itself and earlier plugins — not by plugins that come after it in the list. For example, if the user plugin comes after the project plugin, project.render() will see current_user="root" (the default), not the user created by the user plugin.

Shipping files your COPY needs (get_context_files)​

If your render() emits a COPY directive, the source must exist in the Docker build context. Don't assume the caller builds from a checkout of the idegym repo — a plugin author may build from any directory. Instead, ship the file inside your plugin package and declare it from get_context_files():

from importlib.resources.abc import Traversable
from idegym.plugins.plugin_utils import plugin_asset


class MyPlugin(PluginBase):
def render(self, ctx: BuildContext) -> str:
return "COPY my_plugin/start.sh /usr/local/bin/start.sh"

def get_context_files(self, ctx: BuildContext) -> dict[str, Traversable]:
# dest path (matching the COPY source) -> packaged file
return {"my_plugin/start.sh": plugin_asset(__package__, "start.sh")}

Image.to_spec() collects these onto ImageBuildSpec.context_files. The two builders then make them available in the exact same COPY path:

  • Local Docker build stages any missing declared files in place into the caller's build context (removing only what it created afterwards), so COPY resolves regardless of the caller's working directory while their .dockerignore and sibling COPY sources stay in effect.
  • Kaniko build checks out the idegym repo at the orchestrator's version and uses it as the context (see Image Builder → Kaniko build).

Keep the get_context_files() keys in lockstep with the COPY sources your render() emits. Package the assets so they're readable via importlib.resources — for a single wheel that mixes src-layout code with data files outside src, use hatchling force-include (see the built-in plugins' plugins/pyproject.toml); plugin_asset() also falls back to the source tree for editable installs.

Registering with @image_plugin​

@image_plugin("my-plugin") # "my-plugin" is the YAML `type` field
class MyPlugin(PluginBase): ...

The decorator registers the class in _PLUGIN_REGISTRY. The name must be unique across all installed plugins. Attempting to register the same name twice raises ValueError.

Discovery: Plugins are loaded via the idegym.plugins.image entry point group at import time (when idegym.image.builder is first imported). No manual import is required. Declare the entry point in your package's pyproject.toml:

[project.entry-points."idegym.plugins.image"]
my-plugin = "my_package.plugins:MyPlugin"

Server Plugins​

Server plugins mount FastAPI routers into the running IdeGYM server. They are registered with @server_plugin.

from idegym.api.plugin import server_plugin


@server_plugin
class MyPlugin:
"""Expose /my-plugin/* endpoints on the server."""

@classmethod
def get_server_router(cls):
from my_package.router import router # deferred import avoids circular deps

return router

get_server_router() is called at server startup. It should return a FastAPI APIRouter or None (to skip registration for this plugin class). Deferred imports inside get_server_router() are the recommended pattern: they avoid loading the router module until the server actually needs it, which keeps the plugin's pyproject.toml dependencies from affecting packages that only use the image builder.

@server_plugin raises ValueError if the same class is decorated twice.

Dependency Injection​

Server plugins use FastAPI's native dependency_overrides mechanism. Define a stub dependency function in your router, then let the server wire the real implementation:

# my_package/router.py
from fastapi import APIRouter, Depends

router = APIRouter()


async def _get_my_service() -> MyService:
"""Stub — server overrides this via app.dependency_overrides."""
raise RuntimeError("my_service not configured")


@router.post("/my-plugin/action")
async def do_action(service: MyService = Depends(_get_my_service)): ...

The server then registers the real service:

# server/main.py (or your application entry point)
from my_package.router import _get_my_service

app.dependency_overrides[_get_my_service] = lambda: container.my_service()

The lambda: container.my_service() wrapper is required because FastAPI introspects the callable's signature via inspect.signature(). Dependency-injector Singleton providers have a C-level __call__ with no inspectable Python signature — wrapping in a lambda gives FastAPI a plain Python callable with a () signature.

Discovery​

The server loads plugins from the idegym.plugins.server entry point group at module import time, filtered by /etc/idegym/plugins.json (see Plugin Discovery and Configuration):

[project.entry-points."idegym.plugins.server"]
my-plugin = "my_package.server_plugin:MyPlugin"

MCP Upstream Convention​

A plugin that runs an MCP server inside the container can declare its URL by overriding get_mcp_upstream() on the image build plugin class:

@image_plugin("pycharm")
class PyCharm(PluginBase):
...

def get_mcp_upstream(self, ctx: BuildContext) -> Optional[str]:
return "http://localhost:6789/mcp"

When Image.to_spec() encounters a plugin that returns a non-None URL, it automatically emits a Dockerfile instruction:

# Register MCP upstream: pycharm
RUN set -eux; \
mkdir -p /etc/idegym/mcp-upstreams.d; \
printf '%s\n' '{"url":"http://localhost:6789/mcp"}' > /etc/idegym/mcp-upstreams.d/pycharm.json

The MCP gateway running in the container reads /etc/idegym/mcp-upstreams.d/ at startup to discover upstream MCP servers.

You can also add MCP upstreams explicitly without implementing a plugin, using the mcp-upstream built-in plugin:

from idegym.plugins.defaults.image import MCPUpstream

image = image.with_plugin(MCPUpstream(name="my-service", url="http://localhost:9000/mcp"))
- type: mcp-upstream
name: my-service
url: http://localhost:9000/mcp

Client Operation Plugins​

Client operation plugins add typed, async operation methods to IdeGYMServer. The class is discovered via the idegym.plugins.client entry point group and instantiated with the server's forwarding context:

from idegym.client.operations.forwarding import ForwardingOperations


class MyPluginClientOperations:
def __init__(
self,
forward: ForwardingOperations,
server_id,
client_id,
polling_config,
):
self._forward = forward
self._server_id = server_id
self._client_id = client_id
self._polling_config = polling_config

async def do_action(self, payload: str) -> dict:
from my_package.api import MyRequest

return await self._forward.forward_request(
method="POST",
path="my-plugin/action",
body=MyRequest(payload=payload),
server_id=self._server_id,
client_id=self._client_id,
polling_config=self._polling_config,
)

Declare the entry point in pyproject.toml:

[project.entry-points."idegym.plugins.client"]
my-plugin = "my_package.client_ops:MyPluginClientOperations"

IdeGYMServer.__init__ iterates the idegym.plugins.client group and attaches each loaded class as an attribute. Hyphens in the entry point name are replaced with underscores so the attribute is always valid Python syntax — "my-plugin" becomes server.my_plugin:

server = await client.start_server(...)
result = await server.my_plugin.do_action("hello")

Each IdeGYMServer instance gets its own ops object, so server_a.my_plugin and server_b.my_plugin are independent instances with different server_id values.

Failures are isolated per plugin — if one entry point fails to load (e.g., optional dependency not installed), the other plugins still load and a warning is emitted.

server.forward() — escape hatch​

For endpoints not covered by a typed ops class, use IdeGYMServer.forward():

response = await server.forward(
method="POST",
path="my-plugin/action",
body=MyRequest(payload="hello"),
)

forward() delegates to the same ForwardingOperations used by all other operations. It accepts any Pydantic model as body and returns the parsed JSON response as a dict.


Plugin Discovery and Configuration​

Entry Point Groups​

GroupLoaded byWhen
idegym.plugins.imageimage-builder (builder.py)At import time — ensures the registry is populated before YAML deserialization
idegym.plugins.serverserver/main.pyAt module load — filtered by plugins.json
idegym.plugins.clientclient/server.py (IdeGYMServer.__init__)Per IdeGYMServer instance — deferred import picks up any runtime patches

/etc/idegym/plugins.json​

The IdeGYMServer image build plugin writes this file into the container at build time. It controls which server and client plugins are enabled at runtime:

{"server": ["tools", "rewards", "pycharm"]}

The list contains entry point names from the idegym.plugins.server group. At server startup:

  • If the file exists → load only the listed plugins
  • If the file is absent (development) → load all installed plugins

The IdeGYMServer plugin always includes tools and rewards. Additional plugins can be added by other @image_plugin classes that call ctx.with_extra("idegym.enabled_server_plugins", [...]). The PyCharm plugin does this automatically when added to an image:

image = (
image.with_plugin(PyCharm(version="2026.1.1", user="appuser")).with_plugin( # adds "pycharm" to the list
IdeGYMServer.from_local(root=from_root())
) # writes plugins.json
)

The directory /etc/idegym/ and the file are owned by the container user (appuser) so that subsequent run_commands() calls can overwrite plugins.json if needed.


Writing a Full Plugin​

This example shows a plugin that participates in all integration points.

1. Define the image build plugin​

# my_package/image_plugin.py
from typing import Optional
from idegym.api.plugin import BuildContext, PluginBase, image_plugin


@image_plugin("my-plugin")
class MyPlugin(PluginBase):
port: int = 8090

def apply(self, ctx: BuildContext) -> BuildContext:
# Signal to IdeGYMServer (if in the pipeline) to enable our server plugin
existing = list(ctx.get_extra("idegym.enabled_server_plugins", []))
return ctx.with_extra("idegym.enabled_server_plugins", existing + ["my-plugin"])

def render(self, ctx: BuildContext) -> str:
return f"RUN pip install my-service && my-service --install"

def get_mcp_upstream(self, ctx: BuildContext) -> Optional[str]:
return f"http://localhost:8090/mcp"

2. Define the server plugin​

# my_package/server_plugin.py
from idegym.api.plugin import server_plugin


@server_plugin
class MyServerPlugin:
@classmethod
def get_server_router(cls):
from my_package.router import router

return router
# my_package/router.py
from fastapi import APIRouter, Depends

router = APIRouter()


async def _get_my_service():
raise RuntimeError("not configured")


@router.get("/my-plugin/status")
async def status():
return {"ok": True}

3. Define the client operations class​

# my_package/client_ops.py
class MyPluginClientOperations:
def __init__(self, forward, server_id, client_id, polling_config):
self._forward = forward
self._server_id = server_id
self._client_id = client_id
self._polling_config = polling_config

async def status(self) -> dict:
return await self._forward.forward_request(
method="GET",
path="my-plugin/status",
server_id=self._server_id,
client_id=self._client_id,
polling_config=self._polling_config,
)

4. Declare entry points in pyproject.toml​

[project.entry-points."idegym.plugins.image"]
my-plugin = "my_package.image_plugin:MyPlugin"

[project.entry-points."idegym.plugins.server"]
my-plugin = "my_package.server_plugin:MyServerPlugin"

[project.entry-points."idegym.plugins.client"]
my-plugin = "my_package.client_ops:MyPluginClientOperations"

5. Use the plugin​

from idegym.image.builder import Image
from my_package.image_plugin import MyPlugin

image = (
Image.from_base("ghcr.io/jetbrains-research/idegym/server-debian-bookworm-20250520-slim:latest")
.with_plugin(User(username="appuser", uid=1000, gid=1000))
.with_plugin(MyPlugin(port=8090))
.with_plugin(IdeGYMServer.from_local(root=from_root()))
)

# After building and starting the server:
status = await server.my_plugin.status() # → {"ok": True}

Built-in Default Plugins​

All plugins ship in a single distribution, idegym-plugins. The defaults and shared utilities are always installed; the IDE integrations are activated via extras (idegym-plugins[pycharm], idegym-plugins[idea]).

idegym-plugins defaults (always installed)​

Ships with the IdeGYM workspace. Source: plugins/defaults/src/idegym/plugins/defaults/.

image.py — image build plugins:

Type nameClassDescription
base-systemBaseSystemInstalls system packages via apt-get
userUserCreates a Linux user and group
permissionsPermissionsSets file ownership and mode
mcp-upstreamMCPUpstreamExplicitly declares an MCP upstream URL
projectProjectLoads a project (git, local copy, archive, clone)
idegym-serverIdeGYMServerInstalls the IdeGYM server runtime

server.py — server plugins (no image build component):

Entry point nameClassDescription
toolsToolsPluginMounts the built-in tools router (/api/tools/*)
rewardsRewardsPluginMounts the built-in rewards router (/api/rewards/*)

The IDEA and PyCharm launchers use their bundled JetBrains Runtime without setting the image's JAVA_HOME or adding the bundled runtime to PATH. A base image's Java toolchain and command precedence are preserved; the IDE's bin directory is appended to PATH. If a task needs Java, its base image must provide it. To invoke the IDE's runtime explicitly, use /opt/idea/jbr/bin/java or /opt/pycharm/jbr/bin/java.

PyCharm — idegym-plugins[pycharm] (optional extra)​

Ships in plugins/pycharm/. Install the pycharm extra to use the PyCharm integration.

Integration pointEntry point groupClass
Image buildidegym.plugins.imagePyCharm — installs PyCharm IDE; uses bundled JBR for Java
Server routingidegym.plugins.serverPyCharmPlugin — mounts POST /api/pycharm/inspect
Client operationsidegym.plugins.clientPycharmClientOperations — exposes server.pycharm.inspect(...)

IntelliJ IDEA — idegym-plugins[idea] (optional extra)​

Ships in plugins/idea/. Install the idea extra to use the IntelliJ IDEA integration.

Integration pointEntry point groupClass
Image buildidegym.plugins.imageIdea — installs IntelliJ IDEA; headless by default, headless=False runs under Xvfb like PyCharm
Server routingidegym.plugins.serverIdeaPlugin — mounts POST /api/idea/inspect
Client operationsidegym.plugins.clientIdeaClientOperations — exposes server.idea.inspect(...)

OpenHands tools — idegym-plugins[openhands] (optional extra)​

Ships in plugins/openhands/. Exposes the agent-independent openhands-tools — terminal, file editor, grep, glob, apply-patch, planning file editor, task tracker, and the Gemini file tools — through all three IdeGYM surfaces. There is no OpenHands agent or LLM: it reuses OpenHands' executors, tool definitions, MCP schemas, and terminal sessions.

Integration pointEntry point groupClass
Image buildidegym.plugins.imageOpenHands — installs the runtime into a dedicated venv, starts the loopback service, declares the MCP upstream
Server routingidegym.plugins.serverOpenHandsServerPlugin — mounts /api/openhands/* as a typed proxy to the loopback service
Client operationsidegym.plugins.clientOpenHandsClientOperations — exposes server.openhands.* (tools, stateful terminals, capabilities)

All three surfaces dispatch to one runtime and one set of stateful terminal sessions:

The service runs in its own in-container virtualenv: openhands-sdk transitively pins an older opentelemetry-api than IdeGYM, so the two cannot share one environment. The venv installs the plugin runtime from the idegym-plugins source the base image already provides (the same distribution the pycharm/idea plugins come from) — nothing is fetched or copied a second time — and IdeGYM only proxies to the service over loopback. Terminals are backed by OpenHands' create_terminal_session (one retained pinned pane or process per handle); the backend (tmux/subprocess) is fixed per terminal and never silently switched, and tmux is the reliable choice (OpenHands' subprocess terminal has an unreliable interrupt). Agent-dependent families (task, workflow, tom_consult) are reported as unsupported with a reason rather than faked.


Entry Point Groups Reference​