Code
Run your own Python over upstream data and files, in an isolated sandbox.
A Code node (code) runs Python you write over the run's data and files: parsing a CSV or XLSX export, computing days overdue or statutory interest, scoring and bucketing rows. It covers the jobs the built-in nodes can't express. The code runs in a sandbox with no network, no secrets and no database, and nowhere else.
Configuration
| Field | Type | Default | Description |
|---|---|---|---|
language | python | python | Python 3.12. The field exists so other languages can be added later; Python is the only one today. |
code | text | (starter) | Your code. It must define main(input, files). |
inputExpression | expression | empty | The value main receives as input. A single {{ }} keeps its type (a list stays a list); literal JSON is parsed. Empty is None. |
fileExpressions | list of expressions | [] | Up to 10. Each resolves to a file reference (an FTP download, an email attachment, a Convert to File output) or a list of them. |
tableInputs | list | [] | Up to 5 Data Tables read as files (see Reading a Data Table). Each entry is { name, tableId, where? }. |
timeoutSeconds | number | 30 | The time limit, at most 120 seconds. Your deployment may set a lower maximum; the node's value is capped to it. |
The convention
def main(input, files):
# input: the value of the Input expression
# files: a list of {name, path, content_type, size}, one per input file
return {"rows": 3}What main() returns is under output.result
main() returning {"rows": 3} is read as {{ upstream.<id>.output.result.rows }};
output.rows does not exist. The node's output wraps the return value with files, stdout,
durationMs and memoryBytes, so every returned key goes through .result. See
Output.
- The return value must be JSON: a dict, list, string, number, boolean or
None. Return aDecimalasstr(amount)and a date asday.isoformat(). NaN and Infinity are refused. - Each input file is readable at its
path, under/workspace/in/. - Files you write directly into
/workspace/out/becomeoutput.files, stored like Convert to File output, so Send Email, AI Prompt and Data Table nodes can use them. Subdirectories ofout/are refused. print()output is kept, truncated, asoutput.stdout.- Code under
if __name__ == "__main__":does not run; onlymainis called.
Libraries
The Python standard library (csv, json, datetime, decimal, re, math, statistics and the rest), plus openpyxl 3.1.5 for XLSX. Nothing can be installed: the sandbox has no network and no pip. This list is part of the node's contract, so a Package may rely on exactly these libraries.
Reading a Data Table
Add a Data Table under Tables and the gateway reads its rows in the workflow's project before your code runs, then mounts them as a JSONL file (one row per line) at /workspace/in/<n>-<name>.jsonl. <n> is the file's position among all input files, counting from 1, and the file is listed in files like any other.
| Field | Description |
|---|---|
name | The file stem: letters, digits, - and _, up to 64 characters. |
tableId | A table in this workflow's project. A table of another project or Organization is refused exactly like an unknown one. |
where | Optional conditions, with the same operators and column rules as the Data Table node. Values are literal; they are not expressions. |
The rows count against the same input-file total as your other files (50 MB by default). If they do not fit, the node fails and names the table; add a condition to read fewer rows. The sandbox is unchanged: it still has no network, no secrets and no database, and your code cannot write the table.
import json
def main(input, files):
prices = {}
for f in files:
if f["name"].endswith("prices.jsonl"):
with open(f["path"]) as fh:
for line in fh:
row = json.loads(line)
prices[row["Sku"]] = row["Price"]
return {"skus": len(prices)}Writing the result back
A Code node has no write option of its own. Write the rows you want stored to /workspace/out/rows.jsonl, then follow the node with a Data Table node set to Bulk upsert from a file, pointing at {{ upstream.<code node id>.output.files.0 }}. The Data Table node already takes the table lock and applies the row and size limits, so the same rules cover rows from a Code node.
To read a large table in a separate step, a Data Table node can also return rows as a file, which you then pass to the Code node under Files.
Large data
An inline result is limited to 1.5 MB. For anything larger, return a summary and write the rows to a file, for example /workspace/out/rows.jsonl with one JSON object per line. A Data Table node's Insert or Bulk upsert reads that file directly, so 15,000 rows never pass through the workflow as JSON.
Inside a Loop
The code runs once per Loop iteration, each time in a fresh sandbox. Where you can, pass the whole list in one run instead.
Output
| Field | Type | Description |
|---|---|---|
result | JSON | What main() returned: {{ upstream.<nodeId>.output.result.<key> }}. |
files | list of files | Files written to /workspace/out/. |
files.0 | file | The first of them. |
stdout | text | What the code printed (the first 16,384 characters). |
durationMs | number | Run time in milliseconds. |
memoryBytes | number | Peak memory as sampled, or null for a run too short to sample. |
Limits and errors
Each failure fails the node with its own message in the run trace. An exception shows the last lines of its traceback, with main.py line numbers and no server paths.
| Failure | When |
|---|---|
missing_main | The code defines no main. |
exception | main (or the module itself) raised. |
not_json | The return value is not JSON. |
timeout | The run passed its time limit (default 30 s, at most 120 s). |
memory | The run passed the memory limit (default 512 MB). |
result_too_large | The inline result is over 1.5 MB; write a file instead. |
output_files_too_large | More than 10 output files, one over 20 MB, or a file in a subdirectory of out/. |
stdout_too_large | The code printed more than 1 MB. |
Input files are limited to 50 MB in total, and the sandbox has one CPU. An administrator sets each limit per deployment with an environment variable (CODE_NODE_TIMEOUT_MAX_SECONDS, CODE_NODE_MEMORY_MB, CODE_NODE_CPUS, CODE_NODE_MAX_RESULT_BYTES, CODE_NODE_MAX_OUTPUT_FILES, CODE_NODE_MAX_OUTPUT_FILE_BYTES, CODE_NODE_MAX_INPUT_FILE_BYTES), and each message names the variable it hit.
A {{secret.…}} reference in the code, or in the input the node receives, is refused. If the code needs a service, call it from an HTTP Request node instead.
A production deployment runs Code nodes only on a hardened sandbox. Without one, the node fails with a message saying so; it never falls back to the development sandbox.
The hardened sandbox is gVisor. To turn it on, an administrator:
- Installs gVisor's
runscon the Docker host and registers it as a Docker runtime namedrunsc(in/etc/docker/daemon.json, then restarts Docker). - Sets
SKILL_EXECUTOR=gvisoron both the API gateway and the AI workers service. A deployment that isn't in production mode can still require the hardened sandbox withSANDBOX_REQUIRE_HARDENED=trueon both. - Gives the AI workers service the host's Docker socket (
/var/run/docker.sock), which it uses to start each run's sandbox, and makes sure the Code sandbox image is already on the host. The sandbox has no network, so it can't download the image itself.
The Docker socket gives full control of the host, so keep the AI workers service off any public network. If the host's Docker has no runsc runtime, every Code node fails with a message naming it. Skill scripts use the same sandbox, so they move to gVisor too.
Every run is metered as one Skill execution.
Example
name: Open items
description: Parses the ERP export and writes one row per invoice, with days overdue.
graph:
nodes:
- id: start
type: trigger_manual
position: { x: 0, y: 0 }
data: {}
- id: parse
type: code
position: { x: 280, y: 0 }
data:
language: python
inputExpression: '{"today": "2026-10-10"}'
fileExpressions: []
timeoutSeconds: 60
code: |
import json
from datetime import date
from decimal import Decimal
def main(input, files):
today = date.fromisoformat(input["today"])
rows = [{"Invoice": "INV-1", "Amount": "120.50", "Due": "2026-09-30"}]
total = Decimal("0")
with open("/workspace/out/rows.jsonl", "w") as out:
for row in rows:
total += Decimal(row["Amount"])
due = date.fromisoformat(row["Due"])
row["DaysOverdue"] = (today - due).days
out.write(json.dumps(row) + "\n")
return {"rows": len(rows), "total": str(total)}
edges:
- { id: start-to-parse, source: start, target: parse }Reading this node's result
A later node reads the returned total as {{ upstream.parse.output.result.total }} and the
written file as {{ upstream.parse.output.files.0 }}. {{ upstream.parse.output.total }} does
not exist and fails the node that reads it.
In a Package
The node names nothing in your Organization, so everything travels. When you build a Package, the code is checked like any other text: a {{secret.…}} reference in it refuses the build. An address-shaped match in the source (such as x@self.weights or logo@2x.png) is a PORTABLE_MACHINE_CONTENT_EMAIL warning, not a refusal; a person's address must still go. See Carried text. npx @elizon/cli validate and build also scan the code and warn about network, subprocess or /proc use. The install wizard runs the same scan on the target instance, and the operator acknowledges each flagged node before installing.
- Travels:
language,code,inputExpression,fileExpressions,timeoutSeconds - Converted:
tableInputs→tableInputs(each entry'stableIdbecomestable, adataTables.<name>reference to a Data Table the Package declares and installs;nameandwheretravel as written, and an undeclared table failsvalidateandbuild) - Never carried: none