customScriptstableRun script
Runs a TypeScript snippet on the server. Open the editor (double-click the node, or click the inspector's Open editor button) for the full IDE — Monaco, b…
What it does
Runs a TypeScript snippet on the server during the call. Open the editor
(double-click the node, or use the inspector's Open editor button) for
the full IDE — Monaco with project-aware autocomplete, breakpoints, a
stepper, typed Args, and a return-value cases panel. The script body
compiles to JavaScript on Save and the worker runs the compiled version
in a hardened Node vm sandbox, so you can use any TypeScript syntax in
the editor without worrying about runtime support.
The sandbox is intentionally narrow: SDK globals are exposed as bare
identifiers (vars, args, event, config, log, fetch, crypto,
json, random, randomInt, shuffle, uuid, now, sleep) — no
require, no process, no Buffer, no Function constructor, and no
quad.* namespace. Top-level let / const / var declarations stay
local to the script body and disappear when it returns; cross-node
state must be written explicitly via vars.x = .... The script's
return value is stashed at state._lastScriptReturns[<nodeId>] so
cases of the form return.field resolve correctly even when the
dispatch fires on a later Telnyx event for the same call. Each
execution writes a script.run row into flow_run_events with
inputs, output, and timing.
When to use it
- Compute a derived value the standard nodes can't express ("business hours in the caller's timezone", "round-robin pick from a list of on-call agents")
- Call a tiny helper API and route on its response without setting up a full integration spec
- Validate or normalize a digit-gather result before branching on it
- Stash structured data on
vars(parsed objects, arrays of search hits) for downstream nodes to template through - Drive routing from a return value —
return { tier: "gold" }plus a case onreturn.tieris cleaner than a chain ofbranchnodes
If you need a typed third-party call with audited credentials, use integration instead. If you just need to GET a JSON URL and branch on the status, httpCall has less ceremony.
Configuration
Runs a TypeScript snippet on the server. Open the editor (double-click the node, or click the inspector's Open editor button) for the full IDE — Monaco, breakpoints, stepper, typed Args, return-value cases. Top-level const/let/var stay local to the script; write to vars.x explicitly to share state with other nodes. The return value drives the routing cases.
This node has no configurable fields.
Examples
Set a var, then advance
A minimal script that derives a tier from contact tags and writes it to
vars.tier. The next node can template {{vars.tier}} or branch on it.
{
"id": "compute-tier",
"type": "customScript",
"config": {
"compiled": "const tags = (vars._contact && vars._contact.tags) || [];\nvars.tier = tags.includes('vip') ? 'gold' : tags.includes('flagged') ? 'hold' : 'standard';\nlog.info('tier resolved', vars.tier);",
"argsBindings": [],
"timeoutSecs": 5
},
"on": { "__next": "branch-by-tier" }
}
Return-value cases
Return a small object and branch on return.action without writing to
vars at all. The case engine reads state._lastScriptReturns[node.id]
to resolve return.* paths.
{
"id": "decide-route",
"type": "customScript",
"config": {
"compiled": "const r = await fetch('https://example.com/route?from=' + encodeURIComponent(event.from || ''));\nreturn r.ok ? { action: 'agent', queue: r.json.queue } : { action: 'voicemail' };",
"cases": [
{ "path": "return.action", "op": "==", "value": "agent", "nodeId": "ring-queue" },
{ "path": "return.action", "op": "==", "value": "voicemail", "nodeId": "leave-msg" }
],
"timeoutSecs": 5
},
"on": { "__next": "leave-msg" }
}
Gotchas
- Top-level locals don't persist. A
let total = 0at the top of the script is reset on every entry to the node. Cross-node state goes onvars.x— the script must writevars.total = totalexplicitly. Same script, same call, same node entered again later: fresh locals, samevars. - No
require, noprocess, noBuffer, noFunctionconstructor. The sandbox replacesFunctionwith a thrower and freezesObject.prototype,Array.prototype,String.prototype, etc. Anything that synthesizes code at runtime (eval-via-Function, dynamic-import-via-require) will throw. Use thecryptoandjsonhelpers exposed on the SDK; reach forargs/event/config/varsfor inputs. - Body cap is 64 KiB; default timeout 5 s, hard cap 30 s. Scripts
larger than
MAX_SCRIPT_BODY_BYTES = 64 KiBare rejected before the sandbox even spins up. The wall clock iscfg.timeoutSecs * 1000clamped between 100 ms andHARD_TIMEOUT_MS = 30_000. A script that hits the cap returnsok: falsewitherror: 'script timeout'and routes through the__nextfallback edge. fetchis SSRF-guarded and capped at 1 MiB.fetch(url)blocks non-http(s)protocols, resolves the host throughassertPublicHost(no RFC1918, no metadata IPs), follows zero redirects (redirect: 'manual'), times out at 4 s, and rejects responses larger thanFETCH_MAX_BODY_BYTES = 1 MiB. The return shape is{ ok, status, headers, text, json }—jsonisnullif parsing fails.sleepandsetTimeoutare clamped to 2 s.SLEEP_MAX_MS = 2000bounds both — passing10000silently waits 2 seconds. If you need a real call-flow pause, use the wait node, which delegates to a job queue and survives worker restarts.- Return value is keyed by
node.id.state._lastScriptReturnsis a map from flow-node id to last return value, so two customScript nodes don't collide. The dispatch resolvesreturn.fieldagainst the current node's entry — if you copy a return-driven node, give it a fresh id (you should anyway —node.idis immutable per invariant 6). - One
script.runrow per execution. Inputs (args) and output (return value or error) are captured inflow_run_eventsfor replay. Treat the run log as production-visible: don't log secrets vialog.info.
