Skip to main content
Everything your plugin runs — node call(), trigger poll(), data source fetch(), dynamic get_config(), and credential checks — executes inside an isolated sandbox, never in the platform process. This page explains that execution model and what it means for you as a plugin author.
This is a change from earlier versions of Noxus, where plugins ran as a local FastAPI “plugin server” process. There is no plugin server anymore — each plugin runs as a long-lived worker inside its own sandbox.

Why the sandbox

Plugin code is untrusted, third-party code that the platform installs and runs on behalf of every workspace that uses the plugin’s nodes. Running it in-process would give it the platform’s database handles, credentials, and internal network. Instead each plugin runs in the same isolated environment agents use for code execution — a gVisor sandbox provisioned by the sandbox manager.

Isolated

Each plugin gets its own sandbox with its own filesystem and its own Python virtual environment. A plugin’s dependencies never collide with the platform’s or with another plugin’s.

Network-jailed

Private and internal network ranges are blocked from inside the sandbox. Plugin code can reach the public internet (to call third-party APIs) but cannot reach the platform’s internal services.

No platform key

The sandbox holds no platform API key. It cannot call back into the platform except through the narrow, host-mediated file callbacks described below.

Crash-isolated

A plugin that hangs or crashes takes down only its own worker. Calls are bounded by a timeout and the platform reconnects on the next request.
The plugin system requires the remote sandbox manager. Plugins run as warm workers inside per-plugin sandboxes, so if SANDBOX_MANAGER_URL is not configured, plugins are unavailable — the local in-worker sandbox fallback (Deno/subprocess) does not host plugins. See the operator guide, Sandbox configuration, for how the backend is selected and deployed.

The warm worker

When a plugin is installed the platform provisions one sandbox for it and starts a single long-lived process inside — noxus plugin worker. That process imports your plugin’s module graph once and then waits for requests. Every platform process — the backend and each worker replica — connects to that same warm process through the sandbox manager’s multiplexed WebSocket route and speaks line-delimited JSON-RPC to it. Requests are dispatched by method name: Because the process stays warm, imports and any module-level state are paid for once, not per request. stdout carries only protocol frames; anything your code writes to stdout will corrupt the channel — write logs to stderr (loguru’s default) or use print(..., file=sys.stderr). Worker stderr is surfaced as plugin logs in the UI.

Provisioning

The first time a plugin’s worker is needed, the platform provisions the sandbox under a cross-instance lock so replicas don’t race:
1

Download source

The plugin package is fetched host-side (Git, upload, or marketplace source).
2

Upload the tree

The plugin directory is streamed into the sandbox (to /tmp/noxus-plugin) as a gzipped tar.
3

Install dependencies

uv installs the plugin (and the noxus-sdk that provides the worker CLI) into a sandbox-local .venv. Your pyproject.toml dependencies are installed here.
4

Handshake

The platform connects and calls manifest to confirm the worker imported cleanly and is warm.
The sandbox is shared across all platform processes and all tenants that use the plugin — provisioning happens once, not per call and not per workspace. The sandbox id is stored on the plugin row so any replica can re-attach to a live sandbox instead of provisioning a duplicate.
Your plugin’s dependencies must resolve a compatible noxus-sdk — the SDK that ships the noxus plugin worker command. If your pyproject.toml pins an older SDK, install fails fast with a clear error rather than timing out at the handshake. Keep your floor at or above the platform’s required SDK version.

How inputs and outputs cross the boundary

Everything a call needs is serialized into the JSON-RPC request: the node/trigger/datasource name, the resolved config and inputs, and a RemoteExecutionContext (ctx). Your call()/poll()/fetch() returns plain JSON, which becomes the node’s outputs or the trigger’s events. Files are the exception. File bytes are never inlined into the request. Instead, ctx.get_file_helper() gives you a helper whose operations are serviced as callbacks over the same channel:
  • await helper.get_content(file) → issues a host.get_content request back to the platform, which reads the file from its own storage and returns the bytes.
  • await helper.upload_file(...) → issues a host.upload_file request; the platform writes the bytes to its storage and DB and returns a file descriptor you can return as an output.
This is why the design works even though the sandbox cannot reach internal services: the plugin never HTTP-calls the platform and never holds a key. The callbacks run in-process on the platform, against its own database and storage.

Workspace scoping is enforced by the host

A plugin runs for every tenant that uses its nodes, so a file operation must be pinned to the workspace that made the call. Plugin code cannot be trusted to declare its own workspace — it could simply lie. Instead:
  • The host mints an unguessable single-use token for each call and places it in the ctx. Plugin code never reads or sets it.
  • Every file callback must present that token; the host maps it back to the calling workspace and refuses callbacks without a valid token.
  • On upload, the workspace resolved from the token wins over any group_id the plugin passes, so a plugin cannot plant files in another workspace.

At-most-once side effects

If the connection to a worker drops mid-call (a manager restart, a reaped sandbox), the platform retries only read-only methods (manifest, node.config, integration.*, …). It deliberately does not retry node.execute or trigger.poll: a dropped connection can’t tell whether the call already ran, and re-sending could send the same email or create the same ticket twice. Such a call fails with a clear error instead of silently doubling. Each call is also bounded by a timeout (PLUGIN_CALL_TIMEOUT, 240s by default). A slow node should fit inside it; a hung or malicious worker cannot block a request forever.

What this means for you

  • Write to stderr, never stdout. stdout is the protocol channel.
  • Declare every dependency in pyproject.toml. It is installed into the sandbox venv; nothing from the platform environment leaks in.
  • Reach external services over the public internet as normal (httpx, SDKs, …). Do not expect to reach anything on a private/internal address.
  • Move file bytes through the file helper, not by trying to read platform storage yourself.
  • Make node/trigger work idempotent where you can, since a mid-flight connection loss will not be retried for you.
  • Keep module import cheap. It happens once per worker start, but a slow import delays the first call and every restart.

Sandbox configuration (operators)

How the sandbox backend is chosen and deployed — gVisor, MicroVM, and the local fallbacks.

Agent sandbox

The same isolation model, used by agents to run code and build artifacts.

Creating triggers

Author a polling trigger that emits events from inside the sandbox.

Creating data sources

Author a knowledge-base data source that ingests files from inside the sandbox.