Scripts

Run custom JavaScript in an isolated sandbox with the run_script operation, or save reusable scripts for your flows.

#Scripts

Scripts let a flow run custom JavaScript when the built-in operations and JSONata expressions aren't enough — multi-step logic, conditional API calls, or combining data access with computation.

Scripts are the heavyweight option. For querying, filtering, and transforming data, use the run_expression operation instead: it's always available, needs no setup, and can't have side effects.

#How the sandbox works

Each script runs in its own V8 isolate, separate from the EmuView gateway, using Cloudflare Dynamic Workers. The isolation is strict:

  • The script has no access to EmuView's environment bindings, secrets, or database.
  • All outbound traffic — fetch() calls and the collections API — is routed back through a controlled bridge that enforces your domain allowlist and access-control rules.
  • The script receives a serialised copy of the flow context. Mutating $trigger or $steps inside the script does not affect the rest of the flow — communicate through the return value.
Important

Script execution requires the Dynamic Workers capability on the server. When it isn't available, run_script steps fail closed with an error rather than running code with weaker isolation. run_expression works everywhere.

#What a script can access

Name Description
$trigger The trigger payload
$data Shorthand for $trigger.body, merged with the step's input config
$steps Outputs of previous steps
$user The user the flow runs as (id, email, name, role, projectId)
$flow Flow metadata (id, name, version)
$env Environment variables explicitly allowlisted in the step config — empty by default
$selection Selected records, for manual triggers
log(...args) Write to the step log (up to 500 lines are captured)
sleep(ms) Pause, capped at 5 seconds per call
fetch(url, init) Outbound HTTP — only to domains in the step's allowlist
collections Data access that respects the flow's Run As permissions (see below)

#The collections API

Method Description
collections.get(collection, id) Fetch one record by ID (throws if not found)
collections.list(collection, filter, options?) List records matching a filter. options accepts { limit, offset } for paging
collections.create(collection, data) Create a record
collections.update(collection, id, data) Update a record
collections.delete(collection, id) Delete a record

Every call goes through the same access-control checks as the REST API, using the identity the flow runs as.

Paging collections.list. When options is omitted the list returns at most 100 records — enough for most flows, but it will silently truncate a larger result set. Pass { limit, offset } to page through everything (for example a fan-out over every subscriber to a popular topic). limit may be up to 10000 per call; use offset to walk subsequent pages:

// Read every matching record in pages of 1000.
const page = await collections.list(
	'subscriptions',
	{ topic_id: topicId },
	{ limit: 1000, offset: 0 }
);

#Writing a script

The script body runs inside an async function, so await works at the top level. Whatever you return becomes the step's output:

// Summarise the order that triggered this flow
const order = $trigger.body;
const customer = await collections.get('customers', order.customer_id);

log('Processing order', order.id);

const total = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);

return {
	customerEmail: customer.email,
	itemCount: order.items.length,
	total
};

A later step reads the result as {{$steps.<step_key>.output.customerEmail}}. The full step output is:

Field Description
output The script's return value (null if nothing is returned)
stats Execution stats: cpuTimeMs, wallTimeMs, fetchCount, dbQueryCount
logs Lines written with log()

If the script throws, the step fails with the error message — the step-level onError setting decides what happens next.

#Inline code vs saved scripts

The run_script operation accepts either inline code or a scriptId pointing to a saved script. Saved scripts are reusable across flows and carry their own settings:

Setting Default Description
name Display name (required)
description empty What the script does
code The JavaScript source (required)
max_cpu_ms 5000 CPU time limit for this script
max_memory_mb 128 Memory limit
allowed_hosts [] Domains the script may fetch

Manage saved scripts in the dashboard or through the API (/api/v1/automate/scripts). You can test a script with mock input without running a whole flow:

const { data } = await sdk.automate.testScript('script_01HXK5M8Y2', {
	customer_id: '01HXK5NQAB',
	items: [{ price: 29.99, quantity: 2 }]
});

console.log(data.output, data.logs, data.stats);

#Limits

Limit Value
Execution time The step's timeout_ms (100–30000), capped at the project script CPU quota — 5 seconds by default
Outbound fetch Blocked unless the domain is in allowed_domains / allowed_hosts; ["*"] allows all
sleep() Maximum 5 seconds per call
Captured logs 500 lines per run
Environment variables Only variables named AUTOMATE_PUBLIC_* in the Worker config, and then listed in the step's allowed_env_vars, are exposed as $env

#Gotchas

  • The context is a copy. Assigning to $steps or $trigger inside a script changes nothing outside it. Return data instead.
  • No EmuView internals. There is no direct database handle — collections and fetch are the only ways out of the sandbox.
  • Env vars are opt-in by NAME, and the operator owns that choice. A step's allowed_env_vars can only name variables whose name begins with AUTOMATE_PUBLIC_. Anything else is refused and the step fails, naming the variable. Until 2026-08-03 this list was unrestricted, which meant a flow author could name BETTER_AUTH_SECRET, CLOUDFLARE_API_TOKEN or the R2 credentials and read them straight out of the gateway environment. To expose a value to scripts, rename it with the prefix in your Worker configuration — there is deliberately no way to expose a differently-named variable.
  • Timeouts end the run abruptly. A script that exceeds its time limit fails with a timeout error; partial work is not rolled back, so make writes last.