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

# Scripts

> Small Python scripts that gate or react to platform events on your own deployment

Scripts let a platform administrator attach a short Python script to a
platform event without writing a plugin. The script runs inside a throwaway, sandboxed
interpreter with a two second budget and sees only the event it was given, plus a copy
of the file for file events.

A script runs either before an action or after it:

* **Before** the action, inline, where it can block it with a reason. Example: refuse
  any file whose content looks like a private key, whatever path it arrived by. The
  API and the rest of this page call this kind a **middleware**.
* **After** the action, in the worker, where it cannot change the outcome. Example: log
  every model response over a token threshold to your own audit trail. The API and the
  rest of this page call this kind a **hook**.

> [!NOTE]
>
> Scripts exist on on-prem deployments only (`ON_PREM=true`, which every
> on-prem install already sets). They never run on the Noxus cloud. Only platform
> administrators can see or manage them.

## Where to find it

On an on-prem deployment, **Settings → Platform → Extensions → Scripts** opens
one list of scripts, next to Plugins. The **Runs** column says whether a script runs
*Before* an action (a middleware) or *After* it (a hook), and the filter in the header
shows *All*, *Before the action* or *After the action*. **New script** opens the
editor, where **When it runs** picks the timing and the event together, for example
*Before: File upload*. **See runs** opens the shared run history. A restored database
on a non-on-prem deployment runs none of its scripts.

## Events

| Event | Runs | Kind in the API | What the script receives |
| - | - | - | - |
| `file.upload` | before | middleware | a file a person brings in: file name, type, size, source, SHA-256, a text excerpt (first 64 KB of text-like files), and `file_path`, a copy of the whole file |
| `file.create` | before | middleware | the same, for every file the platform stores, uploads included |
| `file.uploaded` | after | hook | the same as `file.upload`, plus the file id |
| `file.created` | after | hook | the same as `file.uploaded`, for every stored file |
| `llm.request` | before | middleware | model, provider, purpose, the transcript as role and text (binary parts become size markers), the ids of the run, conversation, agent and workflow |
| `llm.response` | after | hook | the same identity, token counts, latency, whether it was cached, an excerpt of the answer |
| `api` | after | hook | route, method, action, resource type and id, the acting user or API key; the request body only when the script opts in, with secret-looking keys masked |

Every payload also carries `name`, `group_id`, `user_id`, `run_id` and `occurred_at`.
The editor's **Event** page names the dataclass `main(event)` receives and shows an
example payload; its **Fields** button lists every field with its type and meaning.

Chat calls made through the execution context's standard model builder are gated,
including cached replies, system instructions, workflow nodes, agents, Genie, current
V2 browser playbooks, verification models resolved in that context, and KB ingestion
using `ctx.kirby`. The registry hook also routes bare preset/model slugs in an active
execution context through that builder. Separate raw model clients without that context
are not covered. Decision models (including decision-based guardrail scans), embeddings,
rerankers, speech-to-text, text-to-speech, and legacy browser/audio model clients are
outside this chat-event contract. Model health probes are explicitly excluded because
a script denial must not suspend a healthy provider. `purpose` may be null.

The payload keeps the most recent 64 KB of transcript. `truncated` indicates discarded
content; a policy needing the whole transcript should deny truncated requests. Retry
checks include the current identity and script configuration, while repeated attempts
with the same identity, policy and transcript share a verdict.

File events cover writes through platform storage. Inline data URIs and channel
attachments that remain external URLs are not storage uploads and do not receive
file events. ZIP members stored by extraction are classified as imports (or chat files
for agent attachments) and are checked separately. A denied email attachment is removed
before bundling. Batch uploads commit per file; earlier accepted files can remain if a
later file is refused. Some channels drop denied attachments without a refusal reply.

`api` events are the audited, successful mutating calls: what the audit log records,
including calls made through the MCP server. Routes that opt out of auditing and calls
that failed produce no event.

### Uploads and created files

