How to: Build workflows with ProcessBuilder¶
The workflow service exposes a Groovy builder DSL for creating OTCS workflow process maps from Content Script. Instead of drawing a map in the Workflow Map Editor, you describe the map as nested builder calls and then validate, preview, or import it into the repository.
Entry point: workflow.getNewProcessBuilder() (since 2.6.0)
Builder type: ProcessBuilder (@ContentScriptAPIObject)
Output: OTCS .map serialization (imported as a workflow definition node)
This page is the practical how-to. For Content Script steps that run inside an existing workflow, see Working with workflows.
What you get¶
| Method | Returns | Since | Purpose |
|---|---|---|---|
validate() |
List<ValidationError> |
4.0.0 | Structured validation (empty list = valid) |
toSvg() |
String |
4.0.0 | Best-effort SVG diagram of the map |
getDefinition() |
CSResource |
2.6.0 | Temporary .map resource |
createDefinition(parent, title, description, addAttachments?, resetRoles?) |
CSNode |
2.6.0 | Import the map into a folder |
Typical flow:
def pb = workflow.getNewProcessBuilder()
pb.workflow(title: "My Workflow", description: "Created from Content Script") {
// packages, start, swimlanes, steps, links…
}
def errors = pb.validate()
errors.each { log.error(it.toString()) }
def parentFolder = docman.getEnterpriseWS()
def wfDef = pb.createDefinition(parentFolder, "My Workflow", "Created from Content Script")
Idempotent recreate
Before calling createDefinition, delete any existing map with the same title if you want re-runs to replace the previous definition:
def existing = docman.getNodeByName(parentFolder, "My Workflow")
if (existing) {
docman.deleteNode(existing)
}
Mental model¶
ProcessBuilder extends Groovy FactoryBuilderSupport. Each keyword (workflow, swimlane, form, gateway, …) is a registered factory that builds a DTO and wires it into the parent node.
workflow
├── packages
│ ├── attributes { attribute* }
│ ├── webforms { webform* }
│ └── cscripts { cscript* }
├── start { go | back | otherwise }*
└── swimlane*
├── form / step { go | back | otherwise }*
├── gateway { route*, otherwise?, back? }
│ └── route { condition | and | or | group }*
└── milestone / end
Structural rules worth memorizing:
startis a direct child ofworkflow(not of a swimlane).- Human tasks (
form/step) and gateways live inside aswimlane. - Only
startandmilestone/endmay sit directly underworkflow. - Inside a gateway, use
route { condition … }— never barego. Useotherwisefor the default branch.
Attribute names accept both camelCase and snake_case (onComplete / on_complete, connectionPoints / connection_points).
Minimal example¶
def pb = workflow.getNewProcessBuilder()
def reviewer = users.getMemberByLoginName("approver1")
def formTemplate = docman.getNodeByName(docman.getEnterpriseWS(), "Approval Form Template")
pb.workflow(title: "Simple Approval", onComplete: "None") {
packages {
webforms {
webform(node: formTemplate, name: "Form")
}
}
start(title: "Start", displayAtInit: true, forms: [[name: "Form", view: "Default"]]) {
go(to: "Review")
}
swimlane(name: "Approvers", assignees: reviewer, form: "Form") {
form(title: "Review", duration: 2.days, priority: "high") {
go(to: "Done")
}
milestone(title: "Done")
}
}
def folder = docman.getEnterpriseWS()
pb.createDefinition(folder, "Simple Approval", "")
Workflow attributes¶
Top-level workflow(...) options:
| Attribute | Type | Default | Notes |
|---|---|---|---|
title |
String | — | Required for a valid map |
description |
String | "" |
|
instructions |
String | "" |
Shown to participants |
custom_message |
String | "" |
Message at initiation |
onComplete |
String | "Delete" |
Delete, None, Move, Copy |
emailAttachmentsAs |
String | "Links" |
Links, Attachments, None |
emailActionConfirmation |
Boolean | false |
|
roleBased |
Boolean | false |
|
skipWeekendFlag |
Boolean | false |
Skip weekends in due-date math |
promptTitle |
Boolean | false |
Prompt for title at initiation |
onInitiateScripts / onCompleteScripts / … |
List | [] |
Lifecycle Content Scripts |
createDefinition extras:
| Parameter | Default | Notes |
|---|---|---|
addAttachments |
true |
Attachments work package |
resetRoles |
false |
Reset role assignments on import |
Packages¶
Declare reusable work packages once, then reference them by name from steps and swimlanes.
Attributes¶
packages {
attributes {
attribute(name: "Priority Level", type: "textpopup",
validValues: ["Low", "Medium", "High"], required: true)
attribute(name: "Due Date", type: "date")
attribute(name: "Amount", type: "real")
attribute(name: "Owner", type: "user")
}
}
Common type values: text, textpopup, textmultiline, date, datepopup, checkbox, integer, integerpopup, real, realpopup, itemreference, tkl, user, userpopup.
WebForms¶
webform needs a live CSNode (Form Template or Form). The name is the identifier steps use later.
packages {
webforms {
webform(
node: formTemplate,
name: "ApprovalForm",
isRequiredForm: true,
displayAttachments: true,
viewName: "DefaultView",
storageMechanism: "Workflow", // or "Form"
values: [
"Requester": [users.current.name]
]
)
}
}
Content Scripts¶
packages {
cscripts {
cscript(node: notifyScript, name: "Notify")
cscript(node: routeScript, name: "RouteLogic")
}
}
Pass either node: (preferred) or name: + scriptId:. When a gateway uses script: "RouteLogic", that string must match the package entry name.
Start step¶
start(
title: "Submit Request",
displayAtInit: true,
initInSmartUI: true,
forms: [[name: "ApprovalForm", view: "SubmitView"]],
attributes: [[name: "Priority Level", required: true]],
instructions: "Fill the form and submit."
) {
go(to: "Review")
}
Useful flags: displayAtInit, promptForTitle, setDueDate, authenticate, initInSmartUI.
Swimlanes and assignees¶
Swimlanes group steps and define who performs them. Properties set on the swimlane (form, view, duration, groupOption, startDate) are inherited by child steps unless overridden.
swimlane(
name: "Approvers",
assignees: [approverUser, backupUser],
form: "ApprovalForm",
view: "ReviewView",
groupOption: "member-accept",
duration: 2.days
) {
// steps…
}
assignees value |
Meaning |
|---|---|
CSMember |
Single user or group |
[member1, member2] |
Multi-performer |
"task:Step Title" |
Inherit performer from another step |
"form:FormName:FieldName" |
Performer from a form field |
omitted / initiator: true |
Initiator swimlane |
groupOption values: member-accept, accept-maintain, expand-one-level, expand-full.
Resolve members and nodes first
The DSL expects real objects: assignees: takes a CSMember, webform(node:) / cscript(node:) take a CSNode. Resolve them before the builder runs:
def member = users.getMemberByLoginName("jsmith")
if (!member) {
throw new IllegalStateException("User 'jsmith' was not found")
}
def formNode = dependencies["approval-form"] // e.g. injected by App Builder
Steps¶
Form / step (human task)¶
form and step are synonyms (both create a FormStep).
form(
title: "Review",
priority: "high", // high | medium | low
duration: 2.days,
enableEmailAction: true,
enableAgent: true,
form: "ApprovalForm", // optional if swimlane already sets it
view: "ReviewView"
) {
go(to: "Decision")
}
Gateway (decision)¶
Conditional routing uses route + condition. Always provide an otherwise default when possible.
gateway(title: "Decision") {
route(to: "Approved") {
condition(form: "ApprovalForm", field: "Approved", op: "=", value: "Yes")
}
route(to: "Escalate") {
condition(form: "ApprovalForm", field: "Amount", op: ">=", value: "1000")
and {
condition(form: "ApprovalForm", field: "Priority", op: "=", value: "High")
}
}
otherwise(to: "Rejected")
}
Condition sources:
| Kind | Attributes | Example |
|---|---|---|
| Form field | form, field, op, value |
condition(form: "F", field: "Status", op: "=", value: "OK") |
| Workflow attribute | attribute, op, value |
condition(attribute: "Priority Level", op: "=", value: "High") |
| CS step outcome | outcome, op, value |
only on gateway(script: …) |
| Status | general: "Status", op, value |
"ok", "step-late", "workflow-late" |
Operators: =, !=, >, <, >=, <=, LIKE, NOT LIKE. Combine with and { }, or { }, and group { }.
Content Script step¶
Add script: on a gateway to create an automated Content Script step (instead of a pure evaluate step):
gateway(title: "Auto Route", script: "RouteLogic", background: true) {
route(to: "Success Path") {
condition(outcome: "success", op: "=", value: "ok")
}
route(to: "Error Path") {
condition(outcome: "error", op: "=", value: "failed")
}
otherwise(to: "Fallback")
}
Outcome kinds: success, error, number, string. outcome: conditions are invalid on gateways without script:.
Milestone / end¶
milestone(title: "Approved")
end(title: "Rejected") // synonym for milestone
Links¶
| Keyword | Role |
|---|---|
go(to: "…") |
Forward transition (not inside gateways) |
back(to: "…") |
Loop back |
otherwise(to: "…") |
Default / else branch |
route(to: "…") { … } |
Conditional edge (gateway only) |
Step targets resolve by title. If titles collide across swimlanes, qualify them:
go(to: "Approvers:Review")
Optional visual routing:
back(to: "Review", connection_points: "left,bottom")
go(to: "Next", connection_points: "bottom+20,top")
Validation and SVG preview¶
def errors = pb.validate()
def fatal = errors.findAll { it.severity == ValidationError.Severity.ERROR }
if (fatal) {
fatal.each { log.error(it.toString()) }
return
}
String svg = pb.toSvg() // best-effort; call validate() first
Common codes: EMPTY_WORKFLOW, MISSING_TITLE, DEAD_LINK, MISSING_CONDITION, INVALID_OUTCOME_CONDITION, DUPLICATE_TITLE, ORPHAN_STEP, MISSING_FORM_REF, GATEWAY_MISSING_DEFAULT_PATH, UNREACHABLE_STEP.
Complete example¶
def pb = workflow.getNewProcessBuilder()
def formTemplate = docman.getNodeByNickname("approval-form-template")
def notifyScript = docman.getNodeByNickname("wf-notify")
def approver = users.getMemberByLoginName("approver1")
def folder = docman.getNodeByNickname("workflows-folder")
pb.workflow(
title: "Document Approval",
description: "Two-step approval",
onComplete: "None",
skipWeekendFlag: true
) {
packages {
attributes {
attribute(name: "Priority Level", type: "textpopup",
validValues: ["Low", "Medium", "High"])
}
cscripts {
cscript(node: notifyScript, name: "Notify")
}
webforms {
webform(node: formTemplate, name: "ApprovalForm", isRequiredForm: true)
}
}
start(
title: "Submit",
displayAtInit: true,
initInSmartUI: true,
forms: [[name: "ApprovalForm", view: "SubmitView"]],
attributes: [[name: "Priority Level", required: true]]
) {
go(to: "Review")
}
swimlane(name: "Approvers", assignees: approver, form: "ApprovalForm", view: "ReviewView") {
form(title: "Review", priority: "high", duration: 2.days,
enableEmailAction: true, onReadyScripts: [notifyScript]) {
go(to: "Decision")
}
gateway(title: "Decision") {
route(to: "Approved") {
condition(form: "ApprovalForm", field: "Approved", op: "=", value: "Yes")
}
otherwise(to: "Requestor Review")
}
milestone(title: "Approved")
}
swimlane(name: "Requestors", initiator: true, form: "ApprovalForm") {
form(title: "Requestor Review", view: "SubmitView", duration: 1.day) {
back(to: "Review", connection_points: "left,bottom")
}
}
}
pb.validate().each { log.error(it.toString()) }
def existing = docman.getNodeByName(folder, "Document Approval")
if (existing) {
docman.deleteNode(existing)
}
def wfDef = pb.createDefinition(folder, "Document Approval", "Created programmatically")
log.info("Created workflow definition ${wfDef.ID}")
Programmatic generation¶
The same DSL is the target language of the App Builder / Generative Service workflow pipeline: an intermediate workflow IR is rendered to Groovy that calls workflow.getNewProcessBuilder(), builds the tree, validates, and calls createDefinition.
When generating scripts, follow the same binding rules the DSL itself requires:
- Resolve dependency nodes (webforms / cscripts) into local variables before the builder block.
- Resolve swimlane member assignees with
users.getMemberByLoginName(…). - Quote string refs for
script:, form names, and step titles. - Emit
route+condition(nevergo) inside gateways; prefer anotherwisefallback. - Qualify step targets as
Swimlane:Titlewhen titles are not unique.
Skeleton of a generated script:
// Dependency nodes (e.g. injected as a Map by the save pipeline)
def dep_approval_form = dependencies['approval-form']
if (!dep_approval_form) {
throw new IllegalStateException("Dependency 'approval-form' is not available")
}
def member_approver1 = users.getMemberByLoginName('approver1')
if (!member_approver1) {
throw new IllegalStateException("User or group 'approver1' was not found")
}
def pb = workflow.getNewProcessBuilder()
pb.workflow(title: 'Document Approval', onComplete: 'None') {
packages {
webforms {
webform(node: dep_approval_form, name: 'ApprovalForm')
}
}
start(title: 'Submit', forms: [[name: 'ApprovalForm', view: 'Default']]) {
go(to: 'Review')
}
swimlane(name: 'Approvers', assignees: member_approver1, form: 'ApprovalForm') {
form(title: 'Review') {
go(to: 'Done')
}
milestone(title: 'Done')
}
}
def errors = pb.validate()
errors.each { log.error(it.toString()) }
def existingMap = docman.getNodeByName(parentFolder, 'Document Approval')
if (existingMap) { docman.deleteNode(existingMap) }
def wfDef = pb.createDefinition(parentFolder, 'Document Approval', '')
Related topics¶
- Working with workflows — Content Script packages and steps inside running workflows
- Document Builder service — another Groovy builder DSL for PDF/Word generation