Setup scripts on workflow steps¶
TL;DR — Almost any step in a Floh workflow can carry an optional setup script that runs in a sandboxed JavaScript runtime before the step's main logic. Use it to compute small values right next to the step that needs 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.
Why setup scripts¶
Workflow authors often need to combine variables in small ways before passing them to the next step. Without setup scripts, that work has to live in a separate transform step, which means:
- The intent ("compute the notification body for this notification step") is split across two places in the workflow.
- The graph fills up with single-purpose transform steps that aren't really doing anything interesting.
- Authors who aren't fluent in JS have to keep context-switching between the step they're configuring and the script that prepares it.
Setup scripts let you keep that small derivation inline on the step that uses it.
Quick example¶
A notification step that wants to address the recipient by their full name:
// On a notification step, in the "Setup script (optional)" panel:
output.fullName = (vars.firstName + " " + vars.lastName).trim();
output.subject = "Welcome, " + output.fullName + "!";
Then in the same step's body field below:
Author surface (same as transform scripts)¶
Setup scripts run in the same QuickJS sandbox as transform-step scripts and expose the same author surface:
output.foo = bar— write a workflow variable.vars— current workflow variables (alias offloh.variables).now()/today()— ISO timestamp /YYYY-MM-DDfor today (UTC).uuid()— generate a UUID.addDays(date, n),formatDate(date, fmt),slug(str),titleCase(str).log.info(msg, data?)/log.warn(...)/log.error(...)/log.debug(...)/log.trace(...)— structured logging.- The full
floh.*namespace also works (floh.variables,floh.log,floh.now(),floh.uuid()).
See transform-step-guide.md for the complete API reference.
Where outputs go¶
Every key you assign to output is added to the run's variable bag — visible BOTH to:
- This step's own config interpolation. A setup script that writes
output.recipientEmail = ...makes{{recipientEmail}}resolve in the same step's recipient/body/subject fields. - Every downstream step.
{{recipientEmail}}will also resolve in any step that runs after this one.
The Phase 1 outputs panel (with click-to-copy {{name}} references) and the Phase 2 reference badges (used by N steps / not yet referenced) work for setup-script outputs the same way they work for transform outputs.
Which step types accept a setup script¶
Almost all of them. The exceptions are the structural types — steps that don't execute domain logic of their own — and the transform step (which already exposes a top-level script field that fills the same role).
| Step type | Setup script supported? | Reason |
|---|---|---|
action |
yes | |
connector |
yes | Common: build connector params from upstream variables. |
notification |
yes | Common: compute body / subject / recipient. |
approval |
yes | Common: resolve a single approver ref. |
condition |
yes | Common: precompute a normalized value to test. |
case |
yes | Common: normalize the discriminator. |
consent / wait / verify_contact / send_sms / user_create / profile_update / role_grant / role_revoke / pam_checkout / document_submission / user_prompt / first_password_invitation / others |
yes | |
transform |
no | The transform's script field already serves this purpose. |
sub_workflow |
no | Dispatched by the engine before StepExecutor runs — the hook would never fire. |
start / end |
no | Entry/exit nodes — no work to do beforehand. |
fork / join |
no | Routing only — no domain logic. |
The validator rejects setupScript on the excluded types at save and publish time. The runtime ignores it (defense in depth) and emits a warn log if a misconfigured row slips through.
Limits¶
Setup scripts share the transform-script sandbox, so they share its limits:
- 5 second wall-clock timeout per execution.
- 16 MB memory cap.
- 64 KB maximum source size.
- No network:
fetch,XMLHttpRequest, sockets, etc. are not available. - No module system:
require,import, dynamicimport(...)are blocked. - No process / globalThis access: same as transform scripts.
Worked examples¶
Notification — compute body and subject¶
output.fullName = (vars.firstName + " " + vars.lastName).trim();
output.subject = "Welcome, " + output.fullName + "!";
output.body =
"Hi " + output.fullName + ",\n\nYour account is ready. Sign in at " + vars.loginUrl + ".";
Connector — build a createUser payload¶
output.userPayload = {
primaryEmail:
vars.firstName.toLowerCase() + "." + vars.lastName.toLowerCase() + "@" + vars.emailDomain,
givenName: vars.firstName,
familyName: vars.lastName,
// Stable idempotency key — a retry submits the same value, the connector
// should treat that as a no-op rather than a duplicate create.
idempotencyKey: uuid(),
};
Approval — resolve a single approver¶
// Compute one approver ref. The approval step is AND-of, so picking among
// alternatives belongs in the setup script, not in approval rules.
var amount = Number(vars.requestedAmount || 0);
if (amount > 10000) {
output.approverRef = "group:helpdesk-approvers";
} else if (vars.submitter && vars.submitter.manager && vars.submitter.manager.id) {
output.approverRef = "user:" + vars.submitter.manager.id;
} else {
output.approverRef = "group:helpdesk-approvers";
log.warn("Submitter has no manager; falling back to helpdesk", {
submitterId: vars.submitter ? vars.submitter.id : null,
});
}
Condition — normalize a discriminator before testing¶
// Condition expressions don't have string operations, so normalize first.
output.normalizedDecision = String(vars.decision || "")
.toLowerCase()
.trim();
Then in the condition step's expression: normalizedDecision == "approved".
Case — normalize the case key¶
// Map free-form region strings into the discrete cases the case step
// switches on.
var region = String(vars.region || "")
.toLowerCase()
.trim();
if (["us", "usa", "united states", "us-east", "us-west"].includes(region)) {
output.regionGroup = "north-america";
} else if (["uk", "fr", "de", "es", "it", "ie"].includes(region)) {
output.regionGroup = "europe";
} else {
output.regionGroup = "other";
}
Then the case step switches on regionGroup.
Action — stamp a record-keeping field¶
Failure semantics¶
If the setup script throws, times out, or returns a non-object, the step fails before its main logic runs. The error message is prefixed with setupScript failed: so it's easy to distinguish from a main-logic failure in the run logs.
This is the same failure model as a transform-step script.
Authoring tips¶
- Keep setup scripts small. If a setup grows past ~30 lines, consider extracting the logic into a dedicated transform step so it has its own outputs panel, live preview, and snippet picker.
- Use the outputs panel. As you type, the panel below the editor lists every detected output. If you assigned
output.fullNameand the panel showsfulName, you'll see the typo at edit time instead of debugging a missing variable downstream. - Watch the reference badges.
used by N stepsconfirms a downstream step is consuming each output;not yet referencedis your hint that a typo or scope mismatch is silently dropping the value. - Don't reach for setup scripts to replicate connector logic. If your setup needs to call an external API, that work belongs in a connector step, not a setup script (network is not available in the sandbox by design).
See also¶
transform-step-guide.md— full transform-script API reference (helpers,floh.*namespace, legacyreturn { … }form).