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.


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, and floh.params exist on the sandbox shape shared with connector scripts, but inside a transform step they always contain empty objects ({}) and floh.http.* returns a stubbed response. If you need HTTP, use a connector step. Never read floh.config / floh.params from a transform — their values are not stable.

Logging rules you should know

  • data must 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 return with a plain object. Returning a string, number, array, or nothing fails the step with Transform script must return an object.
  • Only keys listed in the step's outputs array are persisted as workflow variables downstream. Undeclared keys are dropped; declared keys you don't return produce a warn log 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 outputVariables actually returned.
  • Inspect every floh.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 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 in outputs are silently discarded. If a downstream step says a variable is undefined, this is the first thing to check.
  • Returning the wrong shape. Transform scripts must return a plain object. return [a, b], return 42, return "ok", and falling off the end of the script all fail with Transform script must return an object.
  • Mutating floh.variables in 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 no console in the sandbox. Use floh.log.info(...) — the entries show up in the run timeline under source: "transform:<stepId>".
  • Passing an array to floh.log.*. The data argument 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 uses Math.random() under the hood. Fine for correlation IDs and idempotency keys; wrong for security tokens or nonces.

  • .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 floh.variables.
  • RFC — User Variable Model — the authoritative description of what a resolved user-type variable looks like inside floh.variables.