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 thecollectionsAPI — 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
$triggeror$stepsinside the script does not affect the rest of the flow — communicate through the return value.
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. Whenoptionsis 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).limitmay be up to 10000 per call; useoffsetto 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
$stepsor$triggerinside a script changes nothing outside it. Return data instead. - No EmuView internals. There is no direct database handle —
collectionsandfetchare 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_varscan only name variables whose name begins withAUTOMATE_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 nameBETTER_AUTH_SECRET,CLOUDFLARE_API_TOKENor 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.
#Related concepts
- JSONata expressions — the lighter alternative for data transformation
- Operations reference — the
run_scriptandrun_expressionconfig fields - Access control — the rules
collections.*calls are checked against