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

StatusWhat it means
UNPUBLISHEDWorking draft. Editable. Cannot accept runs.
PUBLISHEDLive. Editable (the working draft) and accepts new runs against the latest published version.
PAUSEDLive but not accepting new runs. In-flight runs continue to completion.
ARCHIVEDRead-only. Cannot be edited, published, or used to start runs.

Transitions:

  • UNPUBLISHED → PUBLISHED via POST /v1/workflows/{id}/publish (succeeds only when validation passes).
  • PUBLISHED ↔ PAUSED via pause / resume.
  • Any state → ARCHIVED via archive. 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

FieldTypeDescription
startNodeIdstringIdentifier of the first node executed for every run.
triggerSourcestringWhere runs originate. Set to API.
nodesobjectMap keyed by node ID. Every node in the graph must appear here, including the start node.

Node fields

FieldTypeRequiredDescription
idstringyesUnique within the workflow. Must equal the key in the nodes map.
typestringyesOne of screeningOutreach, engagementOutreach, condition.
labelstringnoHuman-readable display label.
routingobjectyesMap from emitted routing event name to the target node ID. Every event the node may emit must be routed.
configobjectyesPer-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

Sends a screening conversation to the candidate over SMS, WhatsApp, or email and collects answers to a fixed set of questions.

Config fields

Required core:

FieldTypeRequiredNotes
agentNamestringyesDisplay 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.
agentTonestringyesOne of CASUAL, ENCOURAGING, FRIENDLY, MOTIVATIONAL, NEUTRAL, PROFESSIONAL, CUSTOM.
titlestringyesDisplay label for the underlying campaign created at publish. Internal — does not appear in candidate messages.
channelstringyesOne of SMS, WHATSAPP, EMAIL.
employerNamestringyesName of the hiring company. Grounds how the conversation engine refers to the employer in replies.
roleTitlestringyesTitle of the role. Grounds how the conversation engine refers to the role in replies.
locationstringyesRole location. Grounds how the conversation engine refers to where the role is based.
outreachContextstringyesJob 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.
questionsarrayyesOrdered 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:

FieldTypeRequiredNotes
templateIdstringconditionalRequired for WHATSAPP; rejected for EMAIL.
customTemplatedMessagestringconditionalUsed as the opening message. For SMS the message must include STOP to satisfy opt-out compliance.
customTemplatedSubjectLinestringconditionalRequired for EMAIL.

Optional context for the conversation engine (see Context the conversation engine uses to generate replies):

FieldTypeRequiredNotes
additionalContextstringnoFree-form steering for the conversation engine — tone, constraints, edge-case guidance. Distinct from outreachContext, which is the job description itself.
summaryOfRolestringnoShort one-paragraph role summary. Sits alongside the full description and is useful when the description is long.
contractTypestringnoContract 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):

FieldTypeRequiredNotes
criteriaSatisfiedClosingMessagestringnoCopy the conversation engine uses when closing a conversation with a PASSED_CRITERIA outcome.
criteriaNotSatisfiedClosingMessagestringnoCopy the conversation engine uses when closing a conversation with a FAILED_CRITERIA outcome.
candidateNotInterestedClosingMessagestringnoCopy the conversation engine uses when a conversation closes after the candidate signalled disinterest.

Optional pacing and auto-close:

FieldTypeRequiredNotes
disableNudgingbooleannoWhen true, no nudges are sent.
timeToAutoCloseConversationsInHoursnumbernoHours of inactivity before a conversation auto-closes. Default: 24.

Routing events

EventWhen emittedRequired?
onCompleteConversation reached its terminal state successfullyyes
onNotInterestedCandidate signalled disinterestoptional
onOptedOutCandidate opted out (e.g. SMS STOP)optional
onDeliveryFailedDelivery failed across all available channelsoptional

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:

  1. [{ type: "CONTINUE", slug: "<sibling-slug>" }] — advance to a named sibling question within the same node.
  2. [{ type: "CLOSE_CONVERSATION", status: "<status>" }, { type: "ROUTE_TO", nodeId: "<target>" }] — close the current conversation and hand off to another node.
  3. [{ type: "CLOSE_CONVERSATION", status: "<status>" }] — terminate the run cleanly at this question.

Entry question is questions[0]. The first element of the questions array is the question the conversation engine asks first. Every other question is reachable only via an explicit CONTINUE slug 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 answerRoutes are rejected with MISSING_ANSWER_ROUTES; any CONTINUE action authored without a slug is rejected with BARE_CONTINUE_ROUTE.