`file.create` fires for every file the platform stores: uploads, workflow node and
agent outputs, plugin files, files a connector syncs into a knowledge base, and
exports. `file.upload` is the subset a person brought in: uploads in the app, the API
and the embedded widget, knowledge base uploads, workflow imports, and attachments
that arrive through Slack, Teams, Telegram, Google Chat or email. An upload fires
both, `file.upload` first, so a policy on what users bring in binds to `file.upload`
and a policy on every file binds to `file.create`. The hook events `file.uploaded` and
`file.created` follow the same rule. Files the platform keeps for itself, such as
spilled node outputs and run previews, fire neither. The files a workflow unpacks
from an uploaded zip are created files: `file.upload` sees the archive, `file.create`
sees each member.

Every file event says where the file came from in `event.source`, so one script can
treat sources differently:

| `source` | The file came from |
| - | - |
| `app` | an upload in the app: run inputs, chat attachments, the files page |
| `widget` | an upload in the embedded chat widget |
| `api` | an upload through the public API |
| `knowledge_base` | a knowledge base upload |
| `chat` | a Slack, Teams, Telegram or Google Chat attachment |
| `email` | an email attachment |
| `case` | an attachment on a case |
| `import` | a file inside an imported workflow or agent |
| `workflow` | a workflow's output |
| `agent` | an agent's output |
| `plugin` | a plugin |
| `connector` | a file a connector synced, such as Google Drive or Notion |
| `platform` | anything else the platform made |

The first eight are uploads and also fire `file.upload`. `event.format` is the file's
extension in lowercase (`pdf`, `csv`, `docx`), and `event.content_type` its MIME type.

## Reading the file

A file event does not hand the script an open file. It copies the file into the
script's working directory and puts the name of that copy in `event.file_path`,
so the script opens it like any local file and the document parsers take the path:

```python theme={null}
from noxus_script import Deny, FileUpload
from pypdf import PdfReader


def main(event: FileUpload) -> Deny | None:
    if event.file_path is None:
        return None  # a test run without a file
    if event.file_path.endswith(".pdf"):
        if len(PdfReader(event.file_path).pages) > 200:
            return Deny("PDFs over 200 pages are not accepted")
    return None
```

The copy keeps the original base name (path separators stripped) so parsers that key
off the extension work. A `file.upload` middleware always gets the whole upload,
whatever its size: a secret scanner has to see every byte, and `text_excerpt` is only a
convenience for the first 64 KB of text. Read big files in chunks rather than into
memory; the copy counts against the script's budget. For `file.uploaded` the worker
downloads the stored file before the hook runs, up to `EXTENSIONS_MAX_FILE_BYTES`
(64 MB by default); above that the hook still runs with `file_path` set to `None`. The
copy is deleted with the working directory when the script finishes.

## Writing a script

A script defines one function, `main(event)`, and imports its types from
`noxus_script`:

```python theme={null}
from noxus_script import Deny, FileUpload


def main(event: FileUpload) -> Deny | None:
    if "BEGIN PRIVATE KEY" in (event.text_excerpt or ""):
        return Deny("Looks like a private key")
    return None
```

* `event` is a frozen dataclass for the event the script binds to, so its fields are
  typed attributes: `FileUpload`, `FileUploaded`, `LLMRequest` (whose `messages` are
  `LLMMessage` dataclasses), `LLMResponse` and `ApiCall`. The Event page lists each
  field with its type, and the editor completes them after `event.`. The module is
  built from the platform's event definitions, so it always matches what is sent.
* For a middleware, return `Deny("reason")` to block. Returning `None` or `Allow()`
  lets the action through. A plain `{"allow": False, "reason": "..."}` dict works
  too.
* The reason is the message the user sees, wherever they uploaded the file or made
  the request: the run form, a knowledge base, a chat, a table, the API. A refused
  model call shows it as the workflow run's error, in agent and Genie chat, in a
  deployed widget even when its error banners are hidden, and as the reply on Slack,
  Teams, WhatsApp, Telegram and Google Chat. Leave it out
  (or return an empty one) and the action just fails with a plain message, *This file
  could not be uploaded.* or *This request could not be completed.*, that does not say
  a middleware blocked it. The script's name never reaches the user; the run history
  keeps it, with the reason or the failure, for admins.
