Skip to content

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 fullName from firstName + 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 case step (remember: condition steps 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

output.currentDate = now();

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 data argument 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:

output.fullName = vars.firstName + " " + vars.lastName;
output.accountName = slug(output.fullName);

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:

output.foo = "from-output";
return { foo: "from-return" }; // wins

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 an output.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 object and 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

output.recordId = today() + "-" + uuid();
output.createdAt = now();

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 with Transform script must return an object. Either return an object literal or use output.foo = ... and omit the return.
  • Mutating vars in place. Harmless within the sandbox (the object is a JSON copy of the run state) but confusing to read later. Build new values on output instead.
  • Using console.log. There is no console in the sandbox. Use log.info(...) — the entries show up in the run timeline under source: "transform:<stepId>".
  • Passing an array to log.*. The data argument 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 uses Math.random() under the hood. Fine for correlation IDs and idempotency keys; wrong for security tokens or nonces.
  • Shadowing output. var output = somethingElse inside the script shadows the wrapper's output global 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).

  • Setup script guidesetupScript author guide for non-transform step types.
  • .cursor/rules/domain/floh-workflows.mdc — full schema for workflow definitions, including the TransformStepConfig shape and the step-failure routing contract.
  • User Self-Service Workflows — covers the implicit submitter and targetUser variables you'll usually be reading from vars.
  • RFC — User Variable Model — the authoritative description of what a resolved user-type variable looks like inside vars.