FieldTypeRequiredNotes
slugstringyesUnique within the node. Used in answers[].slug and as a CONTINUE target.
contentstringyesThe question prompt.
questionTypestringnoTEXT (default) or DOCUMENT. Determines which question shape applies — see Question shapes.
expectedAnswerstringnoIS_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.
preferredAnswerTextstringnoNatural-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).
documentTypesstring[]noDeprecated 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.
documentItemsobject[]noTyped 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.
strictnessstringnoLENIENT or STRICT. Tunes the conversation engine's AMBER → pass/fail mapping. Available on TEXT questions only — rejected on DOCUMENT.
answerRoutesarrayyesPer-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.

ShapequestionTypeRequired fieldsAllowed answerRoutes operatorsRejected fields
Yes/noTEXT (or omitted)slug, content; expectedAnswer: IS_YES or IS_NO is optionalanswerState=eq (yes/no)preferredAnswerText
Open-ended (no scoring)TEXT (or omitted)slug, content, answerRoutesanswerState=eq=any (sole or catch-all); answerText mentions/does_not_mention + answerState=any catch-allexpectedAnswer, preferredAnswerText
Open-ended scoringTEXT (or omitted)preferredAnswerText (non-empty)score (gte/lt, must partition 0–100)expectedAnswer
Document uploadDOCUMENTExactly 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=any route. The runtime stores the reply on the conversation and runs the route's actions (CONTINUE to the next question, CLOSE_CONVERSATION to end the run, or ROUTE_TO + CLOSE_CONVERSATION to 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 answerText mentions/does_not_mention routes followed by a trailing answerState=any catch-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

Sends 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

AspectscreeningOutreachengagementOutreach
employerName, roleTitle, location, outreachContextrequiredoptional
questionsrequired, non-empty arrayoptional — a question-less, message-only outreach is valid
Allowed template variablesfull 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 engineoutreachContext, roleTitle, employerName, location, summaryOfRole are all available to the conversation engineThe 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 outcomesPASSED_CRITERIA, FAILED_CRITERIA, COMPLETED, plus the candidate-driven setCOMPLETED, 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

Branches based on the run's accumulated context.

Config fields

FieldTypeRequiredNotes
branchesarrayyesOrdered list of branches. Each { id, label?, when, targetNodeId }. First match wins.
elsestringyesNode ID to route to when no branch matches.
elseLabelstringnoDisplay label for the else path.

Each branch's when is a Condition-node expression.

Routing events

EventWhen emittedRequired?
onConditionResolvedA branch (or else) was selectedyes

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:

RoleSource field(s)Purpose
Job descriptionoutreachContextThe 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 summarysummaryOfRoleShort 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 titleroleTitleAvailable to the engine as structured data, and exposed in messages via the {{JOB_TITLE}} template variable. Removed on engagementOutreach.
Employer nameemployerNameAvailable to the engine as structured data, and exposed in messages via the {{EMPLOYER_NAME}} template variable. Removed on engagementOutreach.
Job locationlocationAvailable to the engine as structured data, and exposed in messages via the {{LOCATION}} template variable. Removed on engagementOutreach.
Contract typecontractTypeAvailable to the engine when contract terms come up in the conversation.
SteeringadditionalContextFree-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 personaagentName, agentToneSelects the agent persona — drives the assistant's name, voice, and signature in generated replies. Both fields are required on every outreach node.
Opening copycustomTemplatedMessage (+ customTemplatedSubjectLine for email)The verbatim first message sent to the candidate. Template variables in {{...}} are substituted at send time.
Lifecycle copycriteriaSatisfiedClosingMessage, criteriaNotSatisfiedClosingMessage, candidateNotInterestedClosingMessagePer-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, not additionalContext. A common mistake is to put tone or style notes in outreachContext and the role description in additionalContext. The semantics are the inverse: outreachContext is the job description, additionalContext is the steering layer. Swapping them produces replies that sound off-brief.
  • On engagementOutreach, lean on additionalContext. Engagement nodes don't pass job-description fields to the conversation engine, so additionalContext is 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.

EventEmitted byWhen it fires
onCompletescreeningOutreach, engagementOutreachThe conversation reached a successful terminal state (passed, failed, or closed cleanly).
onNotInterestedscreeningOutreach, engagementOutreachThe candidate explicitly declined.
onOptedOutscreeningOutreach, engagementOutreachThe candidate opted out (e.g. replied STOP over SMS).
onDeliveryFailedscreeningOutreach, engagementOutreachMessage delivery failed across the available channels (e.g. invalid mobile number).
onConditionResolvedconditionA 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 routing map decides which downstream node the run transitions to.

The two layers are connected through the actions an answer route runs:

In-conversation actionEffect on node-level routing
CONTINUENone. 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. SMS STOP).
  • 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"
}
FieldTypeRequiredNotes
whenobjectyesSingle-leaf condition expression. See Condition vocabulary.
actionsarrayyesOrdered list of one or more actions. Multiple actions in a single route are how ROUTE_TO pairs with CLOSE_CONVERSATION (see Actions).
closingMessagestringnoUp 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 when matches the candidate's answer runs its actions and 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" before mentions "London"); if the fan-out includes a catch-all (answerState=any), it must sit at the end of the array (the validator enforces this with CATCH_ALL_NOT_LAST). The same first-match-wins rule applies to condition-node branches — 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.