* For a hook, the return value is ignored.
* Scripts run in a Python environment of their own with the standard library plus
  `requests`, `pypdf`, `python-docx`, `openpyxl`, `python-pptx`, `beautifulsoup4`,
  `lxml`, `pyyaml`, `python-dateutil` and `chardet`. The editor lists what the running
  deployment actually provides. Platform packages are never importable.
* Outbound HTTP is disabled by default; set `EXTENSIONS_ALLOW_NETWORK=true` only with an external egress policy to allow
  the network modules when a script is saved and, on kernels that support it, TCP at
  the kernel. Subprocesses, `ctypes`, `multiprocessing` and similar modules are always
  refused.
* The interpreter runs with an empty environment, hard CPU, memory and process limits,
  and a Landlock ruleset (Linux 5.13 or later) that leaves the filesystem readable only
  under the interpreter's own trees and the files name resolution and TLS need, and
  writable only in a throwaway working directory. On a kernel without Landlock the run
  fails instead of running unconfined; set `EXTENSIONS_REQUIRE_SANDBOX=false` only if
  you accept that.
* The environment is built into the platform image at `/opt/extensions-venv`; a
  developer checkout builds it with `make extensions-venv`, and `EXTENSIONS_PYTHON`
  points at a different interpreter if you maintain your own. Without one, scripts
  get the standard library only.
* `print()` goes to the run's captured output, which you can read in the run history.
* The editor completes as you type: the modules this deployment's scripts can import,
  a module's functions and classes with their signature after `module.` or
  `from module import`, and the event's fields after `event.`. The list is read from
  the scripts' own interpreter, so blocked modules are never suggested.
* A new script starts from a sample for its event, which follows the event until you
  edit it. **Load sample code** on the Script page swaps in the sample for the current
  event, after asking first if it would replace code you wrote.

## Testing a script

The editor's **Test** page runs the saved script against the event's sample payload,
or one you edit. For the four file events, the payload section takes a real file: its name, type, size, hash and text excerpt replace the payload's file
fields, and it is copied in as `event.file_path` the way a live upload is. The file is
never stored, so a file middleware does not gate its own test. Test runs are recorded,
marked as tests, in **See runs** and under the Tests filter of the script's Runs page,
and never touch live traffic.

## Mode, failure policy and scope

The **Mode** select, in each row of the list and on the editor's
Overview page, sets what a script does: one that runs before an action is *Disabled*,
in *Dry run* or *Enforcing*; one that runs after is *Disabled* or *Enabled*. A new
script that runs before starts in dry run, where it records what it would have blocked
without blocking anything. Watch the run history, then switch to Enforcing once it only
blocks what you expect. Moving a script out of Enforcing from the list asks you to
confirm, because the actions it was blocking go through from then on.

If a middleware crashes or runs out of time, the **failure policy** decides: *fail closed*
(the default) blocks the action with the same plain message as a denial without a
reason; *fail open* lets it through. Either way the run history records the error.

A script applies to every workspace unless you scope it to one. An `api` hook can be
narrowed to a resource type and an action, chosen from dropdowns that default to
*Any (\*)*, and must opt in to receive request bodies.

## Run history

**See runs** on Scripts lists every run after an action and every run before
an action that denied, failed, timed out or would have denied in dry run, with the same
Before and After filter as the list; the editor's **Runs** page
shows the same for one script, with outcome (*Failures*, *Would block*) and *Live* or
*Tests* filters. Both lists have a search that matches the script name, the event, the
reason, the error, the resource and the captured output. Clicking a run opens its
details in place: outcome, reason or error, the resource it looked at, and the captured
output. Allowed middleware runs are counted in metrics but not stored. Rows are
pruned after `EXTENSIONS_RUN_RETENTION_DAYS` (30 by default).

A run row keeps the last 4 KB of what the script printed and the error text, and every
platform administrator can read it. Print identifiers, not the content the script looked
at.

## Managing scripts over the API

