Workflow Configuration
Workflow Configuration
A workflow is a directed graph of nodes connected by named routing events. You build the graph by submitting a JSON object as the configuration field on POST /v1/workflows (or PATCH /v1/workflows/{id}), then publish the workflow to make it executable. Once published, you trigger one run per candidate by calling POST /v1/workflows/{id}/runs.
This guide describes the configuration JSON schema, the supported node types, the routing events each node emits, the answer-route condition DSL for screening flows, the condition-node expression grammar, and the validation rules applied at publish time.
Lifecycle
| Status | What it means |
|---|---|
UNPUBLISHED | Working draft. Editable. Cannot accept runs. |
PUBLISHED | Live. Editable (the working draft) and accepts new runs against the latest published version. |
PAUSED | Live but not accepting new runs. In-flight runs continue to completion. |
ARCHIVED | Read-only. Cannot be edited, published, or used to start runs. |
Transitions:
UNPUBLISHED → PUBLISHEDviaPOST /v1/workflows/{id}/publish(succeeds only when validation passes).PUBLISHED ↔ PAUSEDviapause/resume.- Any state →
ARCHIVEDviaarchive. Irreversible.
Configuration Graph Shape
The configuration field on a workflow is a JSON-encoded string. Decoded, it has the following structure:
{
"startNodeId": "screen-availability",
"triggerSource": "API",
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"label": "Registered Nurse — availability and registration screen",
"routing": {
"onComplete": "branch-on-region",
"onNotInterested": "engagement-nurture",
"onDeliveryFailed": "engagement-email-fallback"
},
"config": {
"channel": "SMS",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts and care homes",
"outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts and private care-home placements. Confirms NMC registration, region, and shift appetite up front.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — we have new RGN shifts coming up that match your profile. A few quick questions to confirm your fit. Reply STOP to opt out.",
"questions": [
{
"slug": "nmc-pin",
"order": 1,
"text": "Are you currently NMC-registered with an active PIN?",
"hasPreferredAnswer": true
}
]
}
},
"branch-on-region": {
"id": "branch-on-region",
"type": "condition",
"routing": { "onConditionResolved": "engagement-compliance-docs" },
"config": { "...": "see Worked Examples" }
},
"engagement-compliance-docs": {
"id": "engagement-compliance-docs",
"type": "engagementOutreach",
"routing": { "onComplete": "engagement-booking-confirm" },
"config": { "...": "see Worked Examples" }
},
"engagement-booking-confirm": {
"id": "engagement-booking-confirm",
"type": "engagementOutreach",
"routing": {},
"config": { "...": "see Worked Examples" }
},
"engagement-nurture": {
"id": "engagement-nurture",
"type": "engagementOutreach",
"routing": {},
"config": { "...": "see Worked Examples" }
},
"engagement-email-fallback": {
"id": "engagement-email-fallback",
"type": "engagementOutreach",
"routing": {},
"config": { "...": "see Worked Examples" }
}
}
}Notice that screen-availability routes three different exit events (onComplete, onNotInterested, onDeliveryFailed) to three different downstream nodes — each conversational outcome lands in its own follow-up journey. onOptedOut is intentionally omitted so opting out terminates the run cleanly.
Top-level fields
| Field | Type | Description |
|---|---|---|
startNodeId | string | Identifier of the first node executed for every run. |
triggerSource | string | Where runs originate. Set to API. |
nodes | object | Map keyed by node ID. Every node in the graph must appear here, including the start node. |
Node fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique within the workflow. Must equal the key in the nodes map. |
type | string | yes | One of screeningOutreach, engagementOutreach, condition. |
label | string | no | Human-readable display label. |
routing | object | yes | Map from emitted routing event name to the target node ID. Every event the node may emit must be routed. |
config | object | yes | Per-node-type configuration. See Supported Node Types. |
The routing map controls the graph topology. When a node completes, the engine looks up the emitted routing event in the node's routing map and transitions to the target node. If the emitted event is not present in the map and is required, validation fails at publish time. Routing events that have no target in the map are treated as terminal — the run completes when an unrouted event fires.
Supported Node Types
Three node types are available: screeningOutreach, engagementOutreach, and condition. The two outreach node types share the same channel set, the same routing events, and the same webhook outcome surface; they differ in the data they require on the config and in what context the conversation engine receives at send time. The engine produces PASSED_CRITERIA and FAILED_CRITERIA outcomes only when a question has hasPreferredAnswer: true — see engagementOutreach for the full field-by-field comparison.
screeningOutreach
screeningOutreachSends a screening conversation to the candidate over SMS, WhatsApp, or email and collects answers to a fixed set of questions.
Config fields
Required core:
| Field | Type | Required | Notes |
|---|---|---|---|
agentId | string | yes | Identifier of the agent persona that runs the conversation. Drives the assistant's name, tone, and signature in generated replies. |
title | string | yes | Display label for the underlying campaign created at publish. Internal — does not appear in candidate messages. |
channel | string | yes | One of SMS, WHATSAPP, EMAIL. |
employerName | string | yes | Name of the hiring company. Grounds how the conversation engine refers to the employer in replies. |
roleTitle | string | yes | Title of the role. Grounds how the conversation engine refers to the role in replies. |
location | string | yes | Role location. Grounds how the conversation engine refers to where the role is based. |
outreachContext | string | yes | Job description text — the primary input the conversation engine uses to discuss the role with candidates. Not "tone notes" — see Context the conversation engine uses to generate replies. |
questions | array | yes | Ordered list of questions. See Questions below. |
Channel and templating:
| Field | Type | Required | Notes |
|---|---|---|---|
templateId | string | conditional | Required for WHATSAPP; rejected for EMAIL. |
customTemplatedMessage | string | conditional | Used as the opening message. For SMS the message must include STOP to satisfy opt-out compliance. |
customTemplatedSubjectLine | string | conditional | Required for EMAIL. |
Optional context for the conversation engine (see Context the conversation engine uses to generate replies):
| Field | Type | Required | Notes |
|---|---|---|---|
additionalContext | string | no | Free-form steering for the conversation engine — tone, constraints, edge-case guidance. Distinct from outreachContext, which is the job description itself. |
summaryOfRole | string | no | Short one-paragraph role summary. Sits alongside the full description and is useful when the description is long. |
contractType | string | no | Contract type (e.g. permanent, contract, bank shifts). Available to the conversation engine when contract terms come up. |
customRequest | string | no | Custom-request text used by the conversation engine in document-request and similar specialised flows. |
Optional lifecycle messages (copy the conversation engine uses at specific conversation transitions):
| Field | Type | Required | Notes |
|---|---|---|---|
followUpMessage | string | no | Copy the conversation engine uses when nudging an unresponsive candidate. |
rejectionMessage | string | no | Copy the conversation engine uses when closing a conversation with a FAILED_CRITERIA outcome. |
closingMessage | string | no | Copy the conversation engine uses when closing a conversation that completed without a pass/fail signal. |
conversationCompletedMessage | string | no | Copy the conversation engine uses when a conversation closes after the candidate signalled disinterest. |
Optional pacing and auto-close:
| Field | Type | Required | Notes |
|---|---|---|---|
nudgesCount | integer | no | Maximum nudges before the conversation auto-closes. Default: 1. |
disableNudging | boolean | no | When true, no nudges are sent. |
timeToAutoCloseConversationsInHours | number | no | Hours of inactivity before a conversation auto-closes. Default: 24. |
Routing events
| Event | When emitted | Required? |
|---|---|---|
onComplete | Conversation reached its terminal state successfully | yes |
onNotInterested | Candidate signalled disinterest | optional |
onOptedOut | Candidate opted out (e.g. SMS STOP) | optional |
onDeliveryFailed | Delivery failed across all available channels | optional |
Optional events may be omitted from routing — when omitted the run terminates if that event fires.
Questions
Each question controls one prompt-answer step in the screening conversation.
| Field | Type | Required | Notes |
|---|---|---|---|
slug | string | yes | Unique within the node. Used in answers[].slug and as a CONTINUE target. |
text | string | yes | The question prompt. |
order | integer | yes | Default sequence order. The engine asks questions in order ascending unless an answer route overrides via CONTINUE with a slug. |
hasPreferredAnswer | boolean | no | When true, answers contribute to the conversation's scorecardTotalValue and you may use score in answer routes. |
answerRoutes | array | no | Per-question branching rules. See Answer-route DSL. |
engagementOutreach
engagementOutreachSends a re-engagement, nurture, or follow-up conversation. Useful for the steps that come after fit has been established — confirming a booking, requesting a compliance pack, or keeping a candidate warm for future roles.
Engagement nodes share the same channel set, the same routing events, and the same webhook outcome surface as screeningOutreach, but the underlying contract differs because engagement nodes don't carry job-description data.
Differences from screeningOutreach
| Aspect | screeningOutreach | engagementOutreach |
|---|---|---|
employerName, roleTitle, location, outreachContext | required | optional |
questions | required, non-empty array | optional — a question-less, message-only outreach is valid |
| Allowed template variables | full set — including {{JOB_TITLE}}, {{EMPLOYER_NAME}}, {{LOCATION}} | full set minus {{JOB_TITLE}}, {{EMPLOYER_NAME}}, {{LOCATION}} (rejected at publish with INVALID_TEMPLATE_VARIABLE) |
| Job-description context for the engine | outreachContext, roleTitle, employerName, location, summaryOfRole are all available to the conversation engine | The conversation engine does not receive any job-description context on engagement nodes — even if the fields are set on the config, they are removed before the engine generates replies. Use additionalContext for any steering you need instead. |
| Typical webhook outcomes | PASSED_CRITERIA, FAILED_CRITERIA, COMPLETED, plus the candidate-driven set | COMPLETED, plus the candidate-driven set (NOT_INTERESTED, OPTED_OUT, DELIVERY_FAILED) |
All other config fields (agentId, title, channel, customTemplatedMessage, customTemplatedSubjectLine, templateId, followUpMessage, rejectionMessage, closingMessage, conversationCompletedMessage, nudgesCount, timeToAutoCloseConversationsInHours, additionalContext, customRequest) and all four routing events behave identically across both node types.
condition
conditionBranches based on the run's accumulated context.
Config fields
| Field | Type | Required | Notes |
|---|---|---|---|
branches | array | yes | Ordered list of branches. Each { id, label?, when, targetNodeId }. First match wins. |
else | string | yes | Node ID to route to when no branch matches. |
elseLabel | string | no | Display label for the else path. |
Each branch's when is a Condition-node expression.
Routing events
| Event | When emitted | Required? |
|---|---|---|
onConditionResolved | A branch (or else) was selected | yes |
The condition node's routing map always uses onConditionResolved to route to the next node. The actual routed node is determined by the matched branch's targetNodeId (or else).
Context the conversation engine uses to generate replies
When a candidate replies during an outreach, Popp's conversation engine generates the next message. The config fields you populate on a node are what shape that reply — without that context, replies are generic; with it, the engine can answer candidate questions about the role and phrase nudges, rejections, and closing notes with substance. Understanding which field plays which role is the difference between a reply that sounds informed and one that does not.
Each field maps to a specific role in the conversation engine's input:
| Role | Source field(s) | Purpose |
|---|---|---|
| Job description | outreachContext | The full role description. Lets the engine answer candidate questions about the role and phrase nudges with substance. Removed on engagementOutreach — provide this only on screeningOutreach if you want the engine to discuss the role. |
| Job summary | summaryOfRole | Short blurb that sits alongside the full description. Useful when the description is long and you want the engine to lead with a one-line framing. |
| Job title | roleTitle | Available to the engine as structured data, and exposed in messages via the {{JOB_TITLE}} template variable. Removed on engagementOutreach. |
| Employer name | employerName | Available to the engine as structured data, and exposed in messages via the {{EMPLOYER_NAME}} template variable. Removed on engagementOutreach. |
| Job location | location | Available to the engine as structured data, and exposed in messages via the {{LOCATION}} template variable. Removed on engagementOutreach. |
| Contract type | contractType | Available to the engine when contract terms come up in the conversation. |
| Steering | additionalContext | Free-form notes for the engine — tone, style, things to avoid, edge cases, anything you would brief a human recruiter on. Always sent to the engine, including on engagementOutreach. |
| Custom request | customRequest | Used by the engine in document-request and similar specialised flows. |
| Agent persona | agentId | Selects the agent persona — drives the assistant's name, voice, and signature in generated replies. |
| Opening copy | customTemplatedMessage (+ customTemplatedSubjectLine for email) | The verbatim first message sent to the candidate. Template variables in {{...}} are substituted at send time. |
| Lifecycle copy | followUpMessage, rejectionMessage, closingMessage, conversationCompletedMessage | Per-state copy the engine uses when nudging, rejecting, closing successfully, or wrapping up after a not-interested signal. Provide these to control how those moments sound. |
Two practical implications:
- The role description belongs in
outreachContext, notadditionalContext. A common mistake is to put tone or style notes inoutreachContextand the role description inadditionalContext. The semantics are the inverse:outreachContextis the job description,additionalContextis the steering layer. Swapping them produces replies that sound off-brief. - On
engagementOutreach, lean onadditionalContext. Engagement nodes don't pass job-description fields to the conversation engine, soadditionalContextis your only steering channel. If you need the engine to know the broader recruiting context for a nurture or compliance step, put it there.
Routing Events
The supported node types emit the following routing events. Wire each event you care about in a node's routing map; events you do not wire terminate the run when they fire.
| Event | Emitted by | When it fires |
|---|---|---|
onComplete | screeningOutreach, engagementOutreach | The conversation reached a successful terminal state (passed, failed, or closed cleanly). |
onNotInterested | screeningOutreach, engagementOutreach | The candidate explicitly declined. |
onOptedOut | screeningOutreach, engagementOutreach | The candidate opted out (e.g. replied STOP over SMS). |
onDeliveryFailed | screeningOutreach, engagementOutreach | Message delivery failed across the available channels (e.g. invalid mobile number). |
onConditionResolved | condition | A branch (or the else fallback) was selected. |
Answer-route DSL
Answer routes attach to individual questions on screeningOutreach and engagementOutreach nodes. They control how the conversation behaves while the candidate is still answering — skip a follow-up if the candidate said "no", close the conversation early when a score requirement isn't met, or hand off to a different node mid-conversation.
Two layers of routing
Outreach nodes have two routing layers that work together:
- In-conversation routing — answer routes (this section). Fire during a conversation, in response to individual answers, scores, or document uploads. Control which question comes next, whether to close the conversation early, and (via
ROUTE_TO) whether to override the node's default downstream target. - Node-level routing — routing events (see Routing Events). Fire when the conversation terminates. The node's
routingmap decides which downstream node the run transitions to.
The two layers are connected through the actions an answer route runs:
| In-conversation action | Effect on node-level routing |
|---|---|
CONTINUE | None. The conversation continues; no node-level event fires. |
CLOSE_CONVERSATION with status: "PASSED" | Conversation terminates. onComplete fires with webhook outcome PASSED_CRITERIA. Run transitions per routing.onComplete. |
CLOSE_CONVERSATION with status: "REJECTED" | Conversation terminates. onComplete fires with webhook outcome FAILED_CRITERIA. Run transitions per routing.onComplete. |
ROUTE_TO (paired with CLOSE_CONVERSATION) | Conversation terminates with the paired status, then the run transitions to the target nodeId — overriding the node's default routing.onComplete target. |
Some node-level events are emitted automatically by the conversation engine and are not configurable via answer routes:
onNotInterested— fires when the candidate explicitly declines.onOptedOut— fires when the candidate opts out (e.g. SMSSTOP).onDeliveryFailed— fires when delivery fails across all available channels.
Wire these on the node's routing map if you want to handle them; you don't trigger them from answer routes.
Route shape
Each entry in answerRoutes[] has shape:
{
"when": { "field": "...", "op": "...", "value": "..." },
"actions": [
{ "type": "CONTINUE", "slug": "next-question" }
],
"closingMessage": "Optional closing copy used when actions terminate the conversation"
}| Field | Type | Required | Notes |
|---|---|---|---|
when | object | yes | Single-leaf condition expression. See Condition vocabulary. |
actions | array | yes | Ordered list of one or more actions. Multiple actions in a single route are how ROUTE_TO pairs with CLOSE_CONVERSATION (see Actions). |
closingMessage | string | no | Up to 1000 characters. Used as the final message when the route's actions terminate the conversation. |
Condition vocabulary
The when expression on an answer route is a single leaf — and, or, and not are not permitted. Use a condition node for compound logic.
field | Operators | Value type | Notes |
|---|---|---|---|
answerState | eq | "yes" | "no" | "any" | The conversation engine's classification of the candidate's answer. |
answerText | mentions, does_not_mention | non-empty string | Semantic match against the answer text (evaluated by the conversation engine). |
documentState | eq | "uploaded" | "not_uploaded" | Whether the candidate uploaded the requested document. |
score | gte, gt, lt, lte | integer 0–100 | Preferred-answer score. Requires the question's hasPreferredAnswer: true. |
Actions
Three action types are supported. A route's actions array runs in order.
CONTINUE
CONTINUEMove on within the same conversation. By default the engine asks the next question by order. Set slug to skip directly to a named question:
{ "type": "CONTINUE", "slug": "intent-to-register" }CONTINUE does not fire a node-level routing event — it stays inside the conversation.
Validation at publish time:
INVALID_CONTINUE_SLUG— the named slug does not exist in this node.CONTINUE_SLUG_CYCLE— explicit slug targets form a cycle (e.g.q1 → q2 → q1). The validator does not detect cycles formed implicitly viaorderordering combined with explicit slug targets.
CLOSE_CONVERSATION
CLOSE_CONVERSATIONTerminate the conversation cleanly. The status field controls the webhook outcome and the node-level event that fires:
status | Webhook outcome | Node-level event fired |
|---|---|---|
"PASSED" | PASSED_CRITERIA | onComplete |
"REJECTED" | FAILED_CRITERIA | onComplete |
{ "type": "CLOSE_CONVERSATION", "status": "REJECTED" }After the conversation closes, the run transitions per the node's routing.onComplete target — unless a ROUTE_TO action is paired in the same route (see below).
ROUTE_TO
ROUTE_TOHand off to a different node when the conversation closes, overriding the node's default routing.onComplete target. Use this when a specific in-conversation outcome should branch into its own downstream journey rather than reusing the node's configured onComplete route.
{ "type": "ROUTE_TO", "nodeId": "engagement-london-compliance" }ROUTE_TO must always be paired with a CLOSE_CONVERSATION action in the same route — the conversation needs to close before the run transitions:
{
"when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [
{ "type": "CLOSE_CONVERSATION", "status": "PASSED" },
{ "type": "ROUTE_TO", "nodeId": "engagement-london-compliance" }
]
}Validation at publish time:
ROUTE_TO_WITHOUT_CLOSE— aROUTE_TOaction is missing its required siblingCLOSE_CONVERSATIONaction.INVALID_ROUTE_TO_TARGET— thenodeIddoes not exist in the workflow graph.
Examples
Close the conversation as REJECTED if the candidate isn't NMC-registered:
{
"when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "REJECTED" }]
}Skip directly to a shift-pattern deep-dive if the candidate scored highly on availability:
{
"when": { "field": "score", "op": "gte", "value": 70 },
"actions": [{ "type": "CONTINUE", "slug": "shift-pattern" }]
}Branch into a London-specific question if the candidate's region response mentions London:
{
"when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [{ "type": "CONTINUE", "slug": "london-trust-preference" }]
}Close as PASSED and hand off to a London-specific compliance node when the answer mentions London:
{
"when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [
{ "type": "CLOSE_CONVERSATION", "status": "PASSED" },
{ "type": "ROUTE_TO", "nodeId": "engagement-london-compliance" }
],
"closingMessage": "Thanks — I'll hand you over to our London compliance team."
}Condition-node DSL
The when expression on each condition node branch is the full recursive expression DSL.
Operators
| Operator | Args | Description |
|---|---|---|
eq | field, value | Field equals value |
neq | field, value | Field not equal to value |
gt, gte, lt, lte | field, value (number) | Numeric comparison |
in | field, values[] | Field equals one of the listed values |
mentions | field, value (string) | String/array contains the substring or item |
does_not_mention | field, value (string) | Inverse of mentions |
exists | field | Field is present and non-null |
empty | field | Field is absent, null, or an empty string/array |
and | conditions[] | All sub-expressions must be true |
or | conditions[] | At least one sub-expression must be true |
not | condition | Inverts the sub-expression |
Field paths
Field paths are dot-delimited and the leading segment must match a WorkflowRunContext namespace:
| Namespace | What it contains |
|---|---|
trigger | The original trigger metadata supplied to Start Workflow Runs. |
candidate | Identity and contact fields for the candidate enrolled in this run. |
job | Role/job-level fields associated with the run. |
conversations | Map of conversation outcomes keyed by upstream node ID. |
answers | Map of question outcomes keyed by question slug. |
analysis | Analysis outputs produced by upstream analysis steps. |
meetings | Meeting outcomes produced by upstream scheduling steps. |
The validator rejects field paths whose leading segment does not match one of these namespaces with an error code such as INVALID_CONTEXT_NAMESPACE. Leaf paths beyond the namespace are not graph-aware in v1.
Depth limit
Expressions deeper than 10 nesting levels evaluate to false at runtime. Keep your branch logic flat — if you find yourself nesting more than a few layers, split into multiple condition nodes.
Examples
Fast-track candidates who scored at least 70 on the shift-availability question:
{ "op": "gte", "field": "answers.shift-availability.score", "value": 70 }Route candidates who hold an active NMC PIN AND signalled availability for full-time work:
{
"op": "and",
"conditions": [
{ "op": "eq", "field": "answers.nmc-pin.answerState", "value": "yes" },
{ "op": "gte", "field": "answers.shift-availability.score", "value": 60 }
]
}Validation at Publish
POST /v1/workflows/{id}/publish runs the full validator suite against the working draft. On failure the response carries validation.valid: false and validation.errors[] listing every problem found (no fail-fast). Common error codes:
Graph-level
| Error code | Cause |
|---|---|
MISSING_START_NODE | startNodeId does not appear in nodes. |
UNSUPPORTED_NODE_TYPE | Node type is not one of the v1 wired types. |
MISSING_REQUIRED_ROUTE | A node did not route a routing event marked as required. |
UNREACHABLE_NODE | A node is defined but cannot be reached from the start node. |
DANGLING_EDGE | A routing target points to a node ID that does not exist. |
Outreach config
| Error code | Cause |
|---|---|
MISSING_OUTREACH_CONFIG_FIELD | A required field on a screeningOutreach/engagementOutreach config is missing. |
MISSING_SCREENING_QUESTIONS | The questions array is empty or missing. |
MISSING_OUTREACH_MESSAGE | Neither templateId nor customTemplatedMessage provided (and channel is not WHATSAPP). |
MISSING_EMAIL_SUBJECT | channel: EMAIL without customTemplatedSubjectLine. |
INVALID_EMAIL_TEMPLATE | templateId provided on an EMAIL channel. |
MISSING_SMS_STOP | channel: SMS with customTemplatedMessage missing the STOP keyword. |
MISSING_WHATSAPP_TEMPLATE | channel: WHATSAPP without templateId. |
INVALID_NODE_CONFIG | Generic node config error (e.g. duplicate question slug). |
INVALID_SCORE_CONDITION | Answer route uses field: "score" on a question without hasPreferredAnswer: true. |
Answer-route specific
| Error code | Cause |
|---|---|
INVALID_CONTINUE_SLUG | A CONTINUE action's slug does not match any question in the same node. |
CONTINUE_SLUG_CYCLE | Explicit CONTINUE slug targets within a node form a cycle. |
ROUTE_TO_WITHOUT_CLOSE | A ROUTE_TO action is missing its required sibling CLOSE_CONVERSATION action in the same route. |
INVALID_ROUTE_TO_TARGET | A ROUTE_TO action's nodeId does not exist in the workflow graph. |
Condition-node specific
The strict expression schema rejects unknown operators, missing fields, and value-type mismatches. Field paths whose leading segment does not match a known WorkflowRunContext namespace are rejected at publish time.
Template variables
Both outreach types parse {{...}} template variables in customTemplatedMessage (and the other interpolated message fields: customTemplatedSubjectLine, followUpMessage, rejectionMessage, closingMessage, conversationCompletedMessage, and any per-route closingMessage) and reject unknown variables with INVALID_TEMPLATE_VARIABLE. Variable names are SCREAMING_SNAKE_CASE and must come from the supported set:
| Variable | Available on | Notes |
|---|---|---|
{{CANDIDATE_FIRST_NAME}} | both | Candidate's first name. |
{{ORGANIZATION_NAME}} | both | The organization running the workflow. |
{{AGENT_NAME}} | both | Name of the agent persona handling the outreach. |
{{CAMPAIGN_OWNER_NAME}} | both | The user who owns the underlying campaign. |
{{INTERVIEWER_NAME}} | both | Interviewer for any associated meeting. |
{{MEETING_TITLE}} | both | Title of the associated meeting (when one is configured). |
{{MEETING_URL}} | both | Booking link for the associated meeting. |
{{MEETING_AVAILABILITY_TEXT}} | both | Human-readable availability blob for the meeting. |
{{JOB_APPLICATION_URL}} | both | URL of the underlying job application. |
{{JOB_TITLE}} | screeningOutreach only | Rejected on engagementOutreach (no job-description data). |
{{EMPLOYER_NAME}} | screeningOutreach only | Rejected on engagementOutreach (no job-description data). |
{{LOCATION}} | screeningOutreach only | Rejected on engagementOutreach (no job-description data). |
Using {{JOB_TITLE}}, {{EMPLOYER_NAME}}, or {{LOCATION}} inside an engagementOutreach node will fail publish with INVALID_TEMPLATE_VARIABLE. Unknown placeholders (e.g. {{firstName}}, {{candidate.firstName}}) are rejected on either node type.
Worked Examples
Example 1 — Linear nurse onboarding journey
Cedar Health Recruitment runs a three-step linear journey for every Registered General Nurse their ATS feeds in: screen for availability and registration, collect compliance documents, then confirm bookings. Three outreach nodes chained — each builds on the last.
{
"startNodeId": "screen-availability",
"triggerSource": "API",
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"label": "RGN — availability and NMC screen",
"routing": { "onComplete": "engagement-compliance-docs" },
"config": {
"channel": "SMS",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts and care homes",
"outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts and private care-home placements. Initial screen confirms NMC registration, region, and shift appetite before we ask for compliance documents.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — we have new RGN shifts coming up that match your profile. A few quick questions to confirm your fit. Reply STOP to opt out.",
"questions": [
{
"slug": "nmc-pin",
"order": 1,
"text": "Are you currently NMC-registered with an active PIN?",
"hasPreferredAnswer": true
},
{
"slug": "region",
"order": 2,
"text": "Which UK region or trusts are you available to cover?"
},
{
"slug": "shift-availability",
"order": 3,
"text": "How many shifts per week are you looking to pick up over the next month?",
"hasPreferredAnswer": true
}
]
}
},
"engagement-compliance-docs": {
"id": "engagement-compliance-docs",
"type": "engagementOutreach",
"label": "Request compliance pack",
"routing": { "onComplete": "engagement-booking-confirm" },
"config": {
"channel": "EMAIL",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts and care homes",
"outreachContext": "Compliance pack request — DBS, right-to-work, mandatory training certificate, and two professional references. Standard for any NHS or care-home placement.",
"customTemplatedSubjectLine": "Cedar Health — your compliance pack for upcoming RGN shifts",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for confirming your details over SMS. To get you booked onto shifts we need your compliance pack: an in-date Enhanced DBS on the update service, your right-to-work documentation, your mandatory training certificate (CSTF or equivalent), and two professional references. Reply to this email with the documents attached and our compliance team will take it from there."
}
},
"engagement-booking-confirm": {
"id": "engagement-booking-confirm",
"type": "engagementOutreach",
"label": "Confirm booking",
"routing": {},
"config": {
"channel": "SMS",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts and care homes",
"outreachContext": "Final confirmation — compliance pack received, candidate is shift-ready. Cedar Health's bookings team will follow up with concrete shift offers.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, your compliance pack is in and you're now shift-ready with Cedar Health. Our bookings team will be in touch this week with the first set of shifts in your region. Reply STOP to opt out at any time."
}
}
}
}screen-availability only routes onComplete here — opting out, signalling not interested, or delivery failure terminates the run cleanly. Example 2 shows what to do if you want each of those exit events to land in its own follow-up.
Example 2 — Branching journey with per-outcome fan-out
Cedar Health Recruitment wants every conversational outcome to land somewhere useful: candidates who say they're not interested go onto a nurture list, candidates who don't reply over SMS get retried over email, region-specific candidates land on a region-specific compliance pack, and everyone else goes through the standard one. This example shows three things at once:
- Multiple routing events on a single screening node, each connected to a different downstream node. The screening's
onComplete,onNotInterested, andonDeliveryFailedevents fan out to three different engagement journeys. - In-conversation pathways via answer routes. The
nmc-pinquestion branches mid-conversation: candidates without an active PIN are asked a follow-up about whether they're in the process of registering, and that follow-up can end the conversation early (which firesonNotInterested). - Content-based branching post-screening. A
conditionnode routes London candidates to a London-specific compliance pack, and everyone else to the standard one.
{
"startNodeId": "screen-availability",
"triggerSource": "API",
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"label": "RGN — availability and NMC screen",
"routing": {
"onComplete": "branch-on-region",
"onNotInterested": "engagement-nurture",
"onDeliveryFailed": "engagement-email-fallback"
},
"config": {
"channel": "SMS",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts and care homes",
"outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts and private care-home placements. We confirm NMC registration first; candidates who are mid-registration are still worth a follow-up nurture email, but candidates who don't intend to register at all are not a fit right now.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — we have new RGN shifts coming up that match your profile. A few quick questions to confirm your fit. Reply STOP to opt out.",
"questions": [
{
"slug": "nmc-pin",
"order": 1,
"text": "Are you currently NMC-registered with an active PIN?",
"hasPreferredAnswer": true,
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CONTINUE", "slug": "intent-to-register" }]
}
]
},
{
"slug": "intent-to-register",
"order": 2,
"text": "Are you in the process of registering with the NMC?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [
{ "type": "CLOSE_CONVERSATION", "status": "REJECTED" },
{ "type": "ROUTE_TO", "nodeId": "engagement-nurture" }
],
"closingMessage": "Thanks for letting us know — we'll keep you on file and reach out when you're ready to pick up RGN shifts."
}
]
},
{
"slug": "region",
"order": 3,
"text": "Which UK region or trusts are you available to cover?"
},
{
"slug": "shift-availability",
"order": 4,
"text": "How many shifts per week are you looking to pick up over the next month?",
"hasPreferredAnswer": true
}
]
}
},
"branch-on-region": {
"id": "branch-on-region",
"type": "condition",
"label": "Route by region",
"routing": { "onConditionResolved": "engagement-compliance-docs" },
"config": {
"branches": [
{
"id": "london-region",
"label": "Region mentions London",
"when": { "op": "mentions", "field": "answers.region.answerText", "value": "London" },
"targetNodeId": "engagement-london-compliance"
}
],
"else": "engagement-compliance-docs",
"elseLabel": "Standard compliance pack"
}
},
"engagement-london-compliance": {
"id": "engagement-london-compliance",
"type": "engagementOutreach",
"label": "London compliance pack",
"routing": {},
"config": {
"channel": "EMAIL",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Greater London NHS trusts",
"outreachContext": "London-trust compliance pack — extra references required by some Greater London trusts on top of the standard pack. Candidate already passed availability screen and indicated London as their region.",
"customTemplatedSubjectLine": "Cedar Health — your London compliance pack for upcoming RGN shifts",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for confirming your details. For our Greater London trusts we need the standard pack plus two London-trust-specific clinical references: in-date Enhanced DBS on the update service, right-to-work documentation, mandatory training certificate, and references from two clinical leads at trusts you've worked at in the last two years. Reply with the documents attached and our compliance team will take it from there."
}
},
"engagement-compliance-docs": {
"id": "engagement-compliance-docs",
"type": "engagementOutreach",
"label": "Standard compliance pack",
"routing": {},
"config": {
"channel": "EMAIL",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts and care homes",
"outreachContext": "Standard compliance pack request — DBS, right-to-work, mandatory training certificate, and two professional references. Used for everyone outside the London trusts.",
"customTemplatedSubjectLine": "Cedar Health — your compliance pack for upcoming RGN shifts",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for confirming your details. To get you booked onto shifts we need your compliance pack: an in-date Enhanced DBS on the update service, right-to-work documentation, your mandatory training certificate (CSTF or equivalent), and two professional references. Reply to this email with the documents attached and our compliance team will take it from there."
}
},
"engagement-nurture": {
"id": "engagement-nurture",
"type": "engagementOutreach",
"label": "Nurture for future roles",
"routing": {},
"config": {
"channel": "EMAIL",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts and care homes",
"outreachContext": "Nurture path — candidate is not currently in the position to take RGN shifts (e.g. mid-registration with the NMC). Keep them on the list and reach out when they are likely ready.",
"customTemplatedSubjectLine": "Cedar Health — we'll be in touch when the timing is right",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for letting us know — we'll keep you on file and reach out when you're ready to pick up RGN shifts. If anything changes in the meantime, just reply to this email and we'll get you set up."
}
},
"engagement-email-fallback": {
"id": "engagement-email-fallback",
"type": "engagementOutreach",
"label": "Email fallback for SMS-unreachable candidates",
"routing": {},
"config": {
"channel": "EMAIL",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts and care homes",
"outreachContext": "SMS delivery failed for this candidate — retry the same screening intent over email so we don't lose the candidate to a bad mobile number.",
"customTemplatedSubjectLine": "Cedar Health Recruitment — RGN shifts in your region",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, we tried to reach you over SMS but couldn't get through. Cedar Health Recruitment has new RGN shifts coming up that match your profile — reply to this email if you're interested and we'll send across the next steps."
}
}
}
}Walking through the journey:
- A candidate enters at
screen-availability. - If they say they don't have an NMC PIN, the answer route on
nmc-pinskips them straight tointent-to-register. If they then say they're not even registering, the answer route closes the conversation asREJECTEDand usesROUTE_TOto send the run directly toengagement-nurture— overriding the node's defaultonCompletetarget. The conversation engine still emitsonNotInterestedautomatically for candidates whose tone signals disinterest more generically (e.g. "not for me thanks"), and that path also lands inengagement-nurturevia the node'sroutingmap. - If they say they have a PIN (or that they're mid-registration), they continue through
regionandshift-availabilityand the conversation completes normally —onCompletefires and the run lands inbranch-on-region. branch-on-regionchecks whether the candidate's region answer mentions London and routes to eitherengagement-london-complianceorengagement-compliance-docs.- If the SMS never delivered (e.g. invalid mobile number),
onDeliveryFailedroutes the run toengagement-email-fallbackinstead, retrying via email. - Opting out (
onOptedOut) is intentionally unrouted, so the run terminates cleanly with no further messages.
Conversation Outcomes in Workflow Webhooks
When a workflow node's conversation completes, the webhook payload carries a workflow-domain outcome on the NodeOutcome (outcome.kind: "conversation" → outcome.status). This enum is intentionally separate from the Conversations API's conversationStatus field — the workflow domain owns its own outcome contract so screening-themed labels do not leak into webhook consumers.
| Workflow outreach outcome | Meaning |
|---|---|
PASSED_CRITERIA | The candidate met the screening criteria the conversation was designed to test for. |
FAILED_CRITERIA | The candidate did not meet the screening criteria. |
COMPLETED | The conversation ran its course or was closed without a specific pass/fail signal. |
NOT_INTERESTED | The candidate explicitly declined. |
OPTED_OUT | The candidate opted out (e.g. SMS STOP). |
DELIVERY_FAILED | Delivery failed across the available channels. |
If you need to read the underlying conversation directly, fetch it via the Conversations API using the conversationId carried on the NodeOutcome. The Conversations API uses its own public status enum (COMPLETED_SCREENING_PASSED, CLOSED, COMPLETED_SCREENING_FAILED, OPTED_OUT, etc.) which is documented separately on the Conversation schema.
Next Steps
- Workflow Events — webhook payloads emitted as runs progress.
- Webhook Authentication — verify the
x-signatureheader on incoming events.
Updated about 4 hours ago