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.
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 | Everything on floh.variables |
| Outputs | The object you return — but only keys declared in outputs are kept |
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 the floh global described below or expressible in plain JavaScript.
The floh global¶
Inside a transform script, one global is available: floh. Anything else you
try to reference (console, fetch, process, require, etc.) throws a
ReferenceError.
| Member | Purpose |
|---|---|
floh.variables |
Plain object of every workflow variable's current value, including the implicit submitter snapshot. |
floh.log.info(msg, data?) |
Emit a log entry at info level. |
floh.log.warn(msg, data?) |
Emit a log entry at warn level. |
floh.log.error(msg, data?) |
Emit a log entry at error level. |
floh.log.debug(msg, data?) |
Emit a log entry at debug level. |
floh.log.trace(msg, data?) |
Emit a log entry at trace level. |
floh.uuid() |
Returns a v4-shaped UUID string (not cryptographically strong — fine for IDs, wrong for tokens). |
floh.now() |
Returns an ISO-8601 timestamp string for "right now". |
Note:
floh.http,floh.config, andfloh.paramsexist on the sandbox shape shared with connector scripts, but inside a transform step they always contain empty objects ({}) andfloh.http.*returns a stubbed response. If you need HTTP, use a connector step. Never readfloh.config/floh.paramsfrom a transform — their values are not stable.
Logging rules you should know¶
datamust be a plain, non-array object ({ key: value }). Primitives and arrays are silently dropped from the log entry. Wrap lists in an object first:floh.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.
Input / output contract¶
A transform script is wrapped roughly like this under the hood (simplified):
function transform() {
var __result = (function () {
// ← your script body is pasted here
})();
if (__result && typeof __result === "object" && !Array.isArray(__result)) {
return { success: true, outputVariables: __result };
}
return { success: false, error: "Transform script must return an object" };
}
Which means:
- You must use a top-level
returnwith a plain object. Returning a string, number, array, or nothing fails the step withTransform script must return an object. - Only keys listed in the step's
outputsarray are persisted as workflow variables downstream. Undeclared keys are dropped; declared keys you don't return produce awarnlog entry. - Each output name must match
^[a-zA-Z_][a-zA-Z0-9_]*$— no dashes, no spaces, no leading digits — and names must be unique.
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 it to:
- See the
outputVariablesactually returned. - Inspect every
floh.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 set outputs to the keys it returns.
1. Hello-world with a verbose walkthrough¶
The simplest useful script. Great starting point for a new workflow: it copies two input variables into a new one, emits a log line, and returns the result.
// The `floh` global is the only thing available in a transform script.
// - floh.variables → every workflow variable's current value
// - floh.log.info → structured log line in the run timeline
// - floh.uuid() → v4-shaped UUID (NOT cryptographically strong)
// - floh.now() → ISO-8601 timestamp string
//
// Declare this step's `outputs: ["fullName"]` so the value below survives.
// Grab inputs once. `floh.variables` is rebuilt fresh for each run, so there
// is no need to defensive-copy it.
var v = floh.variables;
// Compose a human-readable full name. Coerce to string and trim in case the
// inputs come in with surrounding whitespace from a catalog form.
var first = String(v.firstName || "").trim();
var last = String(v.lastName || "").trim();
var fullName = (first + " " + last).trim();
// Log data must be a plain non-array object — arrays and primitives get
// silently dropped from the entry.
floh.log.info("Built full name", { firstName: first, lastName: last, fullName: fullName });
// A transform script MUST return a plain object. Returning a string / number /
// array / nothing fails the step.
return { fullName: fullName };
2. Log every input and every output (debugging helper)¶
Useful while you're building a workflow and want to see exactly what's flowing
through a particular point. Configure outputs: ["_transformRanAt", "_transformRunId"]
(or add the names of any passthrough variables you care about).
// --- 1. Snapshot the inputs ---------------------------------------------
// `Object.keys()` + a manual loop is the QuickJS-safe way to walk an object.
// (Object.entries / Object.fromEntries are available in QuickJS but the loop
// is easier to debug and keeps us inside a very small feature set.)
var inputs = floh.variables || {};
var inputKeys = Object.keys(inputs);
floh.log.info("Transform inputs", {
count: inputKeys.length,
keys: inputKeys,
variables: inputs,
});
// --- 2. Build outputs ----------------------------------------------------
// For this debugging helper we also pass every input through as an output,
// so downstream steps can still see them. Remember: only keys in this step's
// `outputs` array actually persist — so list every key you want passed through.
var outputs = {};
for (var i = 0; i < inputKeys.length; i++) {
var key = inputKeys[i];
outputs[key] = inputs[key];
}
// Add two helper fields so you can confirm the transform actually ran and tie
// log entries back to a single execution.
outputs._transformRanAt = floh.now();
outputs._transformRunId = floh.uuid();
// --- 3. Log the outputs before returning --------------------------------
floh.log.info("Transform outputs", {
count: Object.keys(outputs).length,
keys: Object.keys(outputs),
variables: outputs,
});
return outputs;
3. Emit a categorical selector for a case step¶
The condition step can't express truthy / null checks — the evaluator is a
strict binary-operator parser. Use a transform to emit a stable string selector
and branch on it with a case step.
// Outputs: ["managerBranch"]
// Emits "has_manager" or "no_manager" based on the resolved targetUser.
//
// Downstream: a case step with selector "managerBranch" and arms
// { when: "has_manager" } / { when: "no_manager" } + default.
var target = floh.variables.targetUser;
// `user`-type variables resolve to a full snapshot including `manager`.
// `manager` is either an object ({ id, email, displayName }) or null.
var hasManager = !!(target && target.manager && target.manager.id);
floh.log.debug("Routing by manager presence", {
targetUserId: target ? target.id : null,
hasManager: hasManager,
});
return { 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.
// Outputs: ["approverRef"]
// Chooses between the submitter's manager and the helpdesk group, then
// formats the result as a single approver ref string.
//
// Downstream: approval step config { approvers: ["{{approverRef}}"] }
var submitter = floh.variables.submitter; // always the real caller — never spoofable
var amount = Number(floh.variables.requestedAmount || 0);
var ref;
if (amount > 10000) {
// High-value requests always go to the helpdesk group.
ref = "group:helpdesk-approvers";
} else if (submitter && submitter.manager && submitter.manager.id) {
// Normal path: the submitter's line manager.
ref = "user:" + submitter.manager.id;
} else {
// Fall back to the helpdesk group when the submitter has no manager on
// file. Without this branch the approval step would create an unresolved
// manager-ref row and silently skip.
ref = "group:helpdesk-approvers";
floh.log.warn("Submitter has no manager; falling back to helpdesk", {
submitterId: submitter ? submitter.id : null,
});
}
floh.log.info("Resolved approver", { approverRef: ref, amount: amount });
return { approverRef: ref };
5. Normalize a connector account name¶
Typical "build a Google Workspace / Active Directory username from a person's name" helper. Shows string manipulation and regex usage in QuickJS.
// Outputs: ["accountName", "primaryEmail"]
var v = floh.variables;
var first = String(v.firstName || "")
.trim()
.toLowerCase();
var last = String(v.lastName || "")
.trim()
.toLowerCase();
var domain = String(v.emailDomain || "example.com")
.trim()
.toLowerCase();
// Collapse any internal whitespace, strip characters that aren't a–z/0–9/.,
// and enforce a max length so we don't blow through connector limits.
var base = (first + "." + last)
.replace(/\s+/g, ".") // spaces → dots
.replace(/[^a-z0-9.]/g, "") // drop anything weird
.replace(/\.{2,}/g, ".") // squash runs of dots
.replace(/^\.|\.$/g, ""); // trim leading/trailing dots
if (base.length === 0) {
// Deterministic failure: throwing here surfaces as the step's error message
// and routes through `on: "error"` if the step has one declared.
throw new Error("Could not derive an account name from the supplied variables");
}
if (base.length > 64) base = base.slice(0, 64);
var primaryEmail = base + "@" + domain;
floh.log.info("Derived account identifiers", {
firstName: first,
lastName: last,
accountName: base,
primaryEmail: primaryEmail,
});
return { accountName: base, primaryEmail: primaryEmail };
6. Pick a per-connector identity out of externalIdentities¶
user-type variables include an externalIdentities array listing per-connector
identity links. Use this to resolve a connector-specific account email before
handing off to a connector step.
// Outputs: ["googleEmail"]
//
// externalIdentities entries look roughly like:
// { connectorId: "googleWorkspace-prod", email: "jane@corp.example", ... }
// We want the first live link for the Google Workspace connector — or fail
// loudly if the target user isn't provisioned there yet.
var target = floh.variables.targetUser;
var identities = (target && target.externalIdentities) || [];
// QuickJS supports Array.prototype.find, but a hand-rolled loop keeps the
// failure mode explicit and gives us a place to log decision points.
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) {
floh.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");
}
floh.log.info("Resolved Google Workspace email", {
targetUserId: target.id,
googleEmail: match.email,
});
return { googleEmail: match.email };
7. Tag a record with helper fields (uuid, now)¶
Use the helpers to stamp a payload that a downstream connector step will persist. Handy for correlation IDs and idempotency keys.
// Outputs: ["ticketPayload"]
//
// Builds a ticket payload with a deterministic idempotency key so that if the
// connector retries, it doesn't create duplicates on the far side. `floh.uuid`
// returns a fresh UUID on every call; capture it once per transform run.
var idempotencyKey = floh.uuid();
var createdAt = floh.now();
var ticketPayload = {
idempotencyKey: idempotencyKey,
createdAt: createdAt,
requestedBy: floh.variables.submitter.email,
subject: floh.variables.ticketSubject,
body: floh.variables.ticketBody,
priority: floh.variables.ticketPriority || "normal",
};
floh.log.info("Built ticket payload", {
idempotencyKey: idempotencyKey,
createdAt: createdAt,
});
return { ticketPayload: ticketPayload };
8. Defensive error handling¶
Transform scripts inherit the workflow's failure-routing contract: throwing an
Error fails the step with the thrown message, which routes through the
step's on: "error" transition (or the workflow's global onError setting if
there is no error edge). The engine also writes a sanitized error envelope to
{{<stepId>.error}} and {{lastStepError}} — see the Workflow Orchestration
reference for the full contract.
// Outputs: ["parsedPayload"]
//
// 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 = floh.variables.rawJsonPayload;
if (typeof raw !== "string" || raw.trim().length === 0) {
throw new Error("rawJsonPayload is empty — nothing to parse");
}
var parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
floh.log.error("Failed to parse rawJsonPayload", {
length: raw.length,
preview: raw.slice(0, 80), // safe to log — truncated preview only
});
// The thrown message becomes {{<stepId>.error.message}} for downstream use.
throw new Error("rawJsonPayload is not valid JSON: " + err.message);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("rawJsonPayload must decode to an object, not an array or primitive");
}
return { parsedPayload: parsed };
Common pitfalls¶
- Forgetting to declare
outputs. Keys not listed inoutputsare silently discarded. If a downstream step says a variable isundefined, this is the first thing to check. - Returning the wrong shape. Transform scripts must
returna plain object.return [a, b],return 42,return "ok", and falling off the end of the script all fail withTransform script must return an object. - Mutating
floh.variablesin place. Harmless within the sandbox (the object is a JSON copy of the run state) but confusing to read later. Build a new object to return instead. - Using
console.log. There is noconsolein the sandbox. Usefloh.log.info(...)— the entries show up in the run timeline undersource: "transform:<stepId>". - Passing an array to
floh.log.*. Thedataargument must be a plain non-array object. Wrap lists:floh.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
floh.uuid()as cryptographically strong. It usesMath.random()under the hood. Fine for correlation IDs and idempotency keys; wrong for security tokens or nonces.
Related reading¶
.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 fromfloh.variables. - RFC — User Variable Model — the authoritative description of what a resolved
user-type variable looks like insidefloh.variables.