Workflow Automation Custom Action Guide
Guide for creating and invoking reusable custom actions using the Fluent SDK. Custom actions encapsulate a sequence of OOB steps with typed inputs and outputs, and are invoked from any Flow or Subflow via wfa.action().
For API signatures, full config-property tables, every actionStep.* parameter list, the assignActionOutputs helper, the sys_id fallback, and the system DefaultActionOutputs, see the Custom Action API.
When to Use
- The same multi-step sequence is needed across multiple flows -- consolidate it as a callable action
- Standardize common operations (incident escalation, user provisioning, CMDB upserts, etc.)
- Build a domain library of reusable actions for an application
- Reduce duplication when the same OOB step sequence appears in several flows
- Cross-application reusable logic (invoked via sys_id fallback)
Important: Custom action bodies are strictly sequential OOB steps. No flow logic, no nested custom actions, no triggers. If you need branching or loops, model the logic in the calling flow (or use a subflow, which does support flow logic).
Custom Action vs Subflow vs Built-in Action
Custom Action (Action()) | Subflow (Subflow()) | Built-in (action.core.*) | |
|---|---|---|---|
| Defines reusable logic | Yes | Yes | No (you consume them) |
| Has typed I/O | Yes | Yes | Yes (predefined) |
| Body content | wfa.actionStep() calls only | Any flow logic + actions + nested subflows | - |
Supports wfa.flowLogic.* | No (sequential only) | Yes | - |
| Invoked via | wfa.action() | wfa.subflow() | wfa.action() |
| File location | fluent/actions/ | fluent/flows/ | (built into SDK) |
| Body required | Recommended | Optional (stub allowed) | - |
Core Principles
- Single responsibility -- One clear business function per action. Use verb-phrase names (e.g., "Escalate Incident", "Provision User Account").
- OOB steps only -- The body contains
wfa.actionStep()calls and (optionally) oneassignActionOutputscall. Nothing else. - Sequential execution -- Steps run in declaration order. There is no
if/forEach/etc. - Capture step outputs -- Assign
wfa.actionStep()return values toconstto chain into downstream steps viawfa.dataPill(). - Export the constant -- Always
export const myAction = Action(...). - File location -- Place actions in
fluent/actions/.
Rules
- Globals --
TemplateValue,Time,Durationare available globally. UseTemplateValue({ ... })directly;wfa.TemplateValueis not a thing. - Namespaces -- inside a custom action body use
actionStep.*; theaction.core.*namespace is for flows/subflows. Don't copy parameter names between the two -- they differ. - Step output chaining -- capture step return values:
const result = wfa.actionStep(...), thenwfa.dataPill(result.field, "type")downstream. - Use
wfa.assignActionOutputs(params.outputs, { ... })at the end of the body to expose declared outputs. - Parameter prefixes are inconsistent across steps -- see the naming reference. Wrong prefix is a silent type error.
- Error handling per-step --
errorHandlingType: 'dont_stop_the_action'to continue on step error; default is'stop_the_action'. accessvalues are'public'(default) or'package_private'. The legacy'private'is rejected at build time.- Location & export -- custom actions live in
fluent/actions/; alwaysexport const. - Don't hardcode sys_ids -- resolve via
lookUpRecordor pass in as action inputs. __action_status__is available on every action invocation (code,message) -- use it for error branching at the call site even when the action declares no outputs.skip_insert: true-- handy forcreateRecord/createTaskduring development to validate field mappings without writing records.watermark_email: true-- required onactionStep.emailif a laterwaitForEmailReplyneeds to thread back.
Action Constructor
import { Action, wfa, actionStep } from "@servicenow/sdk/automation";
import { StringColumn, BooleanColumn, ReferenceColumn } from "@servicenow/sdk/core";
export const myAction = Action(
config, // metadata + inputs + outputs (+ access, annotation, protectionPolicy)
body // params => { ... sequential wfa.actionStep() calls + optional assignActionOutputs ... }
);
For the full config property table (including annotation, protectionPolicy, access, category), see the Custom Action API → Config Parameters.
Invoking a Custom Action
Invoke from a Flow or Subflow with wfa.action() -- the same helper used for built-in actions.
import { escalateIncident } from "../actions/escalate-incident.now";
wfa.action(
escalateIncident,
{ $id: Now.ID["escalate_step"], annotation: "Escalate P1 incident" },
{
incident: wfa.dataPill(params.trigger.current, "reference"),
reason: "Auto-escalated: Priority 1 incident created"
}
);
sys_id fallback
When the custom action's definition isn't importable (cross-application), pass its sys_id as a string. Output access becomes untyped.
const result = wfa.action(
"abc123def4567890abc123def4567890",
{ $id: Now.ID["external_action_call"] },
{ someInput: "..." }
);
wfa.dataPill(result.someOutput, "string");
Parameter naming inconsistencies
OOB steps use different parameter-naming conventions per step (create_record_table_name vs. table_name vs. table vs. lookup_table_name; create_record_field_values vs. update_record_field_values vs. field_values vs. fields). Mistyping a parameter name is a common error.
See the canonical reference: Custom Action API → Step parameter naming inconsistencies.
Assigning Action Outputs
Use wfa.assignActionOutputs() to return values from the action body to the calling flow. Place it after all steps (and after wfa.errorEvaluation() if present).
export const myAction = Action(
{
$id: Now.ID['my-action'],
name: 'My Action',
inputs: {
description: StringColumn({ label: 'Description', mandatory: true }),
},
outputs: {
success: BooleanColumn({ label: 'Success' }),
result: StringColumn({ label: 'Result' }),
},
},
(params) => {
const step = wfa.actionStep(
actionStep.createRecord,
{ $id: Now.ID['create-step'], label: 'Create' },
{
create_record_table_name: 'incident',
create_record_field_values: TemplateValue({
short_description: wfa.dataPill(params.inputs.description, 'string'),
}),
}
)
wfa.assignActionOutputs(params.outputs, {
success: '1',
result: `Created: ${wfa.dataPill(step.record.number, 'string')}`,
})
}
)
Key rules:
- Output keys must match names declared in the action's
outputs— TypeScript enforces this with autocomplete - Values can be static strings (
'test'), datapill references (wfa.dataPill(...)), or template literals mixing both - Use
'1'for true and'0'for false on boolean outputs
Error Evaluation
Use wfa.errorEvaluation() to conditionally set the action's status based on step results. This is different from errorHandlingType on individual steps — error evaluation sets the overall action status after all steps complete.
(params) => {
const step = wfa.actionStep(
actionStep.createRecord,
{ $id: Now.ID['step'], label: 'Create' },
{
create_record_table_name: 'incident',
create_record_field_values: TemplateValue({ active: 'true' }),
errorHandlingType: 'stop_the_action',
}
)
wfa.errorEvaluation([
{
label: 'Success Check',
condition: `${wfa.dataPill(step.__step_status__.code, 'integer')}!=500`,
status: { code: 200, message: 'OK' },
dontTreatAsError: true,
},
{
label: 'Failure Check',
condition: `${wfa.dataPill(step.__step_status__.code, 'integer')}=500`,
status: { code: 500, message: 'Step failed' },
},
])
wfa.assignActionOutputs(params.outputs, { success: '1' })
}
Key rules:
- Conditions are evaluated in order — the first matching condition wins
dontTreatAsError: truemeans the action is considered successful even though a condition matched- Condition strings use ServiceNow encoded query syntax (
^for AND,^ORfor OR) - Status
codeandmessagecan be static values or datapill template expressions - Place after all
wfa.actionStep()calls but beforewfa.assignActionOutputs()
Error evaluation vs step error handling:
errorHandlingType: 'stop_the_action'on a step controls whether the action halts when that specific step failswfa.errorEvaluation()evaluates conditions after steps complete and sets the overall action status code/message
Combined Example: Error Evaluation + Output Assignment
The following example demonstrates both wfa.errorEvaluation() and wfa.assignActionOutputs() used together in a single action. The step status is checked via error evaluation, and outputs are assigned regardless of the outcome.
import { Action, wfa, actionStep } from '@servicenow/sdk/automation'
import { ReferenceColumn, StringColumn, BooleanColumn } from '@servicenow/sdk/core'
export const createAndValidate = Action(
{
$id: Now.ID['create-validate'],
name: 'Create and Validate',
inputs: {
incident: ReferenceColumn({ label: 'Incident', referenceTable: 'incident', mandatory: true }),
reason: StringColumn({ label: 'Reason' }),
},
outputs: {
success: BooleanColumn({ label: 'Success' }),
details: StringColumn({ label: 'Details' }),
},
},
(params) => {
const createStep = wfa.actionStep(
actionStep.createRecord,
{ $id: Now.ID['create-step'], label: 'Create Record' },
{
create_record_table_name: 'incident',
create_record_field_values: TemplateValue({ active: 'true' }),
errorHandlingType: 'stop_the_action',
}
)
wfa.errorEvaluation([
{
label: 'Created Successfully',
condition: `${wfa.dataPill(createStep.__step_status__.code, 'integer')}!=500`,
status: { code: 200, message: 'Record created' },
dontTreatAsError: true,
},
{
label: 'Creation Failed',
condition: `${wfa.dataPill(createStep.__step_status__.code, 'integer')}=500`,
status: { code: 500, message: wfa.dataPill(createStep.__step_status__.message, 'string') },
},
])
wfa.assignActionOutputs(params.outputs, {
success: '1',
details: wfa.dataPill(params.inputs.reason, 'string'),
})
}
)
What this shows:
errorHandlingType: 'stop_the_action'on the step halts execution if the step itself failswfa.errorEvaluation()inspectscreateStep.__step_status__.codeto set the overall action statusdontTreatAsError: truemarks the 200 condition as a success outcomewfa.assignActionOutputs()always runs last — values can referenceparams.inputsdata pills- Status
messagein the failure condition is itself a data pill from the step status output
Anti-Patterns
Do NOT use flow logic inside a custom action
Custom action bodies are strictly sequential -- if you need if/forEach, put that logic in the calling flow or use a subflow.
// WRONG
params => {
wfa.flowLogic.if({ ... }, () => { ... }); // NOT allowed
};
// CORRECT -- sequential steps only
params => {
wfa.actionStep(actionStep.createRecord, { ... }, { ... });
wfa.actionStep(actionStep.log, { ... }, { ... });
};
Do NOT call another custom action from inside
Custom actions cannot nest. Compose at the flow/subflow level.
// WRONG -- inside an Action() body
params => {
wfa.action(otherCustomAction, { ... }, { ... });
};
Do NOT mix action.core.* and actionStep.* parameter names
// WRONG -- ah_* prefix belongs to action.core.sendEmail, NOT actionStep.email
wfa.actionStep(actionStep.email, { ... }, { ah_to: "user@x.com", ah_subject: "..." });
// CORRECT
wfa.actionStep(actionStep.email, { ... }, { to: "user@x.com", subject: "..." });
Do NOT assign data pills to local variables
Data pills are evaluated by the platform at runtime, not by JavaScript. Capturing one in a const and reusing the variable doesn't work.
// WRONG
const id = wfa.dataPill(params.inputs.recordId, "string");
wfa.actionStep(actionStep.lookUpRecord, { ... }, { conditions: `sys_id=${id}` });
// CORRECT
wfa.actionStep(
actionStep.lookUpRecord,
{ ... },
{ conditions: `sys_id=${wfa.dataPill(params.inputs.recordId, "string")}` }
);
Patterns
Basic Custom Action
import { Action, wfa, actionStep } from "@servicenow/sdk/automation";
import { StringColumn, BooleanColumn } from "@servicenow/sdk/core";
export const logAndCreate = Action(
{
$id: Now.ID["log_and_create"],
name: "Log and Create Incident",
description: "Logs the request and creates an incident with the given description and priority",
inputs: {
description: StringColumn({ label: "Description", mandatory: true }),
priority: StringColumn({ label: "Priority", mandatory: true })
},
outputs: {
created: BooleanColumn({ label: "Created" })
}
},
params => {
wfa.actionStep(
actionStep.log,
{ $id: Now.ID["log_start"], label: "Log start" },
{
log_message: `Creating incident: ${wfa.dataPill(params.inputs.description, "string")}`,
log_level: "info"
}
);
wfa.actionStep(
actionStep.createRecord,
{ $id: Now.ID["create_incident"], label: "Create incident" },
{
create_record_table_name: "incident",
create_record_field_values: TemplateValue({
short_description: wfa.dataPill(params.inputs.description, "string"),
priority: wfa.dataPill(params.inputs.priority, "string")
})
}
);
wfa.assignActionOutputs(params.outputs, { created: true });
}
);
Chaining Step Outputs
import { Action, wfa, actionStep } from "@servicenow/sdk/automation";
import { ReferenceColumn, StringColumn } from "@servicenow/sdk/core";
export const createAndLog = Action(
{
$id: Now.ID["create_and_log"],
name: "Create Incident and Log Number",
inputs: {
assignee: ReferenceColumn({ label: "Assignee", referenceTable: "sys_user", mandatory: true }),
description: StringColumn({ label: "Description", mandatory: true })
},
outputs: {
incidentNumber: StringColumn({ label: "Incident Number" })
}
},
params => {
const created = wfa.actionStep(
actionStep.createRecord,
{ $id: Now.ID["create_step"], label: "Create incident" },
{
create_record_table_name: "incident",
create_record_field_values: TemplateValue({
short_description: wfa.dataPill(params.inputs.description, "string"),
assigned_to: wfa.dataPill(params.inputs.assignee, "reference")
})
}
);
wfa.actionStep(
actionStep.log,
{ $id: Now.ID["log_number"], label: "Log incident number" },
{
log_message: wfa.dataPill(created.record.number, "string"),
log_level: "info"
}
);
wfa.assignActionOutputs(params.outputs, {
incidentNumber: wfa.dataPill(created.record.number, "string")
});
}
);
Continue-on-Error with errorHandlingType
params => {
// First step continues even if it errors
wfa.actionStep(
actionStep.updateRecord,
{ $id: Now.ID["try_update"] },
{
table_name: "incident",
record: wfa.dataPill(params.inputs.incident, "reference"),
update_record_field_values: TemplateValue({ priority: "1" }),
errorHandlingType: "dont_stop_the_action"
}
);
// This still runs even if the update above failed
wfa.actionStep(
actionStep.log,
{ $id: Now.ID["log_after"] },
{ log_message: "Update attempted", log_level: "info" }
);
};
Script Step with Extended Inputs/Outputs
The actionStep.script step has allowExtendedInputs: true and allowExtendedOutputs: true, so it accepts inputVariables and outputVariables.
import { StringColumn, IntegerColumn } from "@servicenow/sdk/core";
wfa.actionStep(
actionStep.script,
{ $id: Now.ID["calc_metrics"], label: "Calculate metrics" },
{
required_run_time: "instance",
script: Now.include("./scripts/calc-metrics.js"),
inputVariables: {
threshold: { label: "Threshold", value: "100" }
},
outputVariables: {
score: IntegerColumn({ label: "Score", default: "0" }),
verdict: StringColumn({ label: "Verdict", maxLength: 64 })
}
}
);
The score and verdict outputs become available on the step's return value for downstream wfa.dataPill() references.