JavaScript in a config
Most shaping is better done with transforms, markers and assertions — they are declarative, they show up in the generated docs, and the fast path can compile them. But some jobs are genuinely easier as a few lines of code: a recursive walk over an unknown shape, a bespoke grouping, a string format nobody should have to express as a marker chain.
The code: action runs JavaScript over an action's input.
- name: Normalise
code: |
return items.map(i => ({ json: { id: i.json.id, total: i.json.amount * 1.1 } }));
Whatever you return becomes the action's data, readable by later actions the same way any
other action's is — a|Normalise::0->json->total|.
What the script can see
| binding | is |
|---|---|
items | the input, always an array of { json, binary } |
item, $json | the current item, in each_item mode |
$input | .all(), .first(), .last(), .item |
$('ActionName') | any earlier action's result, by name — the same name `a |
$now, $today | the current time, as a DateTime |
$workflow, $execution, $node | names and ids, for logging |
A bare value is wrapped as { json: … } on the way in, so i.json works whatever the previous
action produced.
The environment is an ordinary modern one: Buffer, setTimeout and friends, btoa/atob,
TextEncoder/TextDecoder, FormData, console, Intl, URL, URLSearchParams,
AbortController, the web stream primitives, and Luxon's DateTime / Duration / Interval.
The language is current — optional chaining, Array.at, findLast, Object.groupBy,
async/await, generators, BigInt.
What it deliberately cannot do
There is no filesystem, no socket and no outbound network. That is the design, not an omission: the script is a pure function of its input.
Reaching outward is the http: action or an interface called as a
tool, both of which already carry retries, timeouts, metering and your credentials — none of
which a script should be re-implementing, and none of which it can be trusted to.
Scripts can use npm packages, but only ones you declare — see Modules below.
Request data never reaches the script's text. Splicing it into a program is code injection,
for the same reason database parameters are bound rather than concatenated — so a request-scoped
marker inside source is left alone, and the data arrives through items and the bindings
instead. Config-level values, which are fixed at deploy time and are not attacker-controlled, do
substitute:
marker inside source | |
|---|---|
| `a | var::my_setting |
| `a | body::field |
| `a | uuid |
The consequence worth knowing: a per-request decision cannot be made by interpolating into the script. Resolve the value in a config field and read it with a binding:
- name: Auth
json_output: |-
{ "allowed_id": "a|ap_var::ALLOWED_USER_ID|" }
- name: Check
depends_on: [Auth]
code: |
const allowed = $('Auth').first().json.allowed_id;
Reading another action
$('Name') reads an earlier action's result — but it is a dependency the config does not see,
because it lives in the script rather than in a marker. Declare it, or the two actions run
concurrently and the script reads nothing:
- name: Summarise
depends_on: [FetchOrders] # required — $() alone does not order anything
code: |
return { n: $('FetchOrders').first().json.length };
Prefer depends_on over run_when_succeeded here. A script usually reads a branch that may be
skipped, and run_when_succeeded on a skipped action stops the reader running at all, whereas
depends_on only orders them.
The config linter warns about an undeclared binding at load.
Options
- name: Normalise
code:
source: |
const rate = 1.1;
return items.map(i => ({ json: { id: i.json.id, total: i.json.amount * rate } }));
mode: all_items # all_items (default) | each_item
timeout_ms: 5000
memory_bytes: 33554432
| option | default | |
|---|---|---|
source | — | required. Wrapped in an async function, so top-level await works |
mode | all_items | all_items: one run, items is the array. each_item: one run per item, results collected into an array |
timeout_ms | 5000 | wall clock for the script itself |
memory_bytes | 32 MiB | heap ceiling for one execution |
language | js | only js today |
dependencies | none | npm packages the script may import, as name to version range — see Modules |
Use the standard input: field to choose what becomes items. With none, it is the previous
action's data.
- name: Summarise
input: a|FetchOrders|
code: |
return { count: items.length, total: items.reduce((n, i) => n + i.json.amount, 0) };
Modules
A script can require an npm package, provided the config declares it:
- name: Format
code:
dependencies:
date-fns: ^3.6.0
source: |
const { format } = require("date-fns");
return items.map(i => ({ json: { when: format(new Date(i.json.ts), "yyyy-MM-dd") } }));
Declaring is not decoration — it is the boundary. A script can only reach what is in its own
dependencies block, and a require of anything else fails:
Cannot find module 'lodash'
Dependencies are declared per action, beside the script, rather than once per config. What a script may reach is then visible in the same place a reviewer looks for the script itself.
Which runtime runs a script
Two runtimes, and the script chooses, not the configuration:
| the script | runs in | needs |
|---|---|---|
| imports nothing | the embedded interpreter | nothing — always available |
| imports anything | the JavaScript runtime worker | the worker installed on the host |
So the same script always runs in exactly one place, on every instance and in every mode. The split exists because the worker carries a full JavaScript engine and is around 100 MB, while the scripts that need it are a small minority — bundling it would cost every user for a feature few use.
If a script imports something and no worker is installed, the action fails:
the script in 'MyConfig::Format' imports a module, which needs the JavaScript runtime.
It is not installed. Install it with `airpipe runtime install js-deno`, or set
AIRPIPE__SCRIPT_RUNTIME_JS to a worker binary. Scripts that import nothing keep working
without it.
It never quietly runs the script without the module. A script that asked for date-fns and did
not get it would fail somewhere further in with a confusing error — or worse, appear to succeed
having done less than it said.
Installing the runtime
Self-hosted, one command per host — see Script runtimes for the full command:
airpipe runtime install js-deno
Packages are resolved on first use and cached on disk, not fetched while a request is in flight. Each distinct set of dependencies gets its own store, so two configs asking for different versions of the same package never collide.
Pin exact versions if it matters to you: ^3.6.0 means the code that runs is not necessarily the
code you reviewed.
Module support is a self-hosted capability today. On Air Pipe's managed platform the runtime worker is not installed, so scripts that import a module are refused. Scripts that import nothing run normally.
The limits are real
Both bounds are enforced by the engine, not requested politely.
timeout_ms is applied by an interrupt inside the interpreter, so a script that never yields —
while (true) {} — is genuinely stopped, rather than left running while something else gives up
waiting. The clock starts once the environment is ready, so the budget is your script's time and
not ours.
memory_bytes caps the heap for that one execution. An unbounded allocation fails the action;
it does not touch the process.
Both are capped by your organisation's entitlement, so asking for more than your plan allows
gets you the ceiling rather than an error — the same config runs everywhere and is simply
bounded differently. max_script_ms and max_script_memory_bytes scale with your plan; a
self-hosted engine reads its own, generous, defaults because it is your CPU.
Each execution gets a fresh runtime. Nothing a script leaves behind is visible to the next one, in your organisation or anyone else's.
Errors
A throw fails that action with the script's own message, so you can tell your bug from ours:
the vendor id was missing
Type errors use the wording every JS developer already knows — Cannot read properties of undefined (reading 'x') — because that is what Chrome, Node, Deno and Bun all say, and real
code branches on e.message.
Hitting a limit says which one:
the script did not finish within 5000ms
the script used more than its 32 MiB of memory
See also
- Transforms — the declarative option, and usually the better one.
- Inputs — how
input:and markers choose an action's data. - Vector search — another thing that needed no new action type.