> ## Documentation Index
> Fetch the complete documentation index at: https://docs.noxus.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# How plugins run

> Plugin nodes, triggers, and data sources execute inside an isolated per-plugin sandbox — how the warm worker, JSON-RPC transport, and host callbacks fit together

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.

<Note>
  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.
</Note>

## 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](https://gvisor.dev) sandbox provisioned by the sandbox manager.

<CardGroup cols={2}>
  <Card title="Isolated" icon="shield">
    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.
  </Card>

  <Card title="Network-jailed" icon="network-wired">
    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**.
  </Card>

  <Card title="No platform key" icon="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.
  </Card>

  <Card title="Crash-isolated" icon="life-ring">
    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.
  </Card>
</CardGroup>

<Info>
  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](/deployment/configuration/sandbox), for how the
  backend is selected and deployed.
</Info>

## 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.

```mermaid theme={null}
graph LR
    BE[Backend / Worker replicas] -->|"JSON-RPC over WS"| MGR[Sandbox manager]
    MGR -->|"stdio"| W["noxus plugin worker<br/>(your plugin, imported once)"]
    W -.->|"host.get_content / host.upload_file<br/>(callbacks)"| BE
```

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:

| Method                                        | Runs                                  |
| --------------------------------------------- | ------------------------------------- |
| `node.execute`                                | a node's `call()`                     |
| `node.config`                                 | a node's dynamic `get_config()`       |
| `trigger.poll`                                | a trigger's `poll()`                  |
| `datasource.fetch`                            | a data source's `fetch()`             |
| `integration.ready` / `integration.config`    | credential checks                     |
| `manifest` / `list_nodes` / `validate_config` | metadata (also the install handshake) |

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:

<Steps>
  <Step title="Download source">
    The plugin package is fetched host-side (Git, upload, or marketplace source).
  </Step>

  <Step title="Upload the tree">
    The plugin directory is streamed into the sandbox (to `/tmp/noxus-plugin`) as a gzipped tar.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Handshake">
    The platform connects and calls `manifest` to confirm the worker imported cleanly and is warm.
  </Step>
</Steps>

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.

<Warning>
  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.
</Warning>

## 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.

## Related

<CardGroup cols={2}>
  <Card title="Sandbox configuration (operators)" icon="server" href="/deployment/configuration/sandbox">
    How the sandbox backend is chosen and deployed — gVisor, MicroVM, and the local fallbacks.
  </Card>

  <Card title="Agent sandbox" icon="robot" href="/platform/agents/sandbox">
    The same isolation model, used by agents to run code and build artifacts.
  </Card>

  <Card title="Creating triggers" icon="bolt" href="/developers/plugins/creating-triggers">
    Author a polling trigger that emits events from inside the sandbox.
  </Card>

  <Card title="Creating data sources" icon="database" href="/developers/plugins/creating-datasources">
    Author a knowledge-base data source that ingests files from inside the sandbox.
  </Card>
</CardGroup>
