Skip to content
IntegrationscustomScriptstable

Run 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 on return.tier is cleaner than a chain of branch nodes

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 = 0 at the top of the script is reset on every entry to the node. Cross-node state goes on vars.x — the script must write vars.total = total explicitly. Same script, same call, same node entered again later: fresh locals, same vars.
  • No require, no process, no Buffer, no Function constructor. The sandbox replaces Function with a thrower and freezes Object.prototype, Array.prototype, String.prototype, etc. Anything that synthesizes code at runtime (eval-via-Function, dynamic-import-via-require) will throw. Use the crypto and json helpers exposed on the SDK; reach for args/event/config/vars for inputs.
  • Body cap is 64 KiB; default timeout 5 s, hard cap 30 s. Scripts larger than MAX_SCRIPT_BODY_BYTES = 64 KiB are rejected before the sandbox even spins up. The wall clock is cfg.timeoutSecs * 1000 clamped between 100 ms and HARD_TIMEOUT_MS = 30_000. A script that hits the cap returns ok: false with error: 'script timeout' and routes through the __next fallback edge.
  • fetch is SSRF-guarded and capped at 1 MiB. fetch(url) blocks non-http(s) protocols, resolves the host through assertPublicHost (no RFC1918, no metadata IPs), follows zero redirects (redirect: 'manual'), times out at 4 s, and rejects responses larger than FETCH_MAX_BODY_BYTES = 1 MiB. The return shape is { ok, status, headers, text, json }json is null if parsing fails.
  • sleep and setTimeout are clamped to 2 s. SLEEP_MAX_MS = 2000 bounds both — passing 10000 silently 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._lastScriptReturns is a map from flow-node id to last return value, so two customScript nodes don't collide. The dispatch resolves return.field against the current node's entry — if you copy a return-driven node, give it a fresh id (you should anyway — node.id is immutable per invariant 6).
  • One script.run row per execution. Inputs (args) and output (return value or error) are captured in flow_run_events for replay. Treat the run log as production-visible: don't log secrets via log.info.