Skip to main content

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:

  1. Loads the JS file at the configured Script Path.
  2. Calls its exported exec(props) function.
  3. Routes the flow based on whether exec returned 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 (exec returned)
    • error — failure (exec threw)

Settings

FieldPurpose
Script PathPath 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 DescriptionFree-text — for other developers.
Engine Key/Value PairsUp 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 Parallel nodes, 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 of dictionary.* (use system/session for 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 takes next_1.
  • If exec(props) throws (any uncaught error, including throw new Error(...) from inside a catch) → engine takes error.

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 the error edge. 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:

  1. 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.
  2. Validate required props early and throw. if (!props.agentID) throw new Error('Missing required prop "agentID".');
  3. Default optional props with ?? right next to where they're used: props.role ?? 'user'.
  4. Pull session-level keys (sessionID, requestID, userID, userEmail) from SessionMgr, not props. They're already on the session object.
  5. Coerce string props explicitlyprops.flag === 'true' for booleans (never Boolean(props.flag) — that returns true for the string 'false'). Number(props.n) for numbers.
  6. 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.
  7. 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:

  1. system/* — Keen-provided runtime APIs (the bullets below).
  2. ./relative — your own sibling scripts (./helper.js). Bare/system imports other than the whitelisted system/* 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).

MethodPurpose
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.

MethodPurpose
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 a return (→ next_1) or a throw (→ error) routes a Script Node.

Log API

import LogMgr from 'system/log'

Write to admin-side logs (not visible to end users).

MethodChannel
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

MethodReturnsPurpose
getAgentLastResponse(agentID: string)AgentResponseMost 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)AgentResponseLatest response narrowed by request.
getAgentLastResponseBySessionId(agentID: string, sessionID: string)AgentResponseLatest response narrowed by session.
getAgentResponseBySessionRequestIds(agentID, sessionID, requestID)AgentResponseExact lookup — agent + session + request.

Responses — write

MethodReturnsPurpose
setAgentResponse(response: AgentResponse)voidInject 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

MethodReturnsPurpose
setAgentPrompt(agentId: string, payload: AgentPromptSet)AgentPrompt | ErrorAppend a new prompt to that agent's bucket; engine sets the timestamp.
getRootAgentPrompt(agentId: string)AgentPrompt | nullThis run's root user trigger (the newest role:'user' + agentChat:true entry).
getAgentLastPrompt(agentId: string)AgentPrompt | nullLatest 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.

MethodReturnsPurpose
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?)numberDelete 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).

MethodPurpose
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 asyncawait 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.

MethodPurpose
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 timesystem/http shares one buffer with system/agent and system/dictionary reads; await each call (don't Promise.all them).
  • 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.

MethodPurpose
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.

MethodPurpose
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' });

flowID is sourced from flowContext.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.