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": "branch-on-region",
        "onNotInterested": "engagement-nurture",
        "onDeliveryFailed": "engagement-email-fallback"
      },
      "config": {
        "channel": "SMS",
        "employerName": "Cedar Health Recruitment",
        "roleTitle": "Registered General Nurse (Band 5)",
        "location": "Various UK NHS trusts and care homes",
        "outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts and private care-home placements. Confirms NMC registration, region, and shift appetite up front.",
        "customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — we have new RGN shifts coming up that match your profile. A few quick questions to confirm your fit. Reply STOP to opt out.",
        "questions": [
          {
            "slug": "nmc-pin",
            "order": 1,
            "text": "Are you currently NMC-registered with an active PIN?",
            "hasPreferredAnswer": true
          }
        ]
      }
    },
    "branch-on-region": {
      "id": "branch-on-region",
      "type": "condition",
      "routing": { "onConditionResolved": "engagement-compliance-docs" },
      "config": { "...": "see Worked Examples" }
    },
    "engagement-compliance-docs": {
      "id": "engagement-compliance-docs",
      "type": "engagementOutreach",
      "routing": { "onComplete": "engagement-booking-confirm" },
      "config": { "...": "see Worked Examples" }
    },
    "engagement-booking-confirm": {
      "id": "engagement-booking-confirm",
      "type": "engagementOutreach",
      "routing": {},
      "config": { "...": "see Worked Examples" }
    },
    "engagement-nurture": {
      "id": "engagement-nurture",
      "type": "engagementOutreach",
      "routing": {},
      "config": { "...": "see Worked Examples" }
    },
    "engagement-email-fallback": {
      "id": "engagement-email-fallback",
      "type": "engagementOutreach",
      "routing": {},
      "config": { "...": "see Worked Examples" }
    }
  }
}

Notice that screen-availability routes three different exit events (onComplete, onNotInterested, onDeliveryFailed) to three different downstream nodes — each conversational outcome lands in its own follow-up journey. onOptedOut is intentionally omitted so opting out terminates the run cleanly.

Top-level fields

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 has hasPreferredAnswer: true — 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
agentIdstringyesIdentifier of the agent persona that runs the conversation. Drives the assistant's name, tone, and signature in generated replies.
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.

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.
customRequeststringnoCustom-request text used by the conversation engine in document-request and similar specialised flows.

Optional lifecycle messages (copy the conversation engine uses at specific conversation transitions):

FieldTypeRequiredNotes
followUpMessagestringnoCopy the conversation engine uses when nudging an unresponsive candidate.
rejectionMessagestringnoCopy the conversation engine uses when closing a conversation with a FAILED_CRITERIA outcome.
closingMessagestringnoCopy the conversation engine uses when closing a conversation that completed without a pass/fail signal.
conversationCompletedMessagestringnoCopy the conversation engine uses when a conversation closes after the candidate signalled disinterest.

Optional pacing and auto-close:

FieldTypeRequiredNotes
nudgesCountintegernoMaximum nudges before the conversation auto-closes. Default: 1.
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.

FieldTypeRequiredNotes
slugstringyesUnique within the node. Used in answers[].slug and as a CONTINUE target.
textstringyesThe question prompt.
orderintegeryesDefault sequence order. The engine asks questions in order ascending unless an answer route overrides via CONTINUE with a slug.
hasPreferredAnswerbooleannoWhen true, answers contribute to the conversation's scorecardTotalValue and you may use score in answer routes.
answerRoutesarraynoPer-question branching rules. See Answer-route DSL.

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 (agentId, title, channel, customTemplatedMessage, customTemplatedSubjectLine, templateId, followUpMessage, rejectionMessage, closingMessage, conversationCompletedMessage, nudgesCount, timeToAutoCloseConversationsInHours, additionalContext, customRequest) and all four routing events behave identically across both node types.

