Script Node
The Script Node runs custom JavaScript in a sandbox. It's the escape hatch for anything the other nodes can't do declaratively — string manipulation, custom routing, calls to built-in APIs, talking to external services.
What it serves
When the flow lands on a Script Node, the engine:
- Loads the JS file at the configured Script Path.
- Calls its exported
exec(props)function. - Routes the flow based on whether
execreturned normally or threw.
When to use it
- Custom logic that doesn't fit into Assignment / Condition / Agent.
- Reading and writing the shared session object.
- Calling an external API (via
system/http— the host runs the request, SSRF-guarded). - Composing multiple agent responses into a custom shape.
Connections
- Input dot.
- Two output dots:
next_1— success (execreturned)error— failure (execthrew)
Settings
| Field | Purpose |
|---|---|
| Script Path | Path to the .js file inside src/scripts/. If you have src/scripts/test.js, Script Path is test.js. If you have src/scripts/helpers/test.js, it's helpers/test.js. |
| Node Description | Free-text — for other developers. |
| Engine Key/Value Pairs | Up to 10 rows. Each row becomes a property on the props object passed to exec(props). Values arrive as strings — see Script skeleton. |
Dynamic values in props and Script Path
Both props values and the Script Path support template expansion:
${dictionary.something.something}
The runtime evaluates the expression and substitutes the actual value. Use this when you want the script (or its props) to depend on data flowing through the dictionary.
Script skeleton
Every Script Node JS file exports an async exec(props):
export async function exec(props) {
// props.x ← always a string, even if you typed a number in the UI
const test = props.test;
}
Working with dictionary
The runtime exposes a per-flow dictionary object — same instance from Start Node to End Node. It's how data travels between nodes.
export async function exec() {
dictionary.score = 0.92;
dictionary.userType = 'premium';
}
Note: Not every flow shares the same dictionary across parallel branches. Within a single linear path, yes. Across
Parallelnodes, each branch may have its own.
Size cap: the dictionary is capped at 1.5 MB (
DICT_MAX_BYTES). A write that pushes the dictionary over the cap throws at commit time and fails the node — so keep large blobs out ofdictionary.*(usesystem/sessionfor shared transport, also 1.5 MB-capped, or fetch on demand).
Working with session
The session is the shared global object across all running pipe flows in a request. Use it to transport data between sync and async flows.
See the Session API below.
Importing helpers
You can split a script into multiple files and import between them:
// Adapter.js
export function exec() {
doSomething();
}
function doSomething() {
dictionary.someValue = 123;
}
export function doSomethingElse() {
dictionary.someValueNext = 225;
}
// Main.js
import { exec as AdapterExec, doSomethingElse } from './Adapter.js';
export async function exec() {
AdapterExec();
doSomethingElse();
}
Error routing
The Script Node has two output edges in the compiled flow JSON: next_1 (success) and error (failure).
- If
exec(props)returns normally → engine takesnext_1. - If
exec(props)throws (any uncaught error, includingthrow new Error(...)from inside acatch) → engine takeserror.
What this means for your try/catch:
- Swallow (catch and don't re-throw) → flow always continues down
next_1. Use when the error is recoverable and the downstream logic is the same. - Re-throw (catch, log, then
throw err) → flow forks to theerroredge. Use when downstream needs a different path for failure (a fallback agent, a retry branch, a cleanup node).
The error edge is independent of ChatMgr.writeError / LogMgr.error — those are just ways to report the error. What routes the flow is whether the script throws.
Reusable Script Node pattern
When a script will be dropped into more than one Script Node, follow these rules to keep it reusable:
- Every business choice becomes a prop. Agent ID, destination dictionary key, separator, role, flags, target filename — all props. Nothing literal in the script body except behaviour.
- Validate required props early and throw.
if (!props.agentID) throw new Error('Missing required prop "agentID".'); - Default optional props with
??right next to where they're used:props.role ?? 'user'. - Pull session-level keys (
sessionID,requestID,userID,userEmail) fromSessionMgr, not props. They're already on the session object. - Coerce string props explicitly —
props.flag === 'true'for booleans (neverBoolean(props.flag)— that returnstruefor the string'false').Number(props.n)for numbers. - Wrap in try/catch +
LogMgr.error+ChatMgr.writeError+ re-throw as the default error pattern. Swallow only when downstream genuinely doesn't care about the failure. - JSDoc the file — describe behaviour, list required vs optional props (with defaults), document the error routing contract.
Available libraries
Inside an exec body you can import from a small set of curated modules. Two families:
system/*— Keen-provided runtime APIs (the bullets below)../relative— your own sibling scripts (./helper.js). Bare/system imports other than the whitelistedsystem/*are rejected by the resolver.
The live system/* set is eight resources: system/chat, system/log, system/session, system/tools, system/agent, system/cookies, system/http, and system/dictionary. Below: each one and what it serves.
Session API
import SessionMgr from 'system/session'
Access the shared global session object. The session is created when the main pipe flow is triggered, lives for the duration of the request, and is wiped when the flow exits.
Predefined keys: sessionID, requestID, userID, userEmail. Storage cap: 1.5 MB (simple data only — string / number / boolean).
| Method | Purpose |
|---|---|
hasSessionKey(key) | Returns true if the key exists. |
setSessionValue(key, value) | Sets key = value (`boolean |
getSessionValue(key) | Reads the value for key. |
deleteSessionValue(key) | Removes a key. |
getSessionSize() | Returns the current size in bytes. |
clearSessionStorage() | Wipes the entire session object. |
Use this for passing data between sync and async branches of a flow — it's the ONE shared object across everything.
Chat API
import ChatMgr from 'system/chat'
Write back to the user-facing Chat.
| Method | Purpose |
|---|---|
writeOut(...) | Prints an interim status line in the chat (narration — not the final answer). |
writeThinking(...) | Shows a "thinking" balloon (transient). |
writeError(...) | Shows an error-styled message. Reporting only — does not route the flow (a Script Node that calls writeError but doesn't throw still goes down next_1). |
writeAgentStart(...) / writeAgentProgress(...) / writeAgentStream(...) / writeAgentEnd(...) | Sub-agent lifecycle + token-stream helpers — mark a sub-agent run starting, tick progress, stream chunks, mark it ending. |
writeFiles(files) | Push file attachments to the client. |
Every method is fire-and-forget and none of them is the final response — the final response is the script's
exec()return value. None of them routes the flow either; only areturn(→next_1) or athrow(→error) routes a Script Node.
Log API
import LogMgr from 'system/log'
Write to admin-side logs (not visible to end users).
| Method | Channel |
|---|---|
LogMgr.debug(...) | Debug channel |
LogMgr.info(...) | Info channel |
LogMgr.warning(...) | Warning channel |
LogMgr.error(...) | Error channel — always on, even when others are silenced |
Agent API
import AgentMgr from 'system/agent'
Read and write agent prompts and responses. The runtime keeps two stores per request:
- Responses — flat array of every agent's responses in chronological order.
- Prompts — grouped by
agentID, each agent owning an array of prompts.
Types
type AgentResponse = {
sessionID: string;
requestID: string;
agentID: string;
agentName: string;
response: string;
timestamp: number;
};
type AgentPrompt = {
agentID: string;
sessionID: string;
requestID: string;
prompt: string;
timestamp: number;
agentChat?: boolean;
role: LLMEngine.PromptRole;
files?: SocketFileData[];
filePaths?: LLMEngine.FilePath[];
images?: LLMEngine.ImagePath[];
};
type AgentPromptSet = Omit<AgentPrompt, 'timestamp'>; // timestamp is set by the engine
Responses — read
| Method | Returns | Purpose |
|---|---|---|
getAgentLastResponse(agentID: string) | AgentResponse | Most recent response from this agent (latest by timestamp). |
getAgentLastResponses(agentID: string) | AgentResponse[] | All responses from this agent in this request. |
getAgentLastResponseByRequestId(agentID: string, requestID: string) | AgentResponse | Latest response narrowed by request. |
getAgentLastResponseBySessionId(agentID: string, sessionID: string) | AgentResponse | Latest response narrowed by session. |
getAgentResponseBySessionRequestIds(agentID, sessionID, requestID) | AgentResponse | Exact lookup — agent + session + request. |
Responses — write
| Method | Returns | Purpose |
|---|---|---|
setAgentResponse(response: AgentResponse) | void | Inject a response into the collection. The "fake response" trick — set a response under an /org-defined Agent ID and the engine returns it to chat at flow end. |
Prompts
| Method | Returns | Purpose |
|---|---|---|
setAgentPrompt(agentId: string, payload: AgentPromptSet) | AgentPrompt | Error | Append a new prompt to that agent's bucket; engine sets the timestamp. |
getRootAgentPrompt(agentId: string) | AgentPrompt | null | This run's root user trigger (the newest role:'user' + agentChat:true entry). |
getAgentLastPrompt(agentId: string) | AgentPrompt | null | Latest prompt for this agent. |
Both stores are kept newest-first — "last" means newest, i.e.
arr[0].
Channels
Beyond the per-agent stores, prompts and responses can be tagged onto a channel — an orthogonal category lane that spans every agent. An entry with no channel lands on the default channel root (which is what all the per-agent reads above see). You set the channel on the write (pass channel in the setAgentPrompt / setRootAgentPrompt payload); a response automatically inherits the channel of the prompt that triggered it. Channeled entries are reachable only through the channel reads — the per-agent reads never see them.
| Method | Returns | Purpose |
|---|---|---|
getChannelPrompts(channel, opts?) | AgentPrompt[] | Every prompt on a channel, across all agents (optionally narrowed by { sessionID, requestID }). |
getChannelResponses(channel, opts?) | AgentResponse[] | Every response on a channel, across all agents. |
consumeChannelPrompts(channel, opts?) | AgentPrompt[] | Drain — read and delete a channel's prompts. |
consumeChannelResponses(channel, opts?) | AgentResponse[] | Drain — read and delete a channel's responses. |
deleteChannel(channel, opts?) | number | Delete a channel's prompts and responses across all agents (returns the combined count). |
Use channels when several agents feed one logical lane (e.g. an inter-agent "com" bus) and you want every prompt/response on it regardless of which agent produced it — without polluting the default per-agent reads.
Where the response lands when an Agent Node runs is controlled by
dictionary.__callContext—'tool'→dictionary.result; otherwise → the response collection accessed by these methods.
Tools API
import Tools from 'system/tools'
General utility helpers (the isolate is bare V8 — these bridge the gaps).
| Method | Purpose |
|---|---|
generateUUID() | RFC 4122 v4 UUID, cryptographically random (host crypto). |
getTimestamp() | Current Unix epoch time in milliseconds (Date.now()). |
HTTP API
import Http from 'system/http'
Outbound HTTP — the fetch replacement. The isolate has no network, so the host runs the request and streams the response body back. Every method is async — await it. You get the body straight back (text, or parsed JSON for the *Json helpers); there is no Response object (no .ok / .status / .headers), so a non-2xx still returns its body — check the payload, not a status code.
| Method | Purpose |
|---|---|
get(url, opts?) | GET → response text. |
getJson(url, opts?) | GET → parsed JSON. |
post(url, body, opts?) | POST → response text. |
postJson(url, body, opts?) | POST a JSON body → parsed JSON (sets content-type: application/json for you). |
request(spec) | Full control over { url, method, headers, body } → response text. |
import Http from 'system/http';
const data = await Http.postJson('https://api.example.com/do', { a: 1, b: 2 });
// `data` is the already-parsed JSON body — no response.json() step.
Be careful:
- Public hosts only (SSRF guard). The host rejects loopback / private / link-local / cloud-metadata addresses and non-
http(s)schemes, and re-validates every redirect hop. Reaching an internal host needs an operator allow-list. - One request at a time —
system/httpshares one buffer withsystem/agentandsystem/dictionaryreads;awaiteach call (don'tPromise.allthem). - 10 MB response cap and a 15 s timeout. Decoding a large body costs heap against your isolate budget — pull only what you need.
Cookies API
import Cookies from 'system/cookies'
Read-only access to the consumer's request cookies for this flow (e.g. a Google-Connect token the user picked up on a consent screen). They're snapshotted at flow start and die with the flow; HttpOnly cookies never appear.
| Method | Purpose |
|---|---|
get(name) | One cookie value, or undefined. |
all() | All cookies as a { [name]: value } object. |
has(name) | Whether a cookie is present. |
Dictionary API (cross-flow)
import dictionaryMgr from 'system/dictionary'
Read-only, scoped read of another flow's dictionary — the read-only twin of the bare dictionary global (which is your own flow's dict). The classic use is a Parallel Context clone reaching back into its parent or root dict for a value its own seeded dictionary doesn't carry.
| Method | Purpose |
|---|---|
get({ flowID, path }) | Read one value at path from the dict keyed by flowID. Returns the value, or null if the dict or the path is missing. |
import dictionaryMgr from 'system/dictionary';
const apiBase = dictionaryMgr.get({ flowID: flowContext.parentFlowId, path: 'config.apiBase' });
flowIDis sourced fromflowContext.parentFlowId(or the root flow's id). You can only read dicts within your own run — the connection id is host-injected, never another tenant's.
Compiled JSON shape
{
"scriptNode_GjaitnqLCpwzmK2WLQUPB1Z8CP2emFzt": {
"next_1": ["agentNode_..."],
"error": ["agentNode_..."],
"data": {
"settings": {
"value": "test2.js",
"keyValuePairs": [
{ "key": "SEE HERE", "value": "${dictionary.test}" }
]
}
}
}
}
See the Reusable Script Node pattern above for what makes a good script.