Run your own JavaScript inside a flow: the shape of a script file, the platform resources it can import, the sandbox rules, and worked examples.
A Script node runs one JavaScript file from your deployed project — in a locked-down sandbox.
A Script node runs a .js file you wrote in your project. Use it for the work the other nodes can't express: shape the flow's data, call an external API, drive agents from code, or run custom validation. The file runs in a locked-down sandbox with no access to the machine it runs on — no filesystem, no ambient network — and a fresh sandbox is created for each run and torn down afterwards. The only things a script can reach are the dictionary it's given and the platform resources it explicitly imports.
On the canvas a Script node has three dots: In (from the previous node), Out (runs on a clean return, carrying your return value), and Error (runs if the script throws). Wire the Error dot for anything that can fail — a throw with no Error edge just ends that branch.
Every script exports one function, and it has a specific name.
A script file must export a function named exec that takes one argument, props. It is a named export — export const exec = … — not a default export. It may be synchronous or async, and its return value becomes the node's result, so it must be plain data (no functions or class instances).
the required shape
export const exec = (props) => { // your code — read/write the dictionary, call resources, compute return { ok: true }; // the node's result; must be plain, copyable data }; // async works too — the platform waits for the result: export const exec = async (props) => { const value = await somethingAsync(); return value; };
exec export. A file that uses export default — or exports no exec at all — fails the node to its Error edge.The Script node's Key/Value Pairs arrive as the exec argument.
A Script node has the same Key/Value Pairs editor as an Assignment node (up to 10 pairs; keys up to 100 and values up to 200 characters). Each pair's value is resolved with the usual ${…} rules — only ${dictionary.*} and ${session.*} — and the pairs are handed to exec as the props object. As everywhere, a plain typed value arrives as text; a whole-string placeholder preserves its type (text, a number, true/false or an array — a placeholder that resolves to a nested object arrives empty).
pairs in, props out
Key/Value Pairs on the Script node: city = Sofia userName = ${dictionary.name} // inside the script: export const exec = (props) => { props.city; // "Sofia" — a plain value arrives as text (coerce numbers yourself) props.userName; // dictionary.name's value, with its type preserved };
The dictionary is a ready-to-use global; the session comes through a resource.
The current flow's dictionary is available directly as a dictionary global — no import needed. It is a writable copy of the flow's scratchpad: read it, mutate it, and on a clean return your changes are committed back. The session (the whole-run, flat store) is reached through the system/session resource.
dictionary global + session resource
import Session from 'system/session'; export const exec = (props) => { const items = dictionary.items ?? []; // dictionary — a bare global, no import dictionary.total = items.length; // a write; commits on a clean return Session.setSessionValue('lastStep', 'validated'); // whole-run, flat values only const step = Session.getSessionValue('lastStep'); // (or: Session.lastStep) return { ok: true, step }; };
dictionary changes, any session writes, and any queued agent writes are all committed; the flow continues out Out with your return value.dictionary changes and queued agent writes are discarded (all-or-nothing — nothing is half-written), but session writes made before the throw persist, so an error-handling node can still read them.Two small read-only globals that describe this call.
Besides dictionary, a script sees two small read-only globals. flowContext is present in every script node and identifies the run the script is part of — its flowId, sessionId, requestId, and parentFlowId when the flow was started by another flow. toolOptions is present only when the script runs as a tool an agent called: toolOptions.toolContext is the parsed call (agentId — the agent to answer, options — the fields the model wrote, e.g. options.context), and toolOptions.agentNode.flowContext.sessionId is the calling agent's session — the value every tool reply must be stamped with (see Building Tools). Neither is saved anywhere; they describe this call only.
The nine modules a script may import — everything a script can reach beyond the dictionary and the two globals above.
A script imports a resource by its module name. Both a default import of the whole object and named imports of individual methods work.
two import styles — both valid
import Log from 'system/log'; // the whole resource Log.info('hello'); import { info } from 'system/log'; // or one method by name info('hello');
There are nine resources (note it is system/log, singular):
system/chatwriteOut(text) (a plain status message), writeThinking(text) (a transient "thinking…" line), writeError(text) (a line shown as an error row), writeAgentProgress(subSessionId, current, total, text) (a progress step), plus writeAgentStream / writeAgentStart / writeAgentEnd / writeFiles for richer front-ends. Every call is fire-and-forget and arrives on the Chat SDK's onStream as its own event (message, thinking, error, progress, …). None of them is the final answer — the node's return, or the agent's response, is.system/logerror, warning, info, debug. Not shown to the end user.system/sessionSession.userId).system/dictionaryget({ flowID, path }) returns the value at that dot-path (or null if the flow or the path does not exist) — for example the parent flow's id from flowContext.parentFlowId. It never pulls a whole dictionary in. closeDictionary(flowID) discards another flow's dictionary once you have consumed it; only close a dictionary you know is finished, because its flow would fail on its next node. The current flow's dictionary is the bare dictionary global — this resource is only for cross-flow access.system/agentgetRootAgentPrompt(agentId) (this run's user message), getAgentLastPrompt / getAgentLastResponse (the newest entry), getAgentLastResponses (all, newest first), and the …ByRequestId / …BySessionId variants. Channels let scripts and agents pass work around under a name you choose: getChannelPrompts(name) / getChannelResponses(name) read everything on a channel across all agents; consumeChannelPrompts / consumeChannelResponses read and immediately delete it (the delete happens at once, even if your script later throws); deleteChannel(name, { affectDB: true }) also removes that channel's stored conversation history. Writes — setRootAgentPrompt(agentId, { prompt, sessionID, requestID }) (a new user turn for an agent), setAgentPrompt, setAgentResponse — are queued and committed only when the script returns cleanly; a throw discards them. This is how a script drives agents and how a tool replies to its caller.system/settingsget(field), getMany(...), has(field). This is config a human set on the agent's runtime settings; the partner-facing front settings are never visible here.system/cookiesget(name), all(), has(name). Useful to read a token the user's browser sent (e.g. an OAuth cookie) so a script can call a third-party API on their behalf.system/httpget, getJson, post, postJson, request. Every method returns { ok, status, body }.system/toolsgenerateUUID() (the sandbox has no crypto) and getTimestamp().lodash, an absolute path, or a non-existent module such as system/logs (plural) — is rejected and fails the node to its Error edge.What a script can and cannot do — and why that makes partner code safe to run.
system/http call, which is what makes it safe to run partner-authored code at all.fetch, no crypto. These globals are not present. Use system/http for outbound calls and system/tools for a UUID or timestamp.dictionary global and the resources it imports — nothing else on the host is reachable.system/http calls, system/agent reads and system/dictionary reads must run one after another — await each. Firing two at once (for example inside a Promise.all) fails; a single response that is too large fails too.Split logic into your own helper files, and reuse code across a project.
A script can import your own files by relative path — ./helpers.js in the same folder, ../lib/format.js in a sibling folder. A helper file is a normal module: it exports whatever functions or constants you like and does not need an exec — only the file a node runs (or a registered tool) needs that. Bare package names and absolute paths are not allowed; only system/* resources and your relative files.
your own helper modules
// scripts/init.js — the file a Script node runs import { clean } from './helpers.js'; // same folder import { format } from '../lib/format.js'; // a sibling folder export const exec = (props) => { return format(clean(props.raw)); }; // scripts/helpers.js — a helper: no exec needed export const clean = (s) => (s ?? '').trim();
tools/ folder; a plain helper you import with ./ or ../ is just your own module. See the worked tool example below.Three scripts you will write again and again.
1 — Shape the flow's data. Read the dictionary, compute, write back. Changes commit when the script returns cleanly.
scripts/summarize.js
export const exec = (props) => { const items = dictionary.items ?? []; // read the current flow's scratchpad dictionary.count = items.length; // write — persists on a clean return dictionary.first = items[0] ?? null; return { ok: true }; };
2 — Call an external API. Every system/http method returns { ok, status, body }, so check ok for an error status. A transport or policy failure (a network error, a timeout, a blocked address) throws, so wrap the call in try/catch and wire the node's Error dot.
scripts/fetch-profile.js
import Http from 'system/http'; export const exec = async (props) => { try { const res = await Http.getJson('https://api.example.com/users/' + props.userId); if (!res.ok) { return { ok: false, status: res.status }; } dictionary.profile = res.body; // committed on a clean return return { ok: true }; } catch (err) { return { ok: false, error: err.message }; } };
3 — Reply as a tool. When an agent calls a tool, the tool reads the call from toolOptions, does its work, and pushes the result back to the calling agent through system/agent. The reply must be stamped with the caller's session so the agent recognises it as the answer to its call.
scripts/tools/lookup.js
import Agent from 'system/agent'; import Tools from 'system/tools'; export const exec = () => { const call = toolOptions.toolContext; // the call the model made const agentId = call.agentId; // which agent to answer const session = toolOptions.agentNode.flowContext.sessionId; // stamp with the caller's session const input = (call.options.context ?? '').trim(); Agent.setRootAgentPrompt(agentId, { prompt: 'Looked up: ' + input, sessionID: session, requestID: Tools.generateUUID() }); return { ok: true }; };
sessionID. Without it, the agent — which resolves its trigger scoped to its own session — never sees the answer and reports No tool response, even though the tool ran and fetched its data. This is the single most common tool bug.123 arrives as the string "123" — convert it yourself (Number(props.x)) or pass a placeholder that already holds a number.system/settings exposes the agent's Json Settings; a value placed on the partner-facing front settings is simply invisible to the code that expected it.Keen Agents 2026
Documentation
Release 15