condition

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.
Custom requestcustomRequestUsed by the engine in document-request and similar specialised flows.
Agent personaagentIdSelects the agent persona — drives the assistant's name, voice, and signature in generated replies.
Opening copycustomTemplatedMessage (+ customTemplatedSubjectLine for email)The verbatim first message sent to the candidate. Template variables in {{...}} are substituted at send time.
Lifecycle copyfollowUpMessage, rejectionMessage, closingMessage, conversationCompletedMessagePer-state copy the engine uses when nudging, rejecting, closing successfully, or wrapping up after a not-interested signal. Provide these to control how those moments sound.

Two practical implications:

  • The role description belongs in outreachContext, 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"Conversation terminates. onComplete fires with webhook outcome PASSED_CRITERIA. Run transitions per routing.onComplete.
CLOSE_CONVERSATION with status: "REJECTED"Conversation terminates. onComplete fires with webhook outcome FAILED_CRITERIA. Run transitions per routing.onComplete.
ROUTE_TO (paired with CLOSE_CONVERSATION)Conversation terminates with the paired status, then the run transitions to the target nodeId — overriding the node's default routing.onComplete target.

Some node-level events are emitted automatically by the conversation engine and are not configurable via answer routes:

  • onNotInterested — fires when the candidate explicitly declines.
  • onOptedOut — fires when the candidate opts out (e.g. 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.

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, gt, lt, lteinteger 0–100Preferred-answer score. Requires the question's hasPreferredAnswer: true.

Actions

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

CONTINUE

Move on within the same conversation. By default the engine asks the next question by order. Set slug to skip directly to a named question:

{ "type": "CONTINUE", "slug": "intent-to-register" }

CONTINUE does not fire a node-level routing event — it stays inside the conversation.

Validation at publish time:

  • INVALID_CONTINUE_SLUG — the named slug does not exist in this node.
  • CONTINUE_SLUG_CYCLE — explicit slug targets form a cycle (e.g. q1 → q2 → q1). The validator does not detect cycles formed implicitly via order ordering combined with explicit slug targets.

CLOSE_CONVERSATION

Terminate the conversation cleanly. The status field controls the webhook outcome and the node-level event that fires:

statusWebhook outcomeNode-level event fired
"PASSED"PASSED_CRITERIAonComplete
"REJECTED"FAILED_CRITERIAonComplete
{ "type": "CLOSE_CONVERSATION", "status": "REJECTED" }

After the conversation closes, the run transitions per the node's routing.onComplete target — unless a ROUTE_TO action is paired in the same route (see below).

ROUTE_TO

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 — the conversation needs to close before the run transitions:

{
  "when": { "field": "answerText", "op": "mentions", "value": "London" },
  "actions": [
    { "type": "CLOSE_CONVERSATION", "status": "PASSED" },
    { "type": "ROUTE_TO", "nodeId": "engagement-london-compliance" }
  ]
}

Validation at publish time:

  • ROUTE_TO_WITHOUT_CLOSE — 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": "REJECTED" }]
}

Skip directly to a shift-pattern deep-dive if the candidate scored highly on availability:

{
  "when": { "field": "score", "op": "gte", "value": 70 },
  "actions": [{ "type": "CONTINUE", "slug": "shift-pattern" }]
}

Branch into a London-specific question if the candidate's region response mentions London:

{
  "when": { "field": "answerText", "op": "mentions", "value": "London" },
  "actions": [{ "type": "CONTINUE", "slug": "london-trust-preference" }]
}

Close as PASSED and hand off to a London-specific compliance node when the answer mentions London:

{
  "when": { "field": "answerText", "op": "mentions", "value": "London" },
  "actions": [
    { "type": "CLOSE_CONVERSATION", "status": "PASSED" },
    { "type": "ROUTE_TO", "nodeId": "engagement-london-compliance" }
  ],
  "closingMessage": "Thanks — I'll hand you over to our London compliance team."
}

Condition-node DSL

The when expression on each condition node branch is the full recursive expression DSL.