fieldOperatorsValue typeNotes
answerStateeq"yes" | "no" | "any"The conversation engine's classification of the candidate's answer.
answerTextmentions, does_not_mentionnon-empty stringSemantic match against the answer text (evaluated by the conversation engine).
documentStateeq"uploaded" | "not_uploaded"Whether the candidate uploaded the requested document.
scoregte, ltinteger 0–100Preferred-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 score conditions accept only gte and lt. Two consequences:

  • gt and lte on score are rejected at publish as INVALID_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 emits INVALID_SCORE_CONDITION). Express thresholds as half-open intervals: score >= 70 instead of score > 69, score < 70 instead of score <= 69.
  • The full set (gte, gt, lt, lte) is still available on condition nodes, 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 neq on answerState, in on documentState, or eq on score — are rejected at publish with INVALID_OPERATOR_FOR_FIELD. Presence operators (exists, empty) are not available on answer routes; they remain valid on condition-node expressions (see Condition-node DSL).

Actions

Three action types are supported. A route's actions array runs in order.

CONTINUE

Move 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 — explicit CONTINUE slug targets within a node form a cycle (e.g. q1 → q2 → q1).
  • BARE_CONTINUE_ROUTE — a CONTINUE action was authored without a slug. Every CONTINUE must name its sibling target — there is no implicit fallback. Add a slug, or if the route's intent is to close or hand off, replace the CONTINUE with CLOSE_CONVERSATION (terminate the run) or ROUTE_TO + CLOSE_CONVERSATION (hand off to another node).

CLOSE_CONVERSATION

Terminate 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.

