Docs / Language & functions
The script model
How a script is executed on this platform, for a reader who knows JS and nothing about the product.
For what each function does, see the function reference. This page is about the model: what a script is, what it answers with, and what it may assume.
Every example on this page was run with this input:
{
"event": {
"id": "evt_2f1c9a",
"kind": "webhook",
"tenantId": "6a1b0f6e-0000-4000-8000-000000000001",
"environmentId": "6a1b0f6e-0000-4000-8000-000000000002",
"endpoint": "orders",
"body": {
"id": "o-1042",
"customer": "ada@example.com",
"items": [
{
"sku": "A-1",
"price": 20
},
{
"sku": "B-2",
"price": 10
}
]
},
"headers": {
"content-type": "application/json",
"x-source": "shop"
},
"receivedAt": "2026-03-04T09:15:00.000Z"
},
"settings": {
"crmConnection": "crm",
"pageSize": 50,
"signingSecret": "shared-secret"
}
}
A script is a program, not a module
A script is a program. Its statements run once, from top to bottom, and when the last one finishes the run is over. There is no entry point: nothing calls main, nothing looks for a default export, and the platform never searches your code for a function to invoke.
There are no modules either. import and export are refused before the script runs, and require does not exist. Code you want to share lives in a library script: the platform puts it in front of yours before the run starts, so its functions and variables are simply in scope — no import statement, no namespace.
Everything else you would write in a program works. Declare functions and call them, use a wrapper function if you want a private scope — but neither is required, and wrapping the whole script in one buys nothing.
A whole script, top to bottom
const order = input.event.body;
log("order received", { id: order.id });
function total(items) {
let sum = 0;
for (const item of items) {
sum += item.price;
}
return sum;
}
return { id: order.id, total: total(order.items) };
Result — {"id":"o-1042","total":30}
No wrapper, no export, no entry point — the statements are the script.
What a script returns
A run has one result. Three rules decide it, in order:
- A top-level
returnwins — its value is the result, and the run stops there. - Otherwise, the final value of a top-level variable called
outputis the result. - Otherwise the run has no result, which is perfectly normal.
output only counts at the top level. A variable of that name inside a function is an ordinary local variable, and the platform never looks at it. The result becomes the run’s output: it is what the run detail shows, and — for a run triggered by a synchronous API call — what the caller can be answered with.
An explicit return wins
const output = "ignored";
return { ok: true };
Result — {"ok":true}
Or the final value of a top-level output
const output = { processed: 0 };
output.processed = input.event.body.items.length;
Result — {"processed":2}
output inside a function is just a local variable
function compute() {
const output = 42;
return output;
}
compute();
Result — the run succeeds with no output.
The platform reads the top-level output and this script has none — the one inside the function is an ordinary local variable, and returning it returns it to nobody.
A script may simply do its work
log("nothing to report");
Result — the run succeeds with no output.
input — the one global that carries data
input is the only global holding data. Its parts: input.event is what triggered the run; input.settings is the value the linked settings script returned — configuration as data, so environment-specific values never sit in the script (with no settings script linked it is undefined); and on a door that requires an identity, input.caller names the signed-in person — their id, email and roles, bound by the platform from the door’s own decision, absent everywhere else and never settable from any payload. Filter what you return by input.caller and you have an application backend.
input is frozen, at every depth. Assigning to any part of it throws a TypeError you can catch. Build what you need as a new value instead: an object literal for a few fields, JSON.parse(JSON.stringify(input.event.body)) for a deep copy.
What input.event carries depends on the door the run came through. These fields are always present: id, kind, tenantId, environmentId, body and receivedAt. kind is what to branch on when one script serves more than one door.
| Trigger | What causes it | input.event.body | Fields set |
|---|---|---|---|
webhook | An HTTP call arrived on one of the environment’s endpoints. The caller is answered immediately, before the script runs. | The request body — parsed when it is JSON, the raw text otherwise. | id, kind, tenantId, environmentId, endpoint, body, headers, receivedAt |
api_service | An HTTP call arrived on a synchronous endpoint, and the caller is waiting for this run’s answer — see respond. | The request body — parsed when it is JSON, the raw text otherwise. | id, kind, tenantId, environmentId, endpoint, body, headers, receivedAt |
scheduler | A schedule came due. Nothing else triggered it, so there is no caller and no payload from outside. | { schedule, cron, firedAt } — which schedule fired, its expression, and the minute it was due. | id, kind, tenantId, environmentId, body, receivedAt |
email_in | A message arrived in a mailbox the environment watches. One run per message. | The message envelope — sender, recipients, subject, the text and/or HTML body and the headers. A body too large to read whole arrives cut (or, rarely, without it) and the envelope carries truncated: true. Attachments are not delivered. | id, kind, tenantId, environmentId, endpoint, body, receivedAt |
system | Something went wrong in this environment — a run failed, queued work could not be delivered, something was marked as failing, or a lane began to fill, stall, refuse, wait or skip a schedule and later cleared. The script reacts to it. | The occurrence: { kind, tenantId, environmentId, scriptName?, entity?, runId?, taskId?, error?, triggeredBy?, metric?, lane?, at } — what broke and where, never the payload that caused it. A configured function’s failure names it in entity and carries no scriptName; a lane occurrence names its lane in lane (source, on for entered or cleared, its numbers) and no script. | id, kind, tenantId, environmentId, body, receivedAt |
manual | Someone ran the script on demand — from the editor’s Test button or from the command line. | Whatever input was supplied for the test run. | id, kind, tenantId, environmentId, body, receivedAt |
Reading the trigger
const order = input.event.body;
return order.customer;
Result — "ada@example.com"
Changing it throws — catchably
try {
input.event.body.id = "changed";
} catch (error) {
return error.name + ": " + error.message;
}
Result — "TypeError: cannot mutate the read-only input"
The trigger is the run’s evidence: a resumed run must read the same payload it started with.
Build a new value instead
const order = input.event.body;
return { id: order.id, customer: order.customer, status: "processed" };
Result — {"id":"o-1042","customer":"ada@example.com","status":"processed"}
Calling the platform
Everything the platform offers — logging, HTTP calls, checkpoints, and the functions configured for your tenant — is called by its bare name and answers on the spot. There is no await and no .then, and nothing to hand a callback to: the language has no async, and Promise is not defined. (Callbacks themselves are ordinary — map and sort take one; it is the PLATFORM that never asks for one.)
That is not a simplification of something asynchronous. A platform call really does pause the run: the platform saves the run’s entire state, does the work, and continues your script with the result filled in. A run that is resumed — on another worker, an hour later — picks up the saved result instead of calling again, which is why a retried run never sends the same request twice.
You can call the platform from anywhere, a map or sort callback included. Prefer a plain loop when the list is not small: the calls happen one at a time either way, and only a loop can break — when you have enough, when an answer is bad, when the list turns out longer than you expected. The one way out of a callback is throw, which abandons the whole iteration.
What each function does — its arguments, its result, an example — is in the function reference. This page only fixes how calling works.
The results below come from running these examples against a connection that answers 200.
A call, written straight
const response = fetch({
connection: input.settings.crmConnection,
method: "GET",
path: "/contacts",
query: { pageSize: input.settings.pageSize }
});
log("contacts fetched", { count: response.body.items.length });
return "status " + response.status;
Result — "status 200"
No await, no callback — the next line already has the answer.
Call from a loop, not from a callback
const items = input.event.body.items;
const skus = [];
for (const item of items) {
log("item", { sku: item.sku });
skus.push(item.sku);
}
return skus;
Result — ["A-1","B-2"]
Waiting between two batches
log("first batch done");
checkpoint();
log("this line runs in the next segment");
Result — the run is saved here and continues in a later segment.
checkpoint() hands the worker back and continues where it left off, with every variable still in scope. Time spent parked costs no execution budget.
When a call does not simply succeed
A call that fails is not always over. The platform can make it again on its own, and for one kind of answer it can put your whole run to sleep and come back to it — two different things, worth telling apart before you configure either.
What follows is what the platform does; a configured call can change its share of it, and a script’s own fetch gets exactly these defaults.
Repeating — the platform makes the same call again, moments apart, while your run waits. It repeats when no answer arrived at all (the connection failed, timed out, or was refused) and when the answer means not now:
408,425,429— the far end asking to be called again.- Any 5xx except
501,505, which say the request itself will never work — for a call that is safe to repeat (below).
By default it repeats 3 times — so up to 4 calls in all — waiting longer before each one, and honouring a Retry-After the far end sends. Everything else is an ANSWER, and you decide what it means: 400, 401, 404, 422 are never repeated, and neither is any other 4xx, because asking again cannot change them.
Which calls repeat on a 5xx depends on what the call DOES. A call whose method promises nothing happens twice — GET, HEAD, OPTIONS, PUT, DELETE — or one you mark idempotent is repeated on a 5xx like any other transient answer. A call that changes something (a POST or a PATCH you have not marked) is repeated only on 408, 425, 429, never on a 5xx: the far end may have done the work before it failed to answer, and doing it twice is worse than telling you. Name a status under retry.status when you know the far end rolls back on it. The same line runs through a failed connection: one that never reached the far end at all — refused, unreachable, a name that did not resolve, a certificate the platform will not trust — is repeated for every call, while a connection that died after the request may have gone out is repeated only for a call that is safe to repeat.
Waiting is the other thing, and it is not the same: on a status you name as a long wait, the run is SUSPENDED for as long as the far end asked — its worker released, so the wait costs nothing — and the call is made again when it wakes. A status you have named that way is never repeated on the spot; waiting wins, because repeating would spend the wait holding a worker.
One wait is cut off at 24 hours, whatever the far end asks for and whatever you configure. The longest honest answer a business system gives is “come back after the daily quota resets”, and waiting that out is exactly what this is for; past a day the far end is not busy, it is broken, and a run parked on it should end loudly enough that somebody looks. A longer wait is taken as 24 hours and the run’s log says both what was asked for and what was taken. The same ceiling holds a script’s own checkpoint, which refuses a longer wait instead: waking a day later than you asked would do the work at the wrong time. A run that wants to wake up next week is a schedule, not a wait.
Two budgets bound every call. The one you can set is about SILENCE: how long a call may go without receiving any data before it is cut off — 5 minutes unless you say otherwise. It is not a limit on how long the call may take, so a large download over a slow link finishes as long as the bytes keep coming. Above it the platform holds an absolute limit of 60 minutes on one call and all its repeats together, which nothing you configure can lengthen.
A configured call can override any of this — how many times to repeat, statuses to add, statuses never to repeat, and the silence budget. What it cannot do is exceed the 60 minutes limit or ask for more than 10 repeats.
What a configured call answers
The functions configured for your tenant are the calls you should be making: someone chose the connection, the credential and the wording, and can change them without touching your script. What they can also choose is how much of the answer you have to deal with.
By default a call hands you both halves of what came back: { status, body }, with the body already read as data when the far end sent JSON. A status outside 200–299 arrives the same way — it is an answer to branch on, not a failure.
Whoever configures the call can narrow that, and most well-configured calls do:
- The statuses it accepts. Any other answer stops the run where the call was made, saying what came back — the status, what the far end sent, and how many times the platform had already asked. Write no status check: if the next line runs, the call was answered.
- What the answer IS. The value at a path inside the body, or the one record found in a collection. That value is then what you get — not an envelope around it. Several records where one was asked for stops the run rather than picking one, because which is right is a business question.
- What nothing means. A search that matches nothing, a status that says the record is not there (
404,410), or anullwhere the value should be — a far end’s way of saying it made no record — can be an ordinarynullyou branch on, or it can stop the run, naming what was searched for. Stopping is what happens unless you say otherwise, so a create that quietly made nothing is never handed on as a value. Which one is a decision made in the configuration, not in your script.
So a call configured this way reads as the thing it fetches, and the code around it is business — the example below runs against exactly such a function.
You cannot tell from the call site which of these a function does — that is the point, and it is why the same call can be made stricter later without touching a script. Test the function and read what comes back: the Test panel shows exactly what a script receives.
A find that answers the value itself
const subject = findServiceRequest({ key: "SR-1" });
return subject;
Result — "Login is broken"
No status check and no unwrapping: this function’s configuration accepts 200 and takes the value at a path, so the call answers the value — and any other status would have stopped the run here, saying what the far end sent.
A call, once answered, is never made again
The platform saves a run’s state at every call it makes, every time it pauses, and at short intervals while it computes in between. A run that is interrupted — by a restart, by a pause you asked for, by a wait on a busy system — continues from its last saved point, on any machine. It does not start over, and it does not repeat a call whose answer was recorded: the continuation reads the recording. What it may do again is the pure computation since that point — work that had reached nothing outside the run.
So the ordinary tools work. new Date() gives you the current moment, Date.now() the same moment as a number, Math.random() a random one. Read the clock twice and you get two readings, seconds apart if that is how long the work took. What you read is saved with the run at its next call or pause: a value read before a pause is still that value after it, however long the pause lasted.
input.event.receivedAt is still there and still useful — it is when the run’s trigger arrived, which is not the same thing as now, and for a scheduled run that was caught up late it can be well in the past.
What a script cannot see is the MACHINE it is running on. A date-and-time string must carry a timezone (2026-03-04T09:15:00Z), dates are UTC, and localeCompare takes the locale you want as an argument. Without that, the same script would mean different things on different nodes — and which node runs it is not something you chose.
A few constructs are excluded from the language, because they hide control flow the platform has to be able to save. They are refused before the script runs, with the line and column:
class · this · super · new.target · async/await · generators · import/export · tagged templates · getters/setters · with · eval · debugger · Function · directives · destructuring defaults · sparse arrays · computed object keys
The clock is yours to read
const startedAt = Date.now();
const arrivedAt = new Date(input.event.receivedAt);
return { waitedMs: startedAt - arrivedAt.getTime() >= 0, year: arrivedAt.getUTCFullYear() };
Result — {"waitedMs":true,"year":2026}
The run started at or after its trigger arrived — the two are different moments, and both are available.
Say which locale you mean
return ["Öl", "Zebra"].sort((a, b) => a.localeCompare(b, "sv")).join(",");
Result — "Zebra,Öl"
Swedish files Ö after Z; German files it with O. Name the one you want and the answer is the same on every machine.
Everything else is ordinary JS
const skus = input.event.body.items.map((item) => item.sku);
return { skus, joined: skus.join(", "), count: skus.length };
Result — {"skus":["A-1","B-2"],"joined":"A-1, B-2","count":2}
Objects, arrays, JSON, Math, Map, Set, regular expressions, try/catch, arrow functions, for…of — all as you know them.
Errors, and what catches them
throw ends the run and marks it failed. The error’s name, message and the line it came from are recorded with the run.
try/catch catches what you would expect — TypeError, ReferenceError, RangeError, and anything you throw yourself — and it also catches failed platform calls. A call that could not be made surfaces as a catchable error named PlatformError, carrying the platform’s own reason in its message. When the fault is the platform’s own rather than the call’s, that reason is an internal platform error (ref 1a2b3c4d): quote the ref to your operator, whose log holds what happened. A call that reached the other side and got an unhappy answer does not throw at all: an HTTP call returns its status for you to branch on.
What try/catch never catches is the run’s budget. When a run is out of steps or objects, or out of time where it cannot park, the platform ends it — there is nothing left to catch, and that is deliberate: a script must not be able to trap its own kill switch.
A run that fails ends with one of these, recorded on the run:
PARSE_ERROR— The script could not be read as a program. The line recorded with the run says where, unless the script nests too deeply to read at all.UNSUPPORTED_SYNTAX— The script used something the language excludes. The message names it; the line recorded with the run says where.UNCAUGHT_EXCEPTION— Something was thrown and nothing caught it — including your ownthrow.EXECUTION_LIMIT— The run used up its steps, which count over its whole life, or — in a run answering an operation or a tool call, or one stepped by hand — its segment’s time. A new segment brings fresh time, never fresh steps: move per-item work into a bulk call or a dataset, or split it across runs.HEAP_LIMIT— The run created more objects than one run may over its whole life. Every object, array, function and Map counts, including those a call answered with and those the run’s input brought — the message says how many — and none is given back, so batching inside the run does not help. Keep large data in a dataset or a file, or split the work across runs.SIZE_LIMIT— Something the run held was too large to save between steps: the variables of one function call, one array, object or Map, or the result. Keep large text in a file or a dataset and hold its name, not its contents.EXTERNAL_TIMEOUT— A platform call did not answer in time.EXTERNAL_FAILED— A platform call failed and nothing caught it. Wrap the call intry/catchto handle it yourself.RETRY_NOT_PERSISTED— The script asked to be paused in a run that cannot be saved and resumed.INTERNAL— The platform itself failed. Nothing in the script can cause or prevent this one. When the message carries a ref, quote it to your operator, whose log holds what happened.
Catching an ordinary error
try {
const missing = null;
return missing.id;
} catch (error) {
return error.name + ": " + error.message;
}
Result — "TypeError: cannot read properties of null (reading 'id')"
Catching a call that could not be made
try {
fetch({ connection: "unconfigured", method: "GET", path: "/contacts" });
} catch (error) {
logError("the call could not be made", { reason: error.message });
return error.name;
}
Result — "PlatformError"
The message names the reason the platform recorded — an unknown connection here.
Throwing ends the run
throw new Error("order " + input.event.body.id + " has no shipping address");
Result — the run fails: UNCAUGHT_EXCEPTION — Error: order o-1042 has no shipping address
The result, and what JSON allows
A script’s result becomes the run’s output. A run that produces no result at all is not a failure; it simply has no output.
For a run triggered by a synchronous API call, respond(value) answers the caller. It can be called once, the run keeps going afterwards, and the run’s own result is still its output. A run triggered any other way may call respond too — nobody is listening, and nothing breaks.
The output is stored as JSON, so the usual JSON rules apply at that edge: properties whose value is undefined are dropped, and there is no form for a bigint or for an object that refers to itself. A result the platform cannot represent fails the run rather than being silently trimmed, and says which of these it hit:
cyclic— The result refers to itself, directly or through a chain of properties.bigint— A bigint has no JSON form. Convert it to a string or a number first.undefined— The valueundefinedhas no JSON form, so it cannot be stored as an output.unrepresentable— Part of the result has no JSON form — a function, for instance.
undefined is not data
return JSON.stringify({ id: input.event.body.id, note: undefined });
Result — "{\"id\":\"o-1042\"}"
Answering a synchronous caller
respond({ received: input.event.body.id });
return "answered";
Result — "answered"
Budgets
Every run is bounded, so nothing can run away. Time is budgeted per segment: when it is up the run parks and continues in a new segment, and a script can be given a segment of its own — longer or shorter — up to a ceiling the deployment sets. Steps and objects are counted over the whole run — a new segment, a checkpoint included, brings fresh time but never fresh steps or objects — so a run is long by waiting on its calls, not by computing. These are the defaults a script gets when it asks for nothing:
- Time for one segment of a run: 60000 ms — waiting on calls counts too; when it is up the run parks and continues in a new segment, while a run answering an operation or a tool call, or one stepped by hand, ends with
EXECUTION_LIMIT. - Steps one run may take over its whole life: 5000000 — a new segment brings fresh time, never fresh steps; past them the run ends with
EXECUTION_LIMIT. - Objects one run may create over its whole life: 100000 — every object, array, function and Map counts, every one a call answers with and every one the run’s input brought; none is given back, and past them the run ends with
HEAP_LIMIT. - Frames one run’s stack may hold: 40000 — each call nests a few; the call past them throws
RangeError: Maximum call stack size exceeded, whichtry/catchcatches.
Common mistakes
Assumptions a reader brings from general JS, what each one actually does here, and what to write instead. Most are refused outright; a few run and simply cost more than they look like they do. Every row is executed by the test suite in both shapes.
Exporting an entry point
That the platform imports the script and calls a main function.
Instead of this:
export function main(input) {
return { ok: true };
}
You get — PARSE_ERROR — parse error: 'import' and 'export' may appear only with 'sourceType: "module"'
Write this instead:
return { ok: true };
Why — A script is a program, not a module. Nothing imports it and nothing calls it — its statements are the run.
Assigning to module.exports
That the script is a CommonJS module whose export is the handler.
Instead of this:
module.exports = function (input) {
return { ok: true };
};
You get — UNCAUGHT_EXCEPTION — ReferenceError: module is not defined
Write this instead:
const order = input.event.body;
return { id: order.id };
Why — There is no module system and no wrapper around your code — module is simply a name nothing defines.
Awaiting a platform call
That a call which does I/O must return a promise.
Instead of this:
const response = await fetch({ connection: "crm", method: "GET", path: "/contacts" });
return response.status;
You get — PARSE_ERROR — parse error: 'await' is only allowed within async functions and at the top levels of modules.
Write this instead:
const response = fetch({ connection: "crm", method: "GET", path: "/contacts" });
return response.status;
Why — The pause happens beneath the call, not in the script: the platform saves the whole run and continues it with the result already in place.
Declaring an async function
That work involving I/O has to be wrapped in an async function.
Instead of this:
async function loadContacts() {
return fetch({ connection: "crm", method: "GET", path: "/contacts" });
}
return loadContacts();
You get — UNSUPPORTED_SYNTAX — unsupported syntax: async function
Write this instead:
function loadContacts() {
return fetch({ connection: "crm", method: "GET", path: "/contacts" });
}
return loadContacts().status;
Why — There is nothing asynchronous to express: an ordinary function that calls the platform already does the whole job.
Building a promise
That values arriving later are wrapped in promises.
Instead of this:
return new Promise(function (resolve) {
resolve(fetch({ connection: "crm", method: "GET", path: "/contacts" }));
});
You get — UNCAUGHT_EXCEPTION — ReferenceError: Promise is not defined
Write this instead:
return fetch({ connection: "crm", method: "GET", path: "/contacts" }).body;
Why — Nothing in a script arrives later — a call returns its value on the next line, so there is no promise to build.
Chaining .then
That a call’s result has to be unwrapped before it can be used.
Instead of this:
const contacts = fetch({ connection: "crm", method: "GET", path: "/contacts" }).then(function (response) {
return response.body.items;
});
return contacts;
You get — UNCAUGHT_EXCEPTION — TypeError: fetch(...).then is not a function
Write this instead:
const response = fetch({ connection: "crm", method: "GET", path: "/contacts" });
return response.body.items;
Why — A call returns the answer itself — there is no promise, so there is no .then on it, and no callback for the platform to run later.
Requiring a library
That an HTTP client — or anything else — is pulled in with require or an import.
Instead of this:
const axios = require("axios");
return axios.get("https://crm.example.com/contacts");
You get — UNCAUGHT_EXCEPTION — ReferenceError: require is not defined
Write this instead:
const response = fetch({ connection: "crm", method: "GET", path: "/contacts" });
return response.body;
Why — There is no package manager and nothing to import. Platform functions are in scope by name — fetch is the one HTTP client, and it brings the connection’s credentials, retries and logging with it — and shared code comes from a library script the platform puts in front of yours.
Reading configuration from the environment
That secrets and settings live in environment variables.
Instead of this:
return process.env.CRM_TOKEN;
You get — UNCAUGHT_EXCEPTION — ReferenceError: process is not defined
Write this instead:
return input.settings.crmConnection;
Why — A script never handles a credential: settings carry configuration, and a connection carries its own secret, which the platform applies when the call is made.
Waiting with setTimeout
That waiting means scheduling a callback.
Instead of this:
setTimeout(function () {
log("an hour later");
}, 3600000);
You get — UNCAUGHT_EXCEPTION — ReferenceError: setTimeout is not defined
Write this instead:
checkpoint(3600000);
log("an hour later");
Why — A wait is not a callback here: checkpoint saves the run, frees the worker, and continues the very next line when the wait is over — even across a restart.
Logging to the console
That output goes to standard output.
Instead of this:
console.log("order received");
You get — UNCAUGHT_EXCEPTION — ReferenceError: console is not defined
Write this instead:
log("order received", { id: input.event.body.id });
Why — A run’s log is part of the run: lines are stored with it, levelled, searchable, and kept with the evidence of what happened.
Tagging a template with a function
That a function can be called by putting a template after its name.
Instead of this:
return slugify`order ${input.event.body.id}`;
You get — UNSUPPORTED_SYNTAX — unsupported syntax: TaggedTemplateExpression
Write this instead:
return slugify(`order ${input.event.body.id}`);
Why — Backtick strings themselves work, holes included. What is not here is the tag form, where a template becomes an argument list for the function in front of it — write an ordinary call instead.
Declaring a class
That data is modelled with classes.
Instead of this:
class Order {
constructor(id) {
this.id = id;
}
}
return new Order("o-1").id;
You get — UNSUPPORTED_SYNTAX — unsupported syntax: ClassDeclaration
Write this instead:
function order(id) {
return { id: id };
}
return order(input.event.body.id).id;
Why — Classes and this are excluded — the platform saves a run’s whole state between steps, and plain objects and functions are what it can save faithfully.
Putting an object into a string
That an object turns itself into text when concatenated.
Instead of this:
return "order: " + input.event.body;
You get — UNCAUGHT_EXCEPTION — UnsupportedOperation: operator + is not supported on objects — convert explicitly, e.g. with JSON.stringify
Write this instead:
return "order: " + JSON.stringify(input.event.body);
Why — Silently turning an object into [object Object] is how useless log lines happen. Say what you meant instead.
Handing a Map to a platform call
That a platform call takes any value the script can build.
Instead of this:
const prices = new Map();
for (const item of input.event.body.items) {
prices.set(item.sku, item.price);
}
respond(prices);
You get — UNCAUGHT_EXCEPTION — TypeError: respond's argument 1 is a Map — a platform function receives JSON, which has no Map; pass Object.fromEntries([...value]) or [...value]
Write this instead:
const prices = new Map();
for (const item of input.event.body.items) {
prices.set(item.sku, item.price);
}
respond(Object.fromEntries([...prices]));
Why — A platform call receives JSON, which has no Map, Set or RegExp. Sending {} in their place would look like success while the other side got nothing, so the call refuses and names where the value sits. The log functions show them instead, because a person reads a log line.
Changing the trigger
That the incoming payload is a working copy.
Instead of this:
input.event.body.status = "processed";
return input.event.body;
You get — UNCAUGHT_EXCEPTION — TypeError: cannot mutate the read-only input
Write this instead:
const order = { id: input.event.body.id, status: "processed" };
return order;
Why — The trigger is the run’s evidence of what it was asked to do, and a resumed run must read exactly what it started with.
Calling the platform inside a callback
That map is a way to make one call per item without thinking about the calls.
Instead of this:
const contacts = input.event.body.items.map((item) =>
fetch({ connection: "crm", method: "GET", path: "/contacts/" + item.sku }),
);
return contacts.length;
This runs — It works, and it is one call per item — in order, one after another, never at the same time. Ten items is fine. A thousand is a thousand calls, and if the far end starts refusing them the run parks and resumes once per call, which can take hours. Nothing warns you: the shape that costs an hour looks exactly like the shape that costs a second. And when the per-item work is only staging rows, there is no loop to pay for at all: dsPutMany stores the whole list in one call, dsGetMany reads many keys back in one, dsFetchInto reads every page of a source into a dataset without a loop at all, and dsMapInto/dsEnrich reshape or fill in a whole staged set the same way.
Write this instead:
const items = input.event.body.items;
const contacts = [];
for (const item of items) {
contacts.push(fetch({ connection: "crm", method: "GET", path: "/contacts/" + item.sku }));
}
return contacts.length;
// The loop can stop. `break` on a budget, a count, a bad answer —
// none of which a callback can say. From inside a callback the only
// way out is `throw`, which abandons the whole iteration.
Why — A map reads as a transformation and hides that each item is a call. A loop puts the calls in front of you, lets you stop early, and is the shape to reach for whenever the list is not small and known.
Summarising a dataset by reading it into the script
That counting staged rows means walking them and keeping a tally.
Instead of this:
dsPutMany("run:people", [
{ key: "u1", value: { dept: "Research" } },
{ key: "u2", value: { dept: "Research" } },
{ key: "u3", value: { dept: "Operations" } },
]);
const counts = {};
let at = 0;
for (;;) {
const page = dsPage("run:people", { offset: at, limit: 200 });
if (page.length === 0) { break; }
for (const row of page) {
counts[row.value.dept] = (counts[row.value.dept] || 0) + 1;
}
at = at + page.length;
}
return counts.Research;
This runs — It works, and on a few hundred rows it is fine. On a real staged set it pulls the whole set into the run a page at a time to produce three numbers — every row read, counted and thrown away. dsStats counts in one query without moving a row, dsCount answers a total the same way, and sendReport turns the whole report dataset into a readable summary without a script seeing a single row.
Write this instead:
dsPutMany("run:people", [
{ key: "u1", value: { dept: "Research" } },
{ key: "u2", value: { dept: "Research" } },
{ key: "u3", value: { dept: "Operations" } },
]);
const groups = dsStats("run:people", "dept");
// One GROUP BY where the rows are. Each group is the value as JSON
// text — "Research" arrives with its quotes.
return groups[0].count;
Why — Staged data lives outside the run so that its size is not the run’s problem. A tally written in script quietly pulls it back in — the one thing the dataset calls exist to avoid.
Reading a file into the script to parse it
That a stored CSV is read into the script and parsed there.
Instead of this:
const file = writeBlob(btoa("email\nada@example.com\n"), "text/csv");
return parseCsv(atob(readBlob(file).contentBase64)).length;
This runs — It works on a sample and refuses on the real thing: a script may hold a few hundred kilobytes, and readBlob answers BLOB_TOO_LARGE for an export measured in megabytes. dsFromCsv reads the file where it is stored, a batch at a time, into a dataset.
Write this instead:
const file = writeBlob(btoa("email\nada@example.com\n"), "text/csv");
return dsFromCsv(file, "run:contacts", { keyPath: "email" }).rows;
Why — A file’s contents are kept outside a run on purpose: a handle costs nothing to carry, a megabyte costs the run.