Operators

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
orconditions[]At least one sub-expression must be true
notconditionInverts the sub-expression

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 an error code such as INVALID_CONTEXT_NAMESPACE. Leaf paths beyond the namespace are not graph-aware in v1.

Depth limit

Expressions deeper than 10 nesting levels evaluate to false at runtime. Keep your branch logic flat — if you find yourself nesting more than a few layers, split into multiple condition nodes.

Examples

Fast-track candidates who scored at least 70 on the shift-availability question:

{ "op": "gte", "field": "answers.shift-availability.score", "value": 70 }

Route candidates who hold an active NMC PIN AND signalled availability for full-time work:

{
  "op": "and",
  "conditions": [
    { "op": "eq", "field": "answers.nmc-pin.answerState", "value": "yes" },
    { "op": "gte", "field": "answers.shift-availability.score", "value": 60 }
  ]
}

Validation at Publish

POST /v1/workflows/{id}/publish runs the full validator suite against the working draft. On failure the response carries validation.valid: false and validation.errors[] listing every problem found (no fail-fast). Common error codes:

Graph-level

Error codeCause
MISSING_START_NODEstartNodeId 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.
UNREACHABLE_NODEA node is defined but cannot be reached from the start node.
DANGLING_EDGEA routing target points to a node ID that does not exist.

Outreach config

Error codeCause
MISSING_OUTREACH_CONFIG_FIELDA required field on a screeningOutreach/engagementOutreach config is missing.
MISSING_SCREENING_QUESTIONSThe questions array is empty or missing.
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_NODE_CONFIGGeneric node config error (e.g. duplicate question slug).
INVALID_SCORE_CONDITIONAnswer route uses field: "score" on a question without hasPreferredAnswer: true.

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.

Condition-node specific

The strict expression schema rejects unknown operators, missing fields, and value-type mismatches. Field paths whose leading segment does not match a known WorkflowRunContext namespace are rejected at publish time.

Template variables

Both outreach types parse {{...}} template variables in customTemplatedMessage (and the other interpolated message fields: customTemplatedSubjectLine, followUpMessage, rejectionMessage, closingMessage, conversationCompletedMessage, and any per-route closingMessage) and reject unknown variables with INVALID_TEMPLATE_VARIABLE. Variable names are SCREAMING_SNAKE_CASE and must come from the supported set:

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": {
        "channel": "SMS",
        "employerName": "Cedar Health Recruitment",
        "roleTitle": "Registered General Nurse (Band 5)",
        "location": "Various UK NHS trusts and care homes",
        "outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts and private care-home placements. Initial screen confirms NMC registration, region, and shift appetite before we ask for compliance documents.",
        "customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — we have new RGN shifts coming up that match your profile. A few quick questions to confirm your fit. Reply STOP to opt out.",
        "questions": [
          {
            "slug": "nmc-pin",
            "order": 1,
            "text": "Are you currently NMC-registered with an active PIN?",
            "hasPreferredAnswer": true
          },
          {
            "slug": "region",
            "order": 2,
            "text": "Which UK region or trusts are you available to cover?"
          },
          {
            "slug": "shift-availability",
            "order": 3,
            "text": "How many shifts per week are you looking to pick up over the next month?",
            "hasPreferredAnswer": true
          }
        ]
      }
    },
    "engagement-compliance-docs": {
      "id": "engagement-compliance-docs",
      "type": "engagementOutreach",
      "label": "Request compliance pack",
      "routing": { "onComplete": "engagement-booking-confirm" },
      "config": {
        "channel": "EMAIL",
        "employerName": "Cedar Health Recruitment",
        "roleTitle": "Registered General Nurse (Band 5)",
        "location": "Various UK NHS trusts and care homes",
        "outreachContext": "Compliance pack request — DBS, right-to-work, mandatory training certificate, and two professional references. Standard for any NHS or care-home placement.",
        "customTemplatedSubjectLine": "Cedar Health — your compliance pack for upcoming RGN shifts",
        "customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for confirming your details over SMS. To get you booked onto shifts we need your compliance pack: an in-date Enhanced DBS on the update service, your right-to-work documentation, your mandatory training certificate (CSTF or equivalent), and two professional references. Reply to this email with the documents attached and our compliance team will take it from there."
      }
    },
    "engagement-booking-confirm": {
      "id": "engagement-booking-confirm",
      "type": "engagementOutreach",
      "label": "Confirm booking",
      "routing": {},
      "config": {
        "channel": "SMS",
        "employerName": "Cedar Health Recruitment",
        "roleTitle": "Registered General Nurse (Band 5)",
        "location": "Various UK NHS trusts and care homes",
        "outreachContext": "Final confirmation — compliance pack received, candidate is shift-ready. Cedar Health's bookings team will follow up with concrete shift offers.",
        "customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, your compliance pack is in and you're now shift-ready with Cedar Health. Our bookings team will be in touch this week with the first set of shifts in your region. Reply STOP to opt out at any time."
      }
    }
  }
}