Every `/extensions` endpoint also accepts the deployment's admin key in the
`X-API-Key` header, so scripts can be provisioned by tooling or a configuration
management run without a browser session. Scripts created this way have no creator.
The key is `ADMIN_API_KEY` in the backend environment.

```bash theme={null}
curl -s -X POST "$NOXUS_URL/extensions/scripts" \
  -H "X-API-Key: $ADMIN_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "kind": "middleware",
    "event": "file.upload",
    "name": "Block private keys",
    "source": "from noxus_script import Deny, FileUpload\n\ndef main(event: FileUpload):\n    if \"BEGIN PRIVATE KEY\" in (event.text_excerpt or \"\"):\n        return Deny(\"Looks like a private key\")\n    return None\n",
    "enabled": true,
    "dry_run": false,
    "on_error": "fail_closed",
    "timeout_ms": 2000
  }'
```

| Method and path | What it does |
| - | - |
| `GET /extensions/runtime` | the interpreter, its packages, the budget cap and whether the network is allowed |
| `GET /extensions/events` | the event catalog with each payload's schema and sample |
| `GET /extensions/scripts?kind=` | list scripts |
| `POST /extensions/scripts` | create one; the body is validated like the editor's Create |
| `POST /extensions/scripts/validate` | static validation of a `source` without saving |
| `GET`, `PATCH`, `DELETE /extensions/scripts/{id}` | read, update (any subset of fields, `group_ids` with `clear_group_ids`), delete |
| `POST /extensions/scripts/{id}/test` | run it against the sample or an `event` you pass |
| `GET /extensions/scripts/{id}/runs`, `GET /extensions/runs` | run history, paginated |

Any other key, and any session that is not a platform administrator, gets 401 or 403;
on a deployment that is not on-prem the paths do not exist.

## Limits and guardrails

A script runs admin-written code inside a backend or worker process's host, so every
run is boxed in, and a broken or heavy script degrades into its own failure policy
rather than into an outage. The editor's Dependencies and limitations page, opened from
the button under the script, lists the limits in force and the available libraries.

* **Each run** is a fresh interpreter with an empty environment: 2 s wall clock
  (including copying a staged file in), 2 s of CPU, `EXTENSIONS_MEMORY_MB` (256) of
  address space, 1 MB per written file, 64 open files, and at most
  `EXTENSIONS_WORKDIR_MB` (64) and `EXTENSIONS_WORKDIR_FILES` (256) written in all;
  past those the script is killed.
* **The kernel decides who dies.** The interpreter runs at lower CPU priority and with
  the highest OOM score, so under memory pressure the kernel prefers the script. Container-level OOM
  policies can still terminate the whole pod. When the platform runs as root, the script runs as an
  unprivileged user (uid 65534), so it shares no user with the platform and holds no
  capabilities. A seccomp filter lets it signal, re-limit or reschedule only itself,
  never fork or trace, and open only TCP sockets (none at all with the network off).
  Landlock keeps the filesystem read-only outside a throwaway working directory.
* **Network.** Network access is disabled by default. To enable outbound HTTP, set
  `EXTENSIONS_ALLOW_NETWORK=true`, enforce a deployment egress policy that prevents
  scripts from reaching private, loopback and metadata destinations directly, and set
  `EXTENSIONS_ASSUME_EGRESS_POLICY=true` to attest that policy. This is required on
  every Landlock ABI. Landlock limits destination ports, not destination addresses;
  the Python socket wrapper is convenience, not an address-level security boundary.
  The loopback proxy resolves and pins destinations, checks the platform SSRF policy,
  and allows only `EXTENSIONS_ALLOWED_PORTS`. Its checks protect requests that pass
  through it. `EXTENSIONS_REQUIRE_SANDBOX=true` refuses a networked run without the
  external policy attestation. The disabled-network path uses kernel socket restrictions.
