@servicenow/sdk
Version:
ServiceNow SDK
481 lines (389 loc) • 18.9 kB
Markdown
tags: [wfa, workflow-automation, custom action, Action, actionStep, wfa.actionStep, assignActionOutputs, reusable action, OOB step]
# 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](../api/flow/custom-action-api.md).
## 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
1. **Single responsibility** -- One clear business function per action. Use verb-phrase names (e.g., "Escalate Incident", "Provision User Account").
2. **OOB steps only** -- The body contains `wfa.actionStep()` calls and (optionally) one `assignActionOutputs` call. Nothing else.
3. **Sequential execution** -- Steps run in declaration order. There is no `if`/`forEach`/etc.
4. **Capture step outputs** -- Assign `wfa.actionStep()` return values to `const` to chain into downstream steps via `wfa.dataPill()`.
5. **Export the constant** -- Always `export const myAction = Action(...)`.
6. **File location** -- Place actions in `fluent/actions/`.
## Rules
- **Globals** -- `TemplateValue`, `Time`, `Duration` are available globally. Use `TemplateValue({ ... })` directly; `wfa.TemplateValue` is not a thing.
- **Namespaces** -- inside a custom action body use `actionStep.*`; the `action.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(...)`, then `wfa.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](#parameter-naming-inconsistencies). 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'`.
- **`access`** values are `'public'` (default) or `'package_private'`. The legacy `'private'` is rejected at build time.
- **Location & export** -- custom actions live in `fluent/actions/`; always `export const`.
- **Don't hardcode sys_ids** -- resolve via `lookUpRecord` or 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 for `createRecord` / `createTask` during development to validate field mappings without writing records.
- **`watermark_email: true`** -- required on `actionStep.email` if a later `waitForEmailReply` needs to thread back.
## Action Constructor
```typescript fluent
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](../api/flow/custom-action-api.md#config-parameters).
## Invoking a Custom Action
Invoke from a Flow or Subflow with `wfa.action()` -- the same helper used for built-in actions.
```typescript fluent
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.
```typescript fluent
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](../api/flow/custom-action-api.md#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).
```typescript fluent
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.
```typescript fluent
(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: true` means the action is considered successful even though a condition matched
- Condition strings use ServiceNow encoded query syntax (`^` for AND, `^OR` for OR)
- Status `code` and `message` can be static values or datapill template expressions
- Place after all `wfa.actionStep()` calls but before `wfa.assignActionOutputs()`
**Error evaluation vs step error handling:**
- `errorHandlingType: 'stop_the_action'` on a step controls whether the action halts when that specific step fails
- `wfa.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.
```typescript fluent
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 fails
- `wfa.errorEvaluation()` inspects `createStep.__step_status__.code` to set the overall action status
- `dontTreatAsError: true` marks the 200 condition as a success outcome
- `wfa.assignActionOutputs()` always runs last — values can reference `params.inputs` data pills
- Status `message` in 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**.
```typescript fluent
// 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.
```typescript fluent
// WRONG -- inside an Action() body
params => {
wfa.action(otherCustomAction, { ... }, { ... });
};
```
### Do NOT mix `action.core.*` and `actionStep.*` parameter names
```typescript fluent
// 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.
```typescript fluent
// 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
```typescript fluent
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
```typescript fluent
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`
```typescript fluent
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`.
```typescript fluent
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.