statusWebhook outcomeNode-level event fired
"PASSED_CRITERIA"PASSED_CRITERIAonComplete
"FAILED_CRITERIA"FAILED_CRITERIAonComplete
{ "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

Hand 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 — a ROUTE_TO action is missing its required sibling CLOSE_CONVERSATION action.
  • INVALID_ROUTE_TO_TARGET — the nodeId does 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

OperatorArgsDescription
eqfield, valueField equals value
neqfield, valueField not equal to value
gt, gte, lt, ltefield, value (number)Numeric comparison
infield, values[]Field equals one of the listed values
mentionsfield, value (string)String/array contains the substring or item
does_not_mentionfield, value (string)Inverse of mentions
existsfieldField is present and non-null
emptyfieldField is absent, null, or an empty string/array
andconditions[]All sub-expressions must be true. conditions must be non-empty.
orconditions[]At least one sub-expression must be true. conditions must be non-empty.
notconditionInverts 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:

NamespaceWhat it contains
triggerThe original trigger metadata supplied to Start Workflow Runs.
candidateIdentity and contact fields for the candidate enrolled in this run.
jobRole/job-level fields associated with the run.
conversationsMap of conversation outcomes keyed by upstream node ID.
answersMap of question outcomes keyed by question slug.
analysisAnalysis outputs produced by upstream analysis steps.
meetingsMeeting 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:

LeafValue type
answerStateIS_YES, IS_NO, IS_ANY, INCLUDES, DOES_NOT_INCLUDE, DOCUMENT_UPLOADED, DOCUMENT_NOT_UPLOADED, NEEDS_CLARIFICATION
answerTextstring (semantic match via mentions / does_not_mention)
scoreinteger 0–100 (only when the question carried preferredAnswerText)
documentStateuploaded, not_uploaded
outcomePASSED, 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 codeCause
START_NODE_NOT_FOUNDstartNodeId does not appear in nodes.
UNSUPPORTED_NODE_TYPENode type is not one of the v1 wired types.
MISSING_REQUIRED_ROUTEA node did not route a routing event marked as required.
UNKNOWN_ROUTEA node's routing map contains an event name the node does not emit.
ORPHAN_NODEA node is defined but cannot be reached from the start node.
CYCLE_DETECTEDThe graph contains a cycle. Workflow runs are forward-only and cannot revisit nodes.
INVALID_ROUTE_TARGETA routing target points to a node ID that does not exist in the graph.

Outreach config

Error codeCause
MISSING_OUTREACH_CONFIG_FIELDA required field on a screeningOutreach/engagementOutreach config is missing.
MISSING_AGENT_REFERENCEAn 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_CONFIGA node has no config object at all.
INVALID_NODE_CONFIGGeneric node config shape error that doesn't match a more specific code.
MISSING_OUTREACH_QUESTIONSThe questions array is empty or missing on a conversational outreach node (screeningOutreach or engagementOutreach).
MISSING_QUESTION_CONTENTA question's content is missing or empty.
MISSING_ANSWER_ROUTESA 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_SLUGTwo or more questions in the same node share the same slug.
MISSING_OUTREACH_MESSAGENeither templateId nor customTemplatedMessage provided (and channel is not WHATSAPP).
MISSING_EMAIL_SUBJECTchannel: EMAIL without customTemplatedSubjectLine.
INVALID_EMAIL_TEMPLATEtemplateId provided on an EMAIL channel.
MISSING_SMS_STOPchannel: SMS with customTemplatedMessage missing the STOP keyword.
MISSING_WHATSAPP_TEMPLATEchannel: WHATSAPP without templateId.
INVALID_SCORE_CONDITIONAnswer 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_FAILEDA 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 codeCause
INCOMPATIBLE_FIELD_FOR_QUESTION_TYPEA 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_TYPEThe 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_ROUTESexpectedAnswer 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_QUESTIONpreferredAnswerText appears on a yes/no-shaped question (identified by expectedAnswer being set OR by directional yes/no answerRoutesanswerState eq yes / answerState eq no). Yes/no and scoring shapes are mutually exclusive.
INVALID_OPERATOR_FOR_FIELDA 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 codeCause
INVALID_CONTINUE_SLUGA CONTINUE action's slug does not match any question in the same node.
CONTINUE_SLUG_CYCLEExplicit CONTINUE slug targets within a node form a cycle.
ROUTE_TO_WITHOUT_CLOSEA ROUTE_TO action is missing its required sibling CLOSE_CONVERSATION action in the same route.
INVALID_ROUTE_TO_TARGETA ROUTE_TO action's nodeId does not exist in the workflow graph.
BARE_CONTINUE_ROUTEA 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 codeCause
MIXED_OUTCOME_TYPESThe 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_ROUTEThe 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_MISMATCHA 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_ROUTEA 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_LASTA 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_UNREACHABLEA 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 codeCause
INVALID_CONDITION_FIELDA field path does not begin with a known WorkflowRunContext namespace (trigger, candidate, job, conversations, answers, analysis, meetings).
UNREACHABLE_CONTEXTA 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:

VariableAvailable onNotes
{{CANDIDATE_FIRST_NAME}}bothCandidate's first name.
{{ORGANIZATION_NAME}}bothThe organization running the workflow.
{{AGENT_NAME}}bothName of the agent persona handling the outreach.
{{CAMPAIGN_OWNER_NAME}}bothThe user who owns the underlying campaign.
{{INTERVIEWER_NAME}}bothInterviewer for any associated meeting.
{{MEETING_TITLE}}bothTitle of the associated meeting (when one is configured).
{{MEETING_URL}}bothBooking link for the associated meeting.
{{MEETING_AVAILABILITY_TEXT}}bothHuman-readable availability blob for the meeting.
{{JOB_APPLICATION_URL}}bothURL of the underlying job application.
{{JOB_TITLE}}screeningOutreach onlyRejected on engagementOutreach (no job-description data).
{{EMPLOYER_NAME}}screeningOutreach onlyRejected on engagementOutreach (no job-description data).
{{LOCATION}}screeningOutreach onlyRejected 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:

  1. Multiple routing events on a single screening node, each connected to a different downstream node. The screening's onComplete, onNotInterested, and onDeliveryFailed events fan out to three different engagement journeys.
  2. In-conversation pathways via answer routes. The nmc-pin question 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 via ROUTE_TO paired with CLOSE_CONVERSATION.
  3. Content-based branching post-screening. A condition node 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-pin skips them straight to intent-to-register. If they then say they're not even registering, the answer route closes the conversation with status: "FAILED_CRITERIA" and uses ROUTE_TO to send the run directly to engagement-nurture — overriding the node's default onComplete target. The conversation engine still emits onNotInterested automatically for candidates whose tone signals disinterest more generically (e.g. "not for me thanks"), and that path also lands in engagement-nurture via the node's routing map.
  • If they say they have a PIN (or that they're mid-registration), they continue through region and shift-availability and the conversation completes normally — onComplete fires and the run lands in branch-on-region.
  • branch-on-region checks whether the candidate's region answer mentions London and routes to either engagement-london-compliance or engagement-compliance-docs.
  • If the SMS never delivered (e.g. invalid mobile number), onDeliveryFailed routes the run to engagement-email-fallback instead, 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 outcomeMeaning
PASSED_CRITERIAThe candidate met the screening criteria the conversation was designed to test for.
FAILED_CRITERIAThe candidate did not meet the screening criteria.
COMPLETEDThe conversation ran its course or was closed without a specific pass/fail signal.
NOT_INTERESTEDThe candidate explicitly declined.
OPTED_OUTThe candidate opted out (e.g. SMS STOP).
DELIVERY_FAILEDDelivery 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