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": "engagement-booking-confirm",
"onNotInterested": "engagement-nurture",
"onDeliveryFailed": "engagement-email-fallback"
},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "RGN — availability and NMC screen",
"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",
"content": "Are you currently NMC-registered with an active PIN?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"engagement-booking-confirm": {
"id": "engagement-booking-confirm",
"type": "engagementOutreach",
"label": "Confirm booking",
"routing": {},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Confirm booking",
"channel": "SMS",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, your details are confirmed — our bookings team will reach out shortly with shifts that match your region. Reply STOP to opt out."
}
},
"engagement-nurture": {
"id": "engagement-nurture",
"type": "engagementOutreach",
"label": "Nurture for future roles",
"routing": {},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Nurture for future roles",
"channel": "EMAIL",
"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."
}
},
"engagement-email-fallback": {
"id": "engagement-email-fallback",
"type": "engagementOutreach",
"label": "Email fallback for SMS-unreachable candidates",
"routing": {},
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Email fallback for SMS-unreachable candidates",
"channel": "EMAIL",
"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. Reply to this email if you're interested and we'll send across the next steps."
}
}
}
}
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. The agent persona is referenced by agentName + agentTone so the agent is created automatically on first publish — see Agent reference.
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 carries preferredAnswerText — 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 |
|---|---|---|---|
agentName | string | yes | Display name of the agent persona to use. On publish, an agent with this (name, tone) pair is reused if one already exists on the organisation, or created if not — see Agent reference. Up to 100 characters. |
agentTone | string | yes | One of CASUAL, ENCOURAGING, FRIENDLY, MOTIVATIONAL, NEUTRAL, PROFESSIONAL, CUSTOM. |
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. |
Agent reference
Every outreach node must reference an agent persona — the assistant that drives generated replies and signs messages. The persona is identified by the (agentName, agentTone) pair; both fields are required on every outreach node.
On publish, the workflow service looks up an organisation-level agent matching the pair. If one already exists it is reused; if not, it is created. The same pair across multiple nodes resolves to a single agent. This means a new integration can publish its first workflow without any prior agent provisioning — the persona is materialised automatically.
Validation rejects any outreach node missing either field with MISSING_AGENT_REFERENCE (one issue per missing field, so a config missing both surfaces both errors on a single attempt).
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. |
Optional lifecycle messages (copy the conversation engine uses at specific conversation transitions):
| Field | Type | Required | Notes |
|---|---|---|---|
criteriaSatisfiedClosingMessage | string | no | Copy the conversation engine uses when closing a conversation with a PASSED_CRITERIA outcome. |
criteriaNotSatisfiedClosingMessage | string | no | Copy the conversation engine uses when closing a conversation with a FAILED_CRITERIA outcome. |
candidateNotInterestedClosingMessage | 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 |
|---|---|---|---|
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.
Explicit-routing contract. Every question must declare its forward edge explicitly on every answer route. A route is valid only when its actions take one of these three canonical shapes:
[{ type: "CONTINUE", slug: "<sibling-slug>" }]— advance to a named sibling question within the same node.[{ type: "CLOSE_CONVERSATION", status: "<status>" }, { type: "ROUTE_TO", nodeId: "<target>" }]— close the current conversation and hand off to another node.[{ type: "CLOSE_CONVERSATION", status: "<status>" }]— terminate the run cleanly at this question.Entry question is
questions[0]. The first element of thequestionsarray is the question the conversation engine asks first. Every other question is reachable only via an explicitCONTINUEslug from another route — there is no implicit "next by position" fallback. Array position past element[0]is organisational; the runtime walks via explicit slug edges only.Publishes that omit
answerRoutesare rejected withMISSING_ANSWER_ROUTES; anyCONTINUEaction authored without aslugis rejected withBARE_CONTINUE_ROUTE.
| Field | Type | Required | Notes |
|---|---|---|---|
slug | string | yes | Unique within the node. Used in answers[].slug and as a CONTINUE target. |
content | string | yes | The question prompt. |
questionType | string | no | TEXT (default) or DOCUMENT. Determines which question shape applies — see Question shapes. |
expectedAnswer | string | no | IS_YES or IS_NO. Marks the question as a yes/no knockout and orients pass/fail around the expected answer. TEXT questions only — rejected on DOCUMENT with INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE. |
preferredAnswerText | string | no | Natural-language description of the ideal answer for an open-ended scoring question. Setting this opts the question into scoring, contributes the answer to the conversation's scorecardTotalValue, and enables score routes. Rejected on yes/no shapes (PREFERRED_ANSWER_ON_YES_NO_QUESTION) and DOCUMENT questions (INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE). |
documentTypes | string[] | no | Deprecated free-text list of accepted document kinds for DOCUMENT questions (e.g. ["cv", "passport"]). Prefer documentItems. Mutually exclusive with documentItems. Rejected on TEXT with INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE. |
documentItems | object[] | no | Typed references to DocumentType records for DOCUMENT questions — [{ "documentTypeId": "<uuid>" }]. Each id is verified to belong to the publishing organization and not be archived (rejected with 400 at publish otherwise); the server resolves each to its canonical name. Mutually exclusive with documentTypes. Rejected on TEXT with INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE. See Document Collection Flow for the DocumentType setup. |
strictness | string | no | LENIENT or STRICT. Tunes the conversation engine's AMBER → pass/fail mapping. Available on TEXT questions only — rejected on DOCUMENT. |
answerRoutes | array | yes | Per-question branching rules. At least one route is required, and every route must declare an explicit forward action. See Answer-route DSL. |
Question shapes
A question's combination of questionType, expectedAnswer, preferred-answer fields, and answerRoutes operators must land in one of four valid shapes. The validator rejects mixed shapes at publish so configurations that would silently no-op or misfire at runtime fail loudly.
| Shape | questionType | Required fields | Allowed answerRoutes operators | Rejected fields |
|---|---|---|---|---|
| Yes/no | TEXT (or omitted) | slug, content; expectedAnswer: IS_YES or IS_NO is optional | answerState=eq (yes/no) | preferredAnswerText |
| Open-ended (no scoring) | TEXT (or omitted) | slug, content, answerRoutes | answerState=eq=any (sole or catch-all); answerText mentions/does_not_mention + answerState=any catch-all | expectedAnswer, preferredAnswerText |
| Open-ended scoring | TEXT (or omitted) | preferredAnswerText (non-empty) | score (gte/lt, must partition 0–100) | expectedAnswer |
| Document upload | DOCUMENT | Exactly one of documentItems (typed, recommended) or the deprecated documentTypes (free-text array) | documentState=eq (uploaded/not_uploaded, complete binary pair) | expectedAnswer, preferredAnswerText, strictness |
Yes/no questions are binary knockouts. When expectedAnswer is set, the conversation engine skips scoring and orients pass/fail around the expected answer — replies the engine cannot classify surface as outcome: INCOMPLETE (see Workflow Events). When expectedAnswer is omitted, the engine still classifies the answer as yes or no but does not orient pass/fail — your answerRoutes must encode the pass/fail direction explicitly (e.g. by attaching CLOSE_CONVERSATION with PASSED_CRITERIA to the answerState=yes route and FAILED_CRITERIA to the answerState=no route, or vice versa). strictness is allowed on yes/no questions.
Open-ended (no scoring) questions capture the candidate's free-form answer without scoring it. Two routing patterns:
- Record-and-continue — a single
answerState=anyroute. The runtime stores the reply on the conversation and runs the route's actions (CONTINUEto the next question,CLOSE_CONVERSATIONto end the run, orROUTE_TO+CLOSE_CONVERSATIONto hand off). Use when the answer is for the record only and doesn't need to branch — e.g. "Tell me about your shift preferences" feeding a recruiter review. - Keyword branching — one or more
answerTextmentions/does_not_mentionroutes followed by a trailinganswerState=anycatch-all.mentions "X"fires when the reply mentions X;does_not_mention "X"fires when it does not. The catch-all handles replies that didn't match any prior route. Use when one or two keyword categories should drive routing — e.g. region branching (mentions "London"→ city-specific follow-up), or absence checks (does_not_mention "weekend"→ ask about weekend availability before proceeding).
Open-ended scoring questions are scored by the conversation engine against preferredAnswerText. Setting expectedAnswer here is meaningless (the engine skips scoring when it sees expectedAnswer), so the validator rejects the combination with EXPECTED_ANSWER_ON_NON_YES_NO_ROUTES.
Document questions are evaluated by upload state, not by reply text or score. expectedAnswer, preferred-answer fields, and strictness have no runtime effect on a document question and are rejected up front by INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE. Routes must use documentState; an answer-state, score, or text-mention route on a document question fires INCOMPATIBLE_ROUTE_FOR_QUESTION_TYPE.
See Example 3 for valid payloads and common rejections per shape.
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 (agentName, agentTone, title, channel, customTemplatedMessage, customTemplatedSubjectLine, templateId, criteriaSatisfiedClosingMessage, criteriaNotSatisfiedClosingMessage, candidateNotInterestedClosingMessage, timeToAutoCloseConversationsInHours, additionalContext) 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. |
| Agent persona | agentName, agentTone | Selects the agent persona — drives the assistant's name, voice, and signature in generated replies. Both fields are required on every outreach node. |
| Opening copy | customTemplatedMessage (+ customTemplatedSubjectLine for email) | The verbatim first message sent to the candidate. Template variables in {{...}} are substituted at send time. |
| Lifecycle copy | criteriaSatisfiedClosingMessage, criteriaNotSatisfiedClosingMessage, candidateNotInterestedClosingMessage | Per-state copy the engine uses when closing on a passed candidate, closing on a failed candidate, 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_CRITERIA" | Conversation terminates. onComplete fires with webhook outcome PASSED_CRITERIA. Run transitions per routing.onComplete. |
CLOSE_CONVERSATION with status: "FAILED_CRITERIA" | 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. |
Evaluation order. Answer routes are walked in source order. The first route whose
whenmatches the candidate's answer runs itsactionsand the resolver returns — subsequent routes don't fire, even if they would also match. Order more specific routes before more permissive ones (e.g.mentions "Greater London"beforementions "London"); if the fan-out includes a catch-all (answerState=any), it must sit at the end of the array (the validator enforces this withCATCH_ALL_NOT_LAST). The same first-match-wins rule applies to condition-nodebranches— see Condition-node DSL.
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, lt | integer 0–100 | Preferred-answer score. Requires the question to carry preferredAnswerText. Only gte and lt are accepted on answer routes — see note below. |
Note on score operators. Answer-route
scoreconditions accept onlygteandlt. Two consequences:
gtandlteonscoreare rejected at publish asINVALID_NODE_CONFIG(the schema's score-operator enum is closed, so unsupported operators fail the shape parse — they don't reach the business-rule layer that emitsINVALID_SCORE_CONDITION). Express thresholds as half-open intervals:score >= 70instead ofscore > 69,score < 70instead ofscore <= 69.- The full set (
gte,gt,lt,lte) is still available onconditionnodes, which use the recursive expression DSL — see Condition-node DSL.
Closed operator vocabulary. The operator sets in the table above are exhaustive on the answer-route surface. Other operators on a family field — for example
neqonanswerState,inondocumentState, oreqonscore— are rejected at publish withINVALID_OPERATOR_FOR_FIELD. Presence operators (exists,empty) are not available on answer routes; they remain valid oncondition-node expressions (see Condition-node DSL).
Actions
Three action types are supported. A route's actions array runs in order.
CONTINUE
CONTINUEMove on within the same conversation to a named sibling question. slug names the next question to ask within this node. There is no implicit fallback — a CONTINUE without a slug has no defined runtime target and is rejected at publish time (BARE_CONTINUE_ROUTE):
{ "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— explicitCONTINUEslug targets within a node form a cycle (e.g.q1 → q2 → q1).BARE_CONTINUE_ROUTE— aCONTINUEaction was authored without aslug. EveryCONTINUEmust name its sibling target — there is no implicit fallback. Add aslug, or if the route's intent is to close or hand off, replace theCONTINUEwithCLOSE_CONVERSATION(terminate the run) orROUTE_TO + CLOSE_CONVERSATION(hand off to another node).
CLOSE_CONVERSATION
CLOSE_CONVERSATIONTerminate the conversation cleanly. The status field uses the same vocabulary as the workflow-domain webhook outcome — whatever you set here is what onComplete fires with on the WORKFLOW_NODE_COMPLETED webhook.
status | Webhook outcome | Node-level event fired |
|---|---|---|
"PASSED_CRITERIA" | PASSED_CRITERIA | onComplete |
"FAILED_CRITERIA" | FAILED_CRITERIA | onComplete |
{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }
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 — ROUTE_TO transfers run ownership to another node, and CLOSE_CONVERSATION declares the terminal status for the current conversation. Both are required; without CLOSE_CONVERSATION, the publish is rejected with ROUTE_TO_WITHOUT_CLOSE:
{
"when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [
{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" },
{ "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": "FAILED_CRITERIA" }]
}
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 the conversation 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_CRITERIA" },
{ "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. conditions must be non-empty. |
or | conditions[] | At least one sub-expression must be true. conditions must be non-empty. |
not | condition | Inverts the sub-expression |
and and or reject empty conditions[] arrays at publish time — vacuous always-match catch-alls fail the schema parse and surface as INVALID_NODE_CONFIG on the path to the empty array.
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 INVALID_CONDITION_FIELD. Leaf paths beyond the namespace are not graph-aware in v1 — the validator does not check that a slug, an analysis output, or a meeting field exists on an upstream node.
Within the answers.<slug>.* namespace, the routable leaves are:
| Leaf | Value type |
|---|---|
answerState | IS_YES, IS_NO, IS_ANY, INCLUDES, DOES_NOT_INCLUDE, DOCUMENT_UPLOADED, DOCUMENT_NOT_UPLOADED, NEEDS_CLARIFICATION |
answerText | string (semantic match via mentions / does_not_mention) |
score | integer 0–100 (only when the question carried preferredAnswerText) |
documentState | uploaded, not_uploaded |
outcome | PASSED, FAILED, NOT_COMPLETED, NEEDS_REVIEW, PENDING, INCOMPLETE — see Workflow Events |
INCOMPLETE is set when the conversation engine can't classify a yes/no answer (the candidate's reply is too vague to orient pass/fail). Route on it via { "op": "eq", "field": "answers.<slug>.outcome", "value": "INCOMPLETE" } to send those candidates down a clarification branch rather than to the default pass/fail downstream.
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 |
|---|---|
START_NODE_NOT_FOUND | 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. |
UNKNOWN_ROUTE | A node's routing map contains an event name the node does not emit. |
ORPHAN_NODE | A node is defined but cannot be reached from the start node. |
CYCLE_DETECTED | The graph contains a cycle. Workflow runs are forward-only and cannot revisit nodes. |
INVALID_ROUTE_TARGET | A routing target points to a node ID that does not exist in the graph. |
Outreach config
| Error code | Cause |
|---|---|
MISSING_OUTREACH_CONFIG_FIELD | A required field on a screeningOutreach/engagementOutreach config is missing. |
MISSING_AGENT_REFERENCE | An outreach node config is missing agentName and/or agentTone. Both fields are required on every outreach node — one issue fires per missing field, so a config missing both surfaces two errors on a single publish attempt. See Agent reference. |
MISSING_NODE_CONFIG | A node has no config object at all. |
INVALID_NODE_CONFIG | Generic node config shape error that doesn't match a more specific code. |
MISSING_OUTREACH_QUESTIONS | The questions array is empty or missing on a conversational outreach node (screeningOutreach or engagementOutreach). |
MISSING_QUESTION_CONTENT | A question's content is missing or empty. |
MISSING_ANSWER_ROUTES | A question has no answerRoutes or an empty answerRoutes array. Every question must declare at least one route — there is no implicit "next by array position" fallback. Add at least one answerRoutes entry whose actions close the conversation, route to another node, or CONTINUE to a sibling question by slug. |
DUPLICATE_QUESTION_SLUG | Two or more questions in the same node share the same slug. |
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_SCORE_CONDITION | Answer route uses field: "score" on a question with no preferredAnswerText. Add a non-empty preferredAnswerText to opt the question into scoring. (Unsupported score operators like gt/lte fail the schema parse and surface as INVALID_NODE_CONFIG, not this code.) |
RUBRIC_GENERATION_FAILED | A scoring question's preferredAnswerText was too vague or otherwise unusable to produce a scoring rubric (or a transient platform error occurred). The path identifies the offending question by slug. Retrying often resolves transient failures; if the error persists, sharpen the preferredAnswerText so it expresses concrete answer expectations. |
Question-shape coherence
These rules cross-check each question's fields and routes against the four valid question shapes. They fire at publish time and are distinct from OPERATOR_SOURCE_MISMATCH (which catches field/namespace mismatches across the answer-route and condition-node surfaces); the codes below catch in-shape misuse on a single question.
| Error code | Cause |
|---|---|
INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE | A scalar field doesn't match the question's questionType. Examples: expectedAnswer, strictness, or preferred-answer fields on a DOCUMENT question; documentTypes on a TEXT question. The path identifies the offending field. |
INCOMPATIBLE_ROUTE_FOR_QUESTION_TYPE | The dominant answer-route operator family doesn't match the question's questionType. DOCUMENT questions accept only document-state routes; TEXT questions accept yes/no, text-mention, or scoring routes. |
EXPECTED_ANSWER_ON_NON_YES_NO_ROUTES | expectedAnswer is set on a question whose answerRoutes use scoring, text-mention, or document-state operators. expectedAnswer is a binary signal exclusive to yes/no routes — when set, the conversation engine skips scoring entirely. |
PREFERRED_ANSWER_ON_YES_NO_QUESTION | preferredAnswerText appears on a yes/no-shaped question (identified by expectedAnswer being set OR by directional yes/no answerRoutes — answerState eq yes / answerState eq no). Yes/no and scoring shapes are mutually exclusive. |
INVALID_OPERATOR_FOR_FIELD | A non-canonical value-comparison operator was used on a family field. Canonical sets: answerState/documentState accept only eq; answerText accepts only mentions/does_not_mention; score accepts gte/gt/lt/lte on condition nodes and gte/lt on answer routes. Presence operators (exists/empty) are not affected by this rule. |
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. |
BARE_CONTINUE_ROUTE | A CONTINUE action is missing its slug. Every CONTINUE must name its sibling target — there is no implicit fallback. Add a slug to the CONTINUE action, or replace the action with CLOSE_CONVERSATION (terminate the run) or ROUTE_TO + CLOSE_CONVERSATION (hand off to another node) depending on the route's intent. |
Answer-logic coherence and reachability
These rules apply across the answer routes on a single question (and across the branches on a condition node). They prevent configurations that publish cleanly but misbehave at runtime — silent closes, never-fires routes, and ambiguous fan-outs.
| Error code | Cause |
|---|---|
MIXED_OUTCOME_TYPES | The answer routes (or condition branches) on a single node mix operators from different compatibility groups. Each fan-out must use one group throughout: answer-state (answerState=eq), answer-text (answerText mentions/does_not_mention), score (gte/lt), or document-state (documentState=eq). A trailing answerState=any catch-all is exempt from this rule — it may appear at the end of an answer-text fan-out without triggering a mixed-outcome error. |
MISSING_FALLBACK_ROUTE | The fan-out doesn't cover every input. Cases: • answer-text fan-out is missing the trailing catch-all ({ field: "answerState", op: "eq", value: "any" }).• score fan-out doesn't span the full 0–100 domain.• Binary-intent question (see Question shapes) isn't a complete pair: use documentState=uploaded + =not_uploaded on DOCUMENT, or answerState=yes + =no on TEXT with expectedAnswer. A sole catch-all, a missing half, or a redundant catch-all alongside the pair all fire this code. |
OPERATOR_SOURCE_MISMATCH | A field/operator pair does not match its source namespace. The recognised v1 surfaces are the unprefixed answer-route fields (answerState, answerText, score, documentState) and the namespace-prefixed condition fields (answers.<slug>.<leaf>). The analysis.* and meetings.* namespaces are reserved for forthcoming node types and are rejected today. |
MISSING_TERMINAL_ROUTE | A path through an outreach node's question graph reaches the final question without an explicit CLOSE_CONVERSATION action. Without it the conversation engine closes silently — the candidate hears no closing message. Add a CLOSE_CONVERSATION action (with status: "PASSED_CRITERIA" or status: "FAILED_CRITERIA") on every terminal answer route. |
CATCH_ALL_NOT_LAST | A catch-all route (answerState=any) appears before another route in the answerRoutes array. The runtime resolver walks routes in source order and returns on the first match — any route after the catch-all is unreachable. Move the catch-all to the end of the array. |
SCORE_RANGE_UNREACHABLE | A score route's interval is fully covered by the union of preceding score routes. First-match-wins means the subsumed route never fires. Tighten or reorder the thresholds so each interval reaches some inputs. |
Worked example — a fan-out that fails today
{
"slug": "region",
"content": "Which UK region or trusts are you available to cover?",
"answerRoutes": [
{ "when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [{ "type": "CONTINUE", "slug": "london-shifts" }] },
{ "when": { "field": "answerText", "op": "mentions", "value": "Manchester" },
"actions": [{ "type": "CONTINUE", "slug": "manchester-shifts" }] }
]
}
This passes the schema but is rejected at publish with MISSING_FALLBACK_ROUTE — an answer-text fan-out needs an explicit catch-all so candidates whose answer mentions neither term still have a defined route. Fix:
"answerRoutes": [
{ "when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [{ "type": "CONTINUE", "slug": "london-shifts" }] },
{ "when": { "field": "answerText", "op": "mentions", "value": "Manchester" },
"actions": [{ "type": "CONTINUE", "slug": "manchester-shifts" }] },
{ "when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "shift-availability" }] }
]
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 with INVALID_CONDITION_FIELD.
| Error code | Cause |
|---|---|
INVALID_CONDITION_FIELD | A field path does not begin with a known WorkflowRunContext namespace (trigger, candidate, job, conversations, answers, analysis, meetings). |
UNREACHABLE_CONTEXT | A condition node references a context value (e.g. answers.<slug>.*) that is not produced by any upstream node reachable from the start node. The branch would always evaluate against missing data. |
Template variables
Both outreach types parse {{...}} template variables in customTemplatedMessage (and the other interpolated message fields: customTemplatedSubjectLine, criteriaSatisfiedClosingMessage, criteriaNotSatisfiedClosingMessage, candidateNotInterestedClosingMessage, 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": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "RGN — availability and NMC screen",
"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",
"content": "Are you currently NMC-registered with an active PIN?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "region" }]
}
]
},
{
"slug": "region",
"content": "Which UK region or trusts are you available to cover?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "shift-availability" }]
}
]
},
{
"slug": "shift-availability",
"content": "How many shifts per week are you looking to pick up over the next month?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"engagement-compliance-docs": {
"id": "engagement-compliance-docs",
"type": "engagementOutreach",
"label": "Request compliance pack",
"routing": { "onComplete": "engagement-booking-confirm" },
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Request compliance pack",
"channel": "EMAIL",
"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": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Confirm booking",
"channel": "SMS",
"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 viaROUTE_TOpaired withCLOSE_CONVERSATION. - 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": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "RGN — availability and NMC screen",
"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",
"content": "Are you currently NMC-registered with an active PIN?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CONTINUE", "slug": "region" }]
},
{
"when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CONTINUE", "slug": "intent-to-register" }]
}
]
},
{
"slug": "intent-to-register",
"content": "Are you in the process of registering with the NMC?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CONTINUE", "slug": "region" }]
},
{
"when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [
{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" },
{ "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",
"content": "Which UK region or trusts are you available to cover?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "shift-availability" }]
}
]
},
{
"slug": "shift-availability",
"content": "How many shifts per week are you looking to pick up over the next month?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"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": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "London compliance pack",
"channel": "EMAIL",
"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": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Standard compliance pack",
"channel": "EMAIL",
"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": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Nurture for future roles",
"channel": "EMAIL",
"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": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "Email fallback for SMS-unreachable candidates",
"channel": "EMAIL",
"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 withstatus: "FAILED_CRITERIA"and 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.
Example 3 — Question shapes by example
Four valid question fragments — one per shape from the Question shapes table — followed by common rejection examples and the error code each produces. Fragments are shown in isolation; drop them into a node's config.questions[] array.
Yes/no question with expectedAnswer: IS_YES (knockout)
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered with an active PIN?",
"questionType": "TEXT",
"expectedAnswer": "IS_YES",
"strictness": "STRICT",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CONTINUE", "slug": "region" }] },
{ "when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}
Open-ended (no scoring) question — record-and-continue
{
"slug": "current-shifts",
"content": "What does your current week look like — which shifts are you working now?",
"questionType": "TEXT",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "shift-availability" }] }
]
}
For keyword-based branching, swap the sole catch-all for one or more answerText mentions/does_not_mention routes followed by a trailing answerState=any catch-all (see Question shapes).
Open-ended scoring question with preferredAnswerText and score routes
{
"slug": "shift-availability",
"content": "How many shifts per week are you looking to pick up over the next month, and which days work best for you?",
"questionType": "TEXT",
"preferredAnswerText": "Four or more shifts per week, including at least one weekend day, with availability for early or late starts.",
"answerRoutes": [
{ "when": { "field": "score", "op": "gte", "value": 70 },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] },
{ "when": { "field": "score", "op": "lt", "value": 70 },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}
Document question with documentTypes and document-state routes
{
"slug": "right-to-work",
"content": "Please attach a clear photo of your right-to-work documentation (passport, BRP, or share code letter).",
"questionType": "DOCUMENT",
"documentTypes": ["passport", "biometric-residence-permit", "share-code-letter"],
"answerRoutes": [
{ "when": { "field": "documentState", "op": "eq", "value": "uploaded" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] },
{ "when": { "field": "documentState", "op": "eq", "value": "not_uploaded" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}
Common rejections
These fragments publish-fail with the indicated error code.
expectedAnswer combined with a scoring rubric — fires PREFERRED_ANSWER_ON_YES_NO_QUESTION on path: ...questions[0].preferredAnswerText (and MISSING_FALLBACK_ROUTE on the routes, since expectedAnswer declares binary intent and a sole answerState=any catch-all is not a complete yes/no pair). Yes/no shape (expectedAnswer set) is mutually exclusive with scoring (preferredAnswerText set):
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered?",
"expectedAnswer": "IS_YES",
"preferredAnswerText": "Active PIN holder for at least 12 months",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}
Document question with an answerState route — fires INCOMPATIBLE_ROUTE_FOR_QUESTION_TYPE on the route (and MISSING_FALLBACK_ROUTE, since questionType: DOCUMENT declares binary intent and the fan-out is not a complete documentState=uploaded + =not_uploaded pair):
{
"slug": "right-to-work",
"content": "Attach your right-to-work documentation.",
"questionType": "DOCUMENT",
"documentTypes": ["passport"],
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] }
]
}
Non-canonical operator on an answer-route family field — fires INVALID_OPERATOR_FOR_FIELD on the route's when:
{
"when": { "field": "answerState", "op": "neq", "value": "yes" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }]
}
The fix is to express the inverse with the canonical operator — answerState eq no — or, if you need richer logic across multiple leaves, lift the comparison into a condition node where the full DSL is available.
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.