screen-availability only routes onComplete here — opting out, signalling not interested, or delivery failure terminates the run cleanly. Example 2 shows what to do if you want each of those exit events to land in its own follow-up.

Example 2 — Branching journey with per-outcome fan-out

Cedar Health Recruitment wants every conversational outcome to land somewhere useful: candidates who say they're not interested go onto a nurture list, candidates who don't reply over SMS get retried over email, region-specific candidates land on a region-specific compliance pack, and everyone else goes through the standard one. This example shows three things at once:

  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 (which fires onNotInterested).
  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": {
        "channel": "SMS",
        "employerName": "Cedar Health Recruitment",
        "roleTitle": "Registered General Nurse (Band 5)",
        "location": "Various UK NHS trusts and care homes",
        "outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts and private care-home placements. We confirm NMC registration first; candidates who are mid-registration are still worth a follow-up nurture email, but candidates who don't intend to register at all are not a fit right now.",
        "customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — we have new RGN shifts coming up that match your profile. A few quick questions to confirm your fit. Reply STOP to opt out.",
        "questions": [
          {
            "slug": "nmc-pin",
            "order": 1,
            "text": "Are you currently NMC-registered with an active PIN?",
            "hasPreferredAnswer": true,
            "answerRoutes": [
              {
                "when": { "field": "answerState", "op": "eq", "value": "no" },
                "actions": [{ "type": "CONTINUE", "slug": "intent-to-register" }]
              }
            ]
          },
          {
            "slug": "intent-to-register",
            "order": 2,
            "text": "Are you in the process of registering with the NMC?",
            "answerRoutes": [
              {
                "when": { "field": "answerState", "op": "eq", "value": "no" },
                "actions": [
                  { "type": "CLOSE_CONVERSATION", "status": "REJECTED" },
                  { "type": "ROUTE_TO", "nodeId": "engagement-nurture" }
                ],
                "closingMessage": "Thanks for letting us know — we'll keep you on file and reach out when you're ready to pick up RGN shifts."
              }
            ]
          },
          {
            "slug": "region",
            "order": 3,
            "text": "Which UK region or trusts are you available to cover?"
          },
          {
            "slug": "shift-availability",
            "order": 4,
            "text": "How many shifts per week are you looking to pick up over the next month?",
            "hasPreferredAnswer": true
          }
        ]
      }
    },
    "branch-on-region": {
      "id": "branch-on-region",
      "type": "condition",
      "label": "Route by region",
      "routing": { "onConditionResolved": "engagement-compliance-docs" },
      "config": {
        "branches": [
          {
            "id": "london-region",
            "label": "Region mentions London",
            "when": { "op": "mentions", "field": "answers.region.answerText", "value": "London" },
            "targetNodeId": "engagement-london-compliance"
          }
        ],
        "else": "engagement-compliance-docs",
        "elseLabel": "Standard compliance pack"
      }
    },
    "engagement-london-compliance": {
      "id": "engagement-london-compliance",
      "type": "engagementOutreach",
      "label": "London compliance pack",
      "routing": {},
      "config": {
        "channel": "EMAIL",
        "employerName": "Cedar Health Recruitment",
        "roleTitle": "Registered General Nurse (Band 5)",
        "location": "Greater London NHS trusts",
        "outreachContext": "London-trust compliance pack — extra references required by some Greater London trusts on top of the standard pack. Candidate already passed availability screen and indicated London as their region.",
        "customTemplatedSubjectLine": "Cedar Health — your London compliance pack for upcoming RGN shifts",
        "customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for confirming your details. For our Greater London trusts we need the standard pack plus two London-trust-specific clinical references: in-date Enhanced DBS on the update service, right-to-work documentation, mandatory training certificate, and references from two clinical leads at trusts you've worked at in the last two years. Reply with the documents attached and our compliance team will take it from there."
      }
    },
    "engagement-compliance-docs": {
      "id": "engagement-compliance-docs",
      "type": "engagementOutreach",
      "label": "Standard compliance pack",
      "routing": {},
      "config": {
        "channel": "EMAIL",
        "employerName": "Cedar Health Recruitment",
        "roleTitle": "Registered General Nurse (Band 5)",
        "location": "Various UK NHS trusts and care homes",
        "outreachContext": "Standard compliance pack request — DBS, right-to-work, mandatory training certificate, and two professional references. Used for everyone outside the London trusts.",
        "customTemplatedSubjectLine": "Cedar Health — your compliance pack for upcoming RGN shifts",
        "customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for confirming your details. To get you booked onto shifts we need your compliance pack: an in-date Enhanced DBS on the update service, right-to-work documentation, your mandatory training certificate (CSTF or equivalent), and two professional references. Reply to this email with the documents attached and our compliance team will take it from there."
      }
    },
    "engagement-nurture": {
      "id": "engagement-nurture",
      "type": "engagementOutreach",
      "label": "Nurture for future roles",
      "routing": {},
      "config": {
        "channel": "EMAIL",
        "employerName": "Cedar Health Recruitment",
        "roleTitle": "Registered General Nurse (Band 5)",
        "location": "Various UK NHS trusts and care homes",
        "outreachContext": "Nurture path — candidate is not currently in the position to take RGN shifts (e.g. mid-registration with the NMC). Keep them on the list and reach out when they are likely ready.",
        "customTemplatedSubjectLine": "Cedar Health — we'll be in touch when the timing is right",
        "customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for letting us know — we'll keep you on file and reach out when you're ready to pick up RGN shifts. If anything changes in the meantime, just reply to this email and we'll get you set up."
      }
    },
    "engagement-email-fallback": {
      "id": "engagement-email-fallback",
      "type": "engagementOutreach",
      "label": "Email fallback for SMS-unreachable candidates",
      "routing": {},
      "config": {
        "channel": "EMAIL",
        "employerName": "Cedar Health Recruitment",
        "roleTitle": "Registered General Nurse (Band 5)",
        "location": "Various UK NHS trusts and care homes",
        "outreachContext": "SMS delivery failed for this candidate — retry the same screening intent over email so we don't lose the candidate to a bad mobile number.",
        "customTemplatedSubjectLine": "Cedar Health Recruitment — RGN shifts in your region",
        "customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, we tried to reach you over SMS but couldn't get through. Cedar Health Recruitment has new RGN shifts coming up that match your profile — reply to this email if you're interested and we'll send across the next steps."
      }
    }
  }
}

Walking through the journey:

  • A candidate enters at screen-availability.
  • If they say they don't have an NMC PIN, the answer route on nmc-pin skips them straight to intent-to-register. If they then say they're not even registering, the answer route closes the conversation as REJECTED 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.

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