3 · Procedures
What you will have done: understood why a status check should be one call rather than six, read the eleven procedures and what each answers, and learned why the twelfth tool is behind a scope.
The problem a procedure solves
Asking a simple question costs one tool call per route. How is this campaign doing? means the prospects, the people, the sends, the held queue, the replies and the spend: six round trips, six raw JSON bodies, and a model reading all of it to produce two sentences a person wanted.
Every one of those round trips leaves the building, crosses the network twice, and arrives back as tokens somebody pays for.
A procedure moves the six calls to where the routes already are.
How it runs
Neutron carries a small Lua runtime called assay in the same pod as the API, and assay speaks the instance’s own API through a module called assay.neutron. A procedure is a Lua file shipped inside the image. Neutron spawns the pinned assay binary, hands it the caller’s own bearer, runs the file under a timeout with its output capped, and returns what it printed.
sequenceDiagram
participant H as Your harness
participant M as POST /mcp
participant A as assay, in the pod
participant R as The instance's routes
H->>M: tools/call campaign_status
M->>A: spawn, with the caller's own token
A->>R: prospects, over loopback
A->>R: people
A->>R: sends
A->>R: held
A->>R: replies
A->>R: spend
R-->>A: six answers
A-->>M: one digest
M-->>H: one result, one audit row
Three things follow from running it there.
It reaches the API over the loopback port, not through a shortcut inside the process. That is the whole design: every route gate, every scope check and every audit trail applies to a procedure exactly as it applies to a tool call. A procedure cannot reach anything its caller could not reach by hand, and a token without wire is refused inside a procedure exactly as it is refused outside one.
A read-only procedure runs under the runtime’s read-only gate. A write dies inside the runtime whatever the script says. The declaration in the file is the control, not a convention, and the instance refuses to start if a file declares anything else.
The same file serves three callers. A procedure is registered once and exposed as a named /mcp tool, as a tool the instance’s own in-chat agent can call, and to the local command-line tooling. One definition, one schema, three doors, so the two surfaces cannot drift.
The eleven
Seven read, four write. The name is the tool name, with no prefix.
| Read-only | What it answers in one call |
|---|---|
campaign_status | One campaign whole: prospects by stage, people by verification state, held letters by reason, replies by label since a date, spend against budget, deals by state. |
held_queue | Every letter the queue is holding, grouped by the reason, with the sentence naming each one. A queued letter is waiting on a reviewer and carries no reason of its own. |
inbox_digest | Threads nobody has answered, grouped by the reading they were given. Each carries the person, their company and the first line of the last turn, never the letter. |
fleet_health | The sending estate: each fleet’s boxes and how many are warm, the domains with their state, placement, heat and daily cap, and the attention lines the fleet view raises. |
costs_report | Each vendor’s month to date, the totals per currency, the budgets, and what each campaign has spent against its cap. |
claims_list | The evidence a letter may cite, with its proof and the date it stops being citable. |
work_status | One project’s board: the count in each column, what waits in review, what is ready, and every plan with the status it has reached. |
| Mutating | What it does |
|---|---|
campaign_verify_press | One Verify pass: count the people by verification state, run the verifier, count them again. The route verifies at most ten people per call. |
reply_correct | Corrects the reading given to one inbound turn and reports what the correction moved. The label must be one of the seven readings, never TO_TRIAGE. |
deal_advance | Moves one deal to its next state and reads the stored deal back. A deal cannot move backwards or sideways. |
suppression_propose | Puts an address or a domain on the do-not-contact list and reads it back. |
Notice the shape of the last one. It only ever adds. Lifting a suppression is a person’s decision, and no procedure calls it.
Notice the shape of the others too. Each mutating procedure reports what the ledger now holds rather than what was asked for, because on a forward-only spine those are different answers and only one of them is true.
A worked example
Ask for a campaign’s state.
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"campaign_status",
"arguments":{"campaign":"fufld-denmark"}}}What comes back is a digest rather than six bodies. The round trips for the five figures on journey 14 go from six to one.
The run tool
Some questions do not have a procedure, and writing one into the image for a question asked once is the wrong shape. run takes a Lua script, runs it in the same place with assay.neutron already pointed at the instance’s own API as you, and returns what the script returns. End the script with return <value>.
Its own description says where it may go, and the sentence is worth reading twice: a script may call this instance’s API as the token, many calls in one go, and nothing else: no shell, no files, no other hosts.
That is enforced rather than advised, and the next section is how.
Grant scripts to a token you are driving yourself. A named procedure is a reviewed file in the image; run is whatever the caller typed, and the confinement is what stands between those two facts. A token minted without the scope is refused before anything is spawned, and the refusal is audited:
this token cannot run scripts: mint one with the scripts scope in Settings → Access & Security → API
What confines a script
Four bounds, applied together. They cover a named procedure and an ad hoc run alike.
Every run is read-only. The runtime’s read-only mode is on for all of them, which is what keeps shell, process and file writes shut. A mutating procedure is not granted the gate. Its policy admits write methods through it for one class of target, loopback, and nothing else moves.
A policy file, loaded into the runtime. Two ship in the image, one for read-only procedures and one for mutating procedures and run. A section that is absent would mean unrestricted, so every section in them is deliberate:
| The bound | What it permits |
|---|---|
| Modules | assay.neutron and assay.url. A script cannot require its way to a module that talks to a vendor, a cluster or a package manager. |
| Environment | The three keys the client itself needs. The child is handed no other key, so this is the second of two bounds rather than the only one. |
| HTTP | Loopback only. Rules being present means default deny, so a request matching nothing is refused before the socket opens. |
| Redaction | Passwords, tokens, secrets, authorization headers and API keys are stripped from what a run can print. |
The mutating policy adds one three-line rule to the HTTP section: POST, PUT, PATCH and DELETE to loopback, classified as a read so they pass the read-only gate. It is one rule over every loopback path, shared by the four mutating procedures and by run, so the policy is not what decides which write is allowed.
What decides that is the line above: a write to this instance’s own API is still a write the caller is allowed to make. The route gates and the token’s own scopes are the bound, and a token without wire is refused inside a procedure exactly as it is outside one. The policy’s job is narrower, and it is the one worth stating plainly: a script may reach loopback and nothing else.
An exact environment, not an inherited one. The child process is built a fresh environment holding the runtime’s own settings and the instance’s URL and token. Nothing from the API process is passed through.
A prelude that clears what the policy cannot. The runtime’s own globals register after its block list is applied, so the block list cannot reach them. Every procedure requires a shared file first, and that file clears the filesystem, database, DNS, WebSocket and IO globals before anything else runs. The same prelude is prepended to a run script. The registry refuses to load a procedure whose source does not contain require("_lib"), which it looks for as text anywhere in the file rather than by running anything.
Alongside those, the Lua escapes the policy cannot see are cleared before the builtins register: load, dofile, loadfile, debug, package.loadlib, and five os calls, the ones that execute, exit, remove, rename or name a temporary file.
Limits and the record
| Timeout | 60 seconds, with an outer kill five seconds later for a child that ignores its own deadline. |
| Result cap | 48,000 characters, then truncated with a note saying how long it really was. |
| Audit row | One per run, action procedure.run, naming the procedure, the caller, whether it succeeded and how long it took. |
The audit row records a run call’s script by length alone. The source is never logged, and neither is the credential.
A refused run is recorded too. Asking for run without the scope leaves a row, so a token probing for what it may do is a token you can see probing.
Writing one
A procedure is a Lua file under apps/api/lua/procedures/ in the Neutron repository. A header names it, describes it and declares its mode, and the registry renders that header into the tool’s schema and its advertised description. It uses assay.neutron to call the instance and returns its digest. A name that collides with a built-in tool stops the instance at boot rather than shadowing it.
The rule for writing one is the rule for every tool here. Call the routes; do not reach past them. A procedure that read the database directly would be the first thing in this product that a gate does not stand in front of.