Transform Step — User Guide¶
The transform step runs a small JavaScript snippet in a locked-down sandbox so you can reshape, compute, or derive new workflow variables between other steps. Use it when you need to:
- Combine or format input variables (e.g. build a
fullNamefromfirstName+lastName). - Pick one value out of many (e.g. choose an approver ref based on the submitter's department).
- Emit a categorical selector for a downstream
casestep (remember:conditionsteps can't express truthy / null checks — emit"yes"/"no"from a transform and branch on that). - Correlate data across earlier steps before handing it to a connector or notification.
Transform steps are not the place to make HTTP calls, read files, or import modules — use a connector step for anything that touches the outside world.
The simplest possible transform¶
That's the whole script. output.currentDate becomes a workflow variable and
downstream steps can reference it as {{currentDate}}. No return statement,
no separate "outputs" list — every key you assign to output becomes a
workflow variable.
Runtime at a glance¶
| Aspect | Value |
|---|---|
| Engine | QuickJS (ES2020-ish, no console, no fetch, no require) |
| Timeout | 5 seconds |
| Memory limit | 16 MB |
| Log entry cap | 100 entries per execution |
| Inputs | Read from vars (alias of floh.variables) |
| Outputs | Assign to output.foo = ... — every assigned key becomes a workflow variable |
Scripts run isolated from Node, the database, the filesystem, and the network.
There is no way to import or require anything. Whatever you need has to be
on one of the bare globals listed below or expressible in plain JavaScript.
Globals available in the sandbox¶
| Global | Purpose |
|---|---|
output |
Plain object — assign keys to it (output.foo = 1) to produce workflow variables |
vars |
Alias of floh.variables — every workflow variable's current value |
log.info(msg, data?) |
Structured log line (also warn, error, debug, trace) |
now() |
ISO-8601 timestamp string for "right now" |
today() |
YYYY-MM-DD string for today (UTC) |
uuid() |
v4-shaped UUID. NOT cryptographically strong — fine for IDs, wrong for tokens |
addDays(date, n) |
Returns a new Date shifted by n whole days. Accepts Date or ISO string |
formatDate(date, fmt) |
Format a date with YYYY, MM, DD, HH, mm, ss tokens (UTC) |
slug(str) |
Lowercase + collapse non-alphanumerics to -. Good for usernames / URL slugs |
titleCase(str) |
Capitalise the first letter of each word |
Anything else you try to reference (console, fetch, process, require,
etc.) throws a ReferenceError.
The legacy floh.* namespace (floh.variables, floh.log.*, floh.uuid(),
floh.now()) keeps working — the bare globals are aliases, not replacements,
so existing scripts run unchanged.
Logging rules you should know¶
- The
dataargument must be a plain, non-array object ({ key: value }). Primitives and arrays are silently dropped from the entry. Wrap lists in an object first:log.info("items", { items: myArray }). - The engine forwards each entry to the workflow run log under
source: "transform:<stepId>", so you can find them in the run timeline. - After 100 entries the buffer stops accepting new log calls. Don't log in a tight loop.
Authoring outputs¶
Two equivalent ways to produce workflow variables:
Style A — assign to output (recommended)¶
No return statement. Both keys (fullName, accountName) become workflow
variables. Idiomatic for non-JS-fluent authors.
Style B — return an object literal (legacy, still supported)¶
return {
fullName: vars.firstName + " " + vars.lastName,
accountName: slug(vars.firstName + " " + vars.lastName),
};
Both keys become workflow variables. Still works for existing scripts and useful when you want to compute everything in a single expression.
Mixing both¶
You can mix them. When the same key appears in both, the return value wins — explicit beats implicit:
What about typos?¶
The transform step's editor in the designer shows a read-only "Outputs
produced by this script" panel that updates as you type. If you expected
fullName and the panel shows fulName, you'll see the typo at edit time
instead of debugging a missing variable downstream. Click any chip in the
panel to copy its {{name}} reference syntax into the clipboard.
Dynamic keys¶
output[someName] = value (computed property) and spread (return { ...other })
still produce workflow variables at runtime — but the static parser can't
know what they are without running the script, so they aren't listed in the
designer panel. The panel shows a small footnote when it sees one, so you
know the listed names aren't the full picture. Treat dynamic keys as an
advanced escape hatch.
Returning a non-object is still an error¶
return 42; // fails: Transform script must return an object
return "ok"; // fails: same
return [1, 2, 3]; // fails: arrays are not objects for this purpose
Either return an object literal or omit the return entirely and use
output assignments.
Designer affordances¶
The workflow designer surfaces three affordances that make transform scripts more discoverable:
Snippet picker¶
Click Insert snippet above the script editor to see a curated list of
common transform patterns — building a full name, deriving an account
name + email, branching on manager presence, building an idempotency
key, and so on. Clicking a snippet inserts its code at the editor's
current cursor position (preserving anything you've already written).
The catalog uses only the Phase 1 author surface — output, vars,
helper functions like now() / today() / addDays(...) / slug(...)
— so there's no floh.* prefix to learn before they make sense.
Live preview¶
As you type, the Live preview pane below the editor automatically
runs your script (debounced ~1.5 seconds) against the current mock
variables and renders the resolved output object. You don't need to
click a button — the preview updates whenever you stop typing. This is
the fastest feedback loop for "did the script produce what I expected"
without leaving the editor surface.
The preview hits the same POST /api/workflows/transform-test endpoint
as the manual test panel and shares its rate limit (10 requests per
minute). The 1.5s debounce keeps normal typing well below that ceiling;
if you do hit the limit a friendly message appears in the pane and
preview resumes after the next edit.
Reference badges¶
Each detected output in the Outputs produced by this script panel carries a small badge:
used by N steps(green) — N downstream steps reference{{name}}in their config (notification body, connector parameter, approval ref, etc.).not yet referenced(yellow) — no downstream step references the output yet. If you weren't expecting that, double-check you wired{{name}}into the right field on the next step. Outputs that are intentionally side-effect-free (e.g. only used for anoutput.log = ...debugging pass) will sit on this badge harmlessly.
The badge is informational only — not yet referenced does NOT block
save or publish. The intent is to surface "your script is producing a
value that nothing is consuming yet" early, not to gate the workflow.
Testing a script in the designer¶
The workflow designer's Test Script panel (under any transform step) runs
your script against the POST /api/workflows/transform-test endpoint with
whatever mock variables you paste into the Mock Variables textarea. The
designer prepopulates that textarea with a realistic shape for every declared
workflow variable, including user-type expansions. Use the manual test
panel (alongside the always-on live preview above) to:
- See the merged outputs (every key the script produced).
- Inspect every
log.*entry side-by-side with the output. - Catch
Transform script must return an objectand timeout errors before publishing.
The test endpoint does not create a run, does not call connectors, and is rate-limited to 10 requests per minute per caller.
Sample scripts¶
Every sample below is a complete transform-step body — drop it into the script field and you're done.
1. Hello-world: build a full name¶
output.fullName = (vars.firstName + " " + vars.lastName).trim();
log.info("Built full name", { fullName: output.fullName });
2. Build a date-stamped record id¶
3. Emit a categorical selector for a case step¶
// The condition step can't express truthy / null checks. Emit a stable
// string selector and branch on it with a case step.
//
// Downstream: a case step with selector "managerBranch" and arms
// { when: "has_manager" } / { when: "no_manager" } + default.
var target = vars.targetUser;
var hasManager = !!(target && target.manager && target.manager.id);
log.debug("Routing by manager presence", {
targetUserId: target ? target.id : null,
hasManager: hasManager,
});
output.managerBranch = hasManager ? "has_manager" : "no_manager";
4. Resolve a single approver ref (OR-of semantics)¶
approval steps are AND-of: every entry in approvers must reach a
non-pending decision. To express "either X OR Y can approve", resolve the
single correct ref in a transform and hand it over as a one-element list.
// Downstream: approval step config { approvers: ["{{approverRef}}"] }
var submitter = vars.submitter; // always the real caller — never spoofable
var amount = Number(vars.requestedAmount || 0);
if (amount > 10000) {
output.approverRef = "group:helpdesk-approvers";
} else if (submitter && submitter.manager && submitter.manager.id) {
output.approverRef = "user:" + submitter.manager.id;
} else {
output.approverRef = "group:helpdesk-approvers";
log.warn("Submitter has no manager; falling back to helpdesk", {
submitterId: submitter ? submitter.id : null,
});
}
log.info("Resolved approver", { approverRef: output.approverRef, amount: amount });
5. Normalise a connector account name¶
var first = String(vars.firstName || "")
.toLowerCase()
.trim();
var last = String(vars.lastName || "")
.toLowerCase()
.trim();
var domain = String(vars.emailDomain || "example.com")
.toLowerCase()
.trim();
var accountName = slug(first + " " + last);
if (accountName.length === 0) {
// Throwing surfaces as the step's error message and routes through
// `on: "error"` if the step has one declared. Bail BEFORE setting any
// outputs so a failed step never leaves a half-formed identifier behind.
throw new Error("Could not derive an account name from the supplied variables");
}
output.accountName = accountName;
output.primaryEmail = accountName + "@" + domain;
log.info("Derived account identifiers", {
accountName: output.accountName,
primaryEmail: output.primaryEmail,
});
6. Pick a per-connector identity out of externalIdentities¶
// `user`-type variables include an externalIdentities array listing
// per-connector identity links. Resolve a connector-specific account email.
var target = vars.targetUser;
var identities = (target && target.externalIdentities) || [];
var match = null;
for (var i = 0; i < identities.length; i++) {
var ident = identities[i];
if (ident && String(ident.connectorId).indexOf("googleWorkspace") === 0) {
match = ident;
break;
}
}
if (!match || !match.email) {
log.error("Target user has no Google Workspace identity", {
targetUserId: target ? target.id : null,
connectorCount: identities.length,
});
throw new Error("Target user is not linked to Google Workspace yet");
}
output.googleEmail = match.email;
log.info("Resolved Google Workspace email", {
targetUserId: target.id,
googleEmail: output.googleEmail,
});
7. Build a ticket payload with an idempotency key¶
// Capture the idempotency key once per transform run so retries in the
// downstream connector don't create duplicates.
output.ticketPayload = {
idempotencyKey: uuid(),
createdAt: now(),
requestedBy: vars.submitter.email,
subject: vars.ticketSubject,
body: vars.ticketBody,
priority: vars.ticketPriority || "normal",
};
log.info("Built ticket payload", {
idempotencyKey: output.ticketPayload.idempotencyKey,
createdAt: output.ticketPayload.createdAt,
});
8. Defensive error handling on user-supplied JSON¶
// User-supplied JSON strings from a user_prompt step often arrive with
// stray whitespace or trailing commas. Parse defensively and emit a
// useful error message when the input can't be parsed.
var raw = vars.rawJsonPayload;
if (typeof raw !== "string" || raw.trim().length === 0) {
throw new Error("rawJsonPayload is empty — nothing to parse");
}
try {
output.parsedPayload = JSON.parse(raw);
} catch (err) {
log.error("Failed to parse rawJsonPayload", {
length: raw.length,
preview: raw.slice(0, 80),
});
throw new Error("rawJsonPayload is not valid JSON: " + err.message);
}
if (
!output.parsedPayload ||
typeof output.parsedPayload !== "object" ||
Array.isArray(output.parsedPayload)
) {
throw new Error("rawJsonPayload must decode to an object, not an array or primitive");
}
Common pitfalls¶
- Returning the wrong shape.
return [a, b],return 42,return "ok", and other non-object returns fail withTransform script must return an object. Either return an object literal or useoutput.foo = ...and omit thereturn. - Mutating
varsin place. Harmless within the sandbox (the object is a JSON copy of the run state) but confusing to read later. Build new values onoutputinstead. - Using
console.log. There is noconsolein the sandbox. Uselog.info(...)— the entries show up in the run timeline undersource: "transform:<stepId>". - Passing an array to
log.*. Thedataargument must be a plain non-array object. Wrap lists:log.info("items", { items: myArray }). - Hitting the 5-second timeout. Transform steps are for cheap in-memory computation. If you find yourself looping over thousands of items or building a deeply nested string, move the work into a connector.
- Treating
uuid()as cryptographically strong. It usesMath.random()under the hood. Fine for correlation IDs and idempotency keys; wrong for security tokens or nonces. - Shadowing
output.var output = somethingElseinside the script shadows the wrapper'soutputglobal and breaks the contract. Don't do this. Pick a different variable name.
Setup scripts on other step types¶
Phase 3 extends the transform-script author surface to almost every
other step type via an optional setupScript field. A setup script
runs in the same sandbox as a transform script, executes BEFORE the
step's main logic, and its output.foo = bar writes become workflow
variables that are immediately available for the step's own config
interpolation AND for downstream steps.
This lets you compute small values right next to the step that uses them — a notification body, a connector parameter, an idempotency key, a normalized condition expression — instead of inserting a standalone transform step just to derive things.
See the dedicated Setup script guide for the
full list of supported step types, worked examples per step type, and
authoring tips. The author surface (output, vars, now, today,
uuid, addDays, formatDate, slug, titleCase, log, the
floh.* namespace) is identical to the transform-script surface
documented above.
Excluded step types: start, end, fork, join, transform
(which already exposes script and is the right tool when you need
multiple lines of pre-step compute), and sub_workflow (dispatched by
the engine before StepExecutor runs, so the hook would never fire).
Related reading¶
- Setup script guide —
setupScriptauthor guide for non-transform step types. .cursor/rules/domain/floh-workflows.mdc— full schema for workflow definitions, including theTransformStepConfigshape and the step-failure routing contract.- User Self-Service Workflows — covers the
implicit
submitterandtargetUservariables you'll usually be reading fromvars. - RFC — User Variable Model — the
authoritative description of what a resolved
user-type variable looks like insidevars.