gRPC Worker Plugins (Python)

View as Markdown

Use the nemo-relay-plugin package to build an out-of-process plugin worker. The SDK owns gRPC server setup, generated protocol stubs, JSON-envelope conversion, continuations, cancellation hooks, and scope-stack helpers. Refer to gRPC Worker Plugin Concepts to compare the Python, Rust, and command worker runtimes.

This guide builds a complete plugin that adds a tag to every tool request and emits a mark through the Relay host runtime.

Create the Project

Create the following project layout:

python-grpc-worker/
├── pyproject.toml
├── relay-plugin.toml
└── nemo_relay_python_grpc_worker/
├── __init__.py
└── worker.py

Add the following pyproject.toml file:

1[build-system]
2requires = ["setuptools>=68"]
3build-backend = "setuptools.build_meta"
4
5[project]
6name = "nemo-relay-python-grpc-worker-example"
7version = "0.1.0"
8description = "Example Python gRPC worker plugin for NeMo Relay"
9requires-python = ">=3.11"
10dependencies = [
11 "nemo-relay-plugin>=0.5.0",
12]
13
14[tool.setuptools.packages.find]
15where = ["."]
16include = ["nemo_relay_python_grpc_worker"]

Create nemo_relay_python_grpc_worker/__init__.py with the following content:

1# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2# SPDX-License-Identifier: Apache-2.0
3
4"""Example Python gRPC worker plugin package."""

Implement the Worker

Add the following implementation to nemo_relay_python_grpc_worker/worker.py:

1# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2# SPDX-License-Identifier: Apache-2.0
3
4"""Example Python worker plugin using the nemo-relay-plugin SDK."""
5
6from __future__ import annotations
7
8from typing import Any
9
10from nemo_relay_plugin import ConfigDiagnostic, DiagnosticLevel, Json, PluginContext, WorkerPlugin, serve_plugin
11
12
13class ExamplePythonWorker(WorkerPlugin):
14 """Small worker plugin that tags tool request JSON and emits a host mark."""
15
16 plugin_id = "examples.python_grpc_worker"
17
18 def validate(self, config: Json) -> list[ConfigDiagnostic | dict[str, Any]]:
19 if not isinstance(config, dict):
20 return [
21 ConfigDiagnostic(
22 level=DiagnosticLevel.ERROR,
23 code="examples.python_grpc_worker.invalid_config",
24 component=self.plugin_id,
25 message="plugin config must be a JSON object",
26 )
27 ]
28 if config.get("reject") is True:
29 return [
30 ConfigDiagnostic(
31 level=DiagnosticLevel.ERROR,
32 code="examples.python_grpc_worker.rejected",
33 component=self.plugin_id,
34 field="reject",
35 message="Python gRPC worker rejection requested",
36 )
37 ]
38 if "tag" in config and not isinstance(config["tag"], str):
39 return [
40 ConfigDiagnostic(
41 level=DiagnosticLevel.ERROR,
42 code="examples.python_grpc_worker.invalid_tag",
43 component=self.plugin_id,
44 field="tag",
45 message="tag must be a string",
46 )
47 ]
48 return []
49
50 def register(self, ctx: PluginContext, config: Json) -> None:
51 if not isinstance(config, dict):
52 raise TypeError("plugin config must be a JSON object")
53 if config.get("reject") is True:
54 raise ValueError("Python gRPC worker rejection requested")
55 tag = config.get("tag", "python_grpc_worker")
56 if not isinstance(tag, str):
57 raise TypeError("tag must be a string")
58
59 async def tag_tool_request(tool_name: str, args: Json) -> Json:
60 tagged_args = _tag_json(args, tag)
61 await ctx.runtime.emit_mark(
62 "examples.python_grpc_worker.tool_request",
63 {"tool_name": tool_name, "source": "python-grpc-worker", "tag": tag},
64 )
65 return tagged_args
66
67 ctx.register_tool_request_intercept("tag_tool_request", tag_tool_request)
68
69
70def _tag_json(value: Json, tag: str) -> Json:
71 if not isinstance(value, dict):
72 return value
73 metadata = value.get("_nemo_relay_plugin")
74 if metadata is None:
75 metadata = {}
76 elif not isinstance(metadata, dict):
77 return value
78 return {
79 **value,
80 "_nemo_relay_plugin": {**metadata, "tag": tag},
81 }
82
83
84async def main() -> None:
85 """Entrypoint referenced by relay-plugin.toml."""
86 await serve_plugin(ExamplePythonWorker())
87
88
89if __name__ == "__main__":
90 import asyncio
91
92 asyncio.run(main())

WorkerPlugin.validate returns structured diagnostics after Relay starts the worker process and before Relay installs registrations. WorkerPlugin.register registers a tool request intercept that updates the request JSON and emits a mark through PluginContext.runtime. The main entrypoint blocks until Relay requests shutdown.

Create the Manifest

Create relay-plugin.toml with the following content:

1manifest_version = 1
2
3[plugin]
4id = "examples.python_grpc_worker"
5kind = "worker"
6
7[compat]
8relay = ">=0.5,<1.0"
9worker_protocol = "grpc-v1"
10
11[defaults]
12enabled = false
13
14[capabilities]
15items = ["plugin_worker"]
16
17[source]
18manifest_root = "."
19artifact = "nemo_relay_python_grpc_worker/worker.py"
20
21[integrity]
22sha256 = "sha256:966849be254cc6299a17a4bb65500363e9a48f98cc1e0091192e42b23821486f"
23
24[load]
25runtime = "python"
26entrypoint = "nemo_relay_python_grpc_worker.worker:main"

The digest matches the worker.py file in this guide. Use Python 3.11 or later to calculate a new digest before you register the worker:

$python3 - <<'PY'
$from hashlib import sha256
$from pathlib import Path
$
$artifact = Path("nemo_relay_python_grpc_worker/worker.py")
$print(f"sha256:{sha256(artifact.read_bytes()).hexdigest()}")
$PY

Register the Plugin

Run the following commands from the project directory:

$relay_tmp="$(mktemp -d)"
$relay_config="$relay_tmp/gateway.toml"
$
$nemo-relay --config "$relay_config" plugins add ./relay-plugin.toml

plugins add creates an isolated Relay-managed Python environment and installs the source.manifest_root project into it. Relay uses that recorded environment when it starts the worker. Do not start the worker directly: Relay supplies its worker socket, host socket, activation ID, and activation token.

Configure the Plugin

After plugins add registers the worker, edit $relay_tmp/plugins.toml. Add the configuration table to the [[plugins.dynamic]] entry that plugins add created. The configuration sets the tag that the worker adds to tool request JSON:

1# `plugins add` writes the canonical absolute manifest path.
2[[plugins.dynamic]]
3manifest = "/absolute/path/to/python-grpc-worker/relay-plugin.toml"
4
5[plugins.dynamic.config]
6tag = "documentation"

Do not use this TOML block instead of plugins add for a Python worker. The CLI also creates the lifecycle record that points to the Relay-managed Python environment. The tag field is optional and defaults to python_grpc_worker.

Test Worker-Side Validation

nemo-relay plugins validate checks the manifest, trust evidence, and optional static schema. It does not call WorkerPlugin.validate.

To test runtime validation:

  1. Set reject = true, then start the gateway. Relay starts the worker and reports the worker diagnostic before it installs registrations.
  2. Set tag to a non-string value, then start the gateway. Relay reports the structured diagnostic from WorkerPlugin.validate.

WorkerPlugin.validate runs after Relay starts the worker and before Relay installs registrations.

Run the Plugin

Enable, validate, and start the gateway with the following commands:

$nemo-relay --config "$relay_config" plugins enable examples.python_grpc_worker
$nemo-relay --config "$relay_config" plugins validate examples.python_grpc_worker
$nemo-relay --config "$relay_config" --bind 127.0.0.1:4040

Update the Worker

After you change worker source code or dependencies, rebuild the managed environment instead of only updating the manifest digest:

  1. Recalculate integrity.sha256 with Python 3.11 or later and update relay-plugin.toml.

  2. Remove the registered worker, then add it again:

    $nemo-relay --config "$relay_config" plugins remove examples.python_grpc_worker
    $nemo-relay --config "$relay_config" plugins add ./relay-plugin.toml
  3. Restore any [[plugins.dynamic]].config values, then enable the worker.

plugins add installs a new copy of source.manifest_root into a fresh Relay-managed environment. It cannot refresh an already registered worker.

After you stop Relay, remove the plugin and its managed environment:

$nemo-relay --config "$relay_config" plugins remove examples.python_grpc_worker
$rm -rf "$relay_tmp"

Refer to gRPC Worker Protocol Overview for the shared grpc-v1 contract and Configure Discoverable Plugins for host trust and lifecycle policies.