Elizon Docs
Workflows

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

FieldTypeDefaultDescription
languagepythonpythonPython 3.12. The field exists so other languages can be added later; Python is the only one today.
codetext(starter)Your code. It must define main(input, files).
inputExpressionexpressionemptyThe value main receives as input. A single {{ }} keeps its type (a list stays a list); literal JSON is parsed. Empty is None.
fileExpressionslist 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.
tableInputslist[]Up to 5 Data Tables read as files (see Reading a Data Table). Each entry is { name, tableId, where? }.
timeoutSecondsnumber30The 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 a Decimal as str(amount) and a date as day.isoformat(). NaN and Infinity are refused.
  • Each input file is readable at its path, under /workspace/in/.
  • Files you write directly into /workspace/out/ become output.files, stored like Convert to File output, so Send Email, AI Prompt and Data Table nodes can use them. Subdirectories of out/ are refused.
  • print() output is kept, truncated, as output.stdout.
  • Code under if __name__ == "__main__": does not run; only main is 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.

FieldDescription
nameThe file stem: letters, digits, - and _, up to 64 characters.
tableIdA table in this workflow's project. A table of another project or Organization is refused exactly like an unknown one.
whereOptional 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

FieldTypeDescription
resultJSONWhat main() returned: {{ upstream.<nodeId>.output.result.<key> }}.
fileslist of filesFiles written to /workspace/out/.
files.0fileThe first of them.
stdouttextWhat the code printed (the first 16,384 characters).
durationMsnumberRun time in milliseconds.
memoryBytesnumberPeak 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.

FailureWhen
missing_mainThe code defines no main.
exceptionmain (or the module itself) raised.
not_jsonThe return value is not JSON.
timeoutThe run passed its time limit (default 30 s, at most 120 s).
memoryThe run passed the memory limit (default 512 MB).
result_too_largeThe inline result is over 1.5 MB; write a file instead.
output_files_too_largeMore than 10 output files, one over 20 MB, or a file in a subdirectory of out/.
stdout_too_largeThe 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:

  1. Installs gVisor's runsc on the Docker host and registers it as a Docker runtime named runsc (in /etc/docker/daemon.json, then restarts Docker).
  2. Sets SKILL_EXECUTOR=gvisor on both the API gateway and the AI workers service. A deployment that isn't in production mode can still require the hardened sandbox with SANDBOX_REQUIRE_HARDENED=true on both.
  3. 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

workflows/open-items.yaml
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's tableId becomes table, a dataTables.<name> reference to a Data Table the Package declares and installs; name and where travel as written, and an undeclared table fails validate and build)
  • Never carried: none