* **Bursts.** A process runs `EXTENSIONS_MAX_CONCURRENT` (8) scripts at once. A middleware run
  waits up to `EXTENSIONS_SLOT_WAIT_MS` (2000) for a slot, then gets its full budget,
  with at most `EXTENSIONS_MAX_QUEUED` (128) waiting. Worker hooks run sequentially
  within each job and wait for execution capacity without the inline admission deadline. A run the platform cannot serve
  (no slot, a full queue, too little disk) is refused at once, and a refused middleware
  blocks the action whatever its failure policy says: fail open means the script's own
  failures let the action through, never that flooding the platform skips the check.
  Size a pod for `EXTENSIONS_MAX_CONCURRENT` x `EXTENSIONS_MEMORY_MB` on top of its
  usual use.
* **Broken scripts.** A script that fails 5 times in a row on its own (error, timeout or
  rejection) is skipped for 60 s, its failure policy applying meanwhile (fail open lets the action
  through, fail closed blocks it), then gets one
  try; platform refusals do not count, and the Test page still runs it. Saving a new
  version starts it afresh.
* **Fan-out.** At most `EXTENSIONS_MAX_SCRIPTS_PER_EVENT` (5) enabled scripts per event
  and kind; several middleware on one event run concurrently and any denial blocks.
* **Disk.** An upload is written to disk once and copied, read-only, into each
  script's working directory. Staging keeps `EXTENSIONS_MIN_FREE_DISK_MB` (1024) free
  on the temp volume, or the action is refused; working directories a crashed process
  left behind are removed on start.
* **Records.** A script writes at most 300 live run rows a minute per process; the rest
  are counted in metrics only. Enforced blocks get ten times that room (3000 a minute). Rows are kept `EXTENSIONS_RUN_RETENTION_DAYS` (30) days.
* **Hooks** run on the worker's own `extensions` pool, `WORKER_EXTENSIONS_CONCURRENCY`
  (4) at a time. An event older than `EXTENSIONS_HOOK_MAX_AGE_SECONDS` (900) when its
  turn comes is dropped, so a backlog after an outage does not replay stale side
  effects. An upload's `file.uploaded` and `file.created` hooks run in one job on one
  download of the file. Audit bodies over 64 KB are not passed on. A hook may see an event twice
  after a worker restart, and one can be lost if the process exits between committing
  the action and queueing the hook: make repeats harmless, and do not use hooks as the
  only record of anything. A queued hook job holds its event, so an `llm.response`
  answer excerpt or an opted-in `api` body sits in the job queue table until the job is
  cleaned up.
* **Sizes.** 64 KB of source, 64 KB of excerpt and transcript, 64 KB of result; 64 MB
  of downloaded file for a `file.uploaded` hook (an upload middleware gets the whole
  file).

Every stop is counted in `noxus_extensions_guardrail_total` by event and reason
(`queue_full`, `slot_wait`, `circuit_open`, `low_disk`, `hook_stale`,
`record_suppressed`, `body_dropped`, and `egress_blocked` with the event `network`).

On Docker Compose, the backend and worker containers need a seccomp profile that
allows the `landlock_*` syscalls and `seccomp` itself. Recent Docker defaults do; if
every run fails with "Landlock is not available", the engine's profile is older.

## Before and after in Settings

Middleware and hooks share one Scripts list, editor, test panel and run history.
Settings calls a middleware a script that runs *Before* the action and a hook one that
runs *After* it. Existing middleware and hook links still select the matching filter.
Choose when the script runs and its event under **When it runs** when creating it. An
existing script keeps its timing; create another script to move a policy from before
to after.

A script that runs before an action runs inline and can block it. One that runs after
it runs asynchronously and cannot change its outcome. Only scripts that run before
offer dry run and failure policy controls.

## Operational metrics

Import `cdk/grafana/dashboards/extensions.json` for evaluation outcomes, blocks,
admission pressure, phase latency, hook dispatch failures and event-loop responsiveness.
All executions use fresh interpreters. There is no warm pool.

When metrics sending is enabled, NCS also receives aggregate extension counters from
the latest backend, relay and worker process samples within five minutes. These are
snapshots, including test evaluations, not counts in the check-in reporting window.
Retries replace the snapshot. Values can fall after restarts or process expiry, and
work since a process's final sample can be lost. No script identifiers or payloads
are sent. The existing metrics-sending opt-out applies.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.