Workflow Configuration
Workflow Configuration
A workflow is a directed graph of nodes connected by named routing events. You build the graph by submitting a JSON object as the configuration field on POST /v1/workflows (or PATCH /v1/workflows/{id}), then publish the workflow to make it executable. Once published, you trigger one run per candidate by calling POST /v1/workflows/{id}/runs.
This guide describes the configuration JSON schema, the supported node types, the routing events each node emits, the answer-route condition DSL for screening flows, the condition-node expression grammar, and the validation rules applied at publish time.
Lifecycle
| Status | What it means |
|---|---|
UNPUBLISHED | Working draft. Editable. Cannot accept runs. |
PUBLISHED | Live. Editable (the working draft) and accepts new runs against the latest published version. |
PAUSED | Live but not accepting new runs. In-flight runs continue to completion. |
ARCHIVED | Read-only. Cannot be edited, published, or used to start runs. |
Transitions:
UNPUBLISHED → PUBLISHEDviaPOST /v1/workflows/{id}/publish(succeeds only when validation passes).PUBLISHED ↔ PAUSEDviapause/resume.- Any state →
ARCHIVEDviaarchive. Irreversible.
Configuration Graph Shape
The configuration field on a workflow is a JSON-encoded string. Decoded, it has the following structure:
{
"entryPathways": [{ "entrySource": "API", "entrySlug": "screen-availability" }],
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"slug": "screen-availability",
"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.",
"outreachContext": "Confirming shift preferences with RGN candidates who have already passed screening, before the bookings team calls.",
"questions": [
{
"slug": "confirm-region",
"content": "Which regions are you able to travel to for shifts?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"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.",
"outreachContext": "Keeping in touch with RGN candidates who are not ready to pick up shifts yet, so they can re-engage when the timing suits them.",
"questions": [
{
"slug": "nurture-timing",
"content": "Roughly when would you like us to check back in with you?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"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.",
"outreachContext": "Re-reaching RGN candidates over email after SMS delivery failed, to confirm whether they are still interested.",
"questions": [
{
"slug": "fallback-interest",
"content": "Are you still interested in RGN shifts with Cedar Health?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
}
}
}Notice that screen-availability routes three different exit events (onComplete, onNotInterested, onDeliveryFailed) to three different downstream nodes — each conversational outcome lands in its own follow-up journey. onOptedOut is intentionally omitted so opting out terminates the run cleanly. The agent persona is referenced by agentName + agentTone so the agent is created automatically on first publish — see Agent reference.
Top-level fields
| Field | Type | Description |
|---|---|---|
startNodeId | string | Deprecated — legacy single entry node, superseded by entryPathways; still accepted for back-compat. See Entry. |
triggerSource | string | Deprecated — legacy trigger source, superseded by entryPathways; still accepted for back-compat. Set to API. See Entry. |
entryPathways | array | Preferred. List of entry pathways. When omitted, the legacy startNodeId + triggerSource define the implicit API pathway. |
nodes | object | Map keyed by node ID. Every node in the graph must appear here, including the start node. |
Node fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique within the workflow. Must equal the key in the nodes map. |
type | string | yes | One of screeningOutreach, engagementOutreach, condition, createAtsApplication, updateAtsApplication, schedulingOutreach. |
label | string | no | Human-readable display label. |
slug | string | no | Stable per-node identifier that entry pathways target by entrySlug. Lowercase letters, digits, _, and -; unique within the workflow. Optional, but required on any node an entry pathway targets. Set it explicitly and keep it stable across re-publishes so pathways keep resolving. |
routing | object | no | Map from emitted routing event name to the target node ID. Omit it, or send {}, to make the node terminal. If the map has any entry it must route every event that node type marks required. |
config | object | yes | Per-node-type configuration. See Supported Node Types. |
The routing map controls the graph topology. When a node completes, the engine looks up the emitted routing event in the node's routing map and transitions to the target node. If a required event is missing from a non-empty map, the workflow is refused on create, update and publish alike. Optional events may be left unrouted, and an unrouted event is terminal — the run completes when it fires.
Entry
A candidate enters a workflow through an entry pathway — a source paired with the node the candidate starts on. Entry is generalising from a single hard-wired trigger into a list of named pathways, so the same workflow can eventually be entered from more than one source.
Two sources are available to integrators — the API pathway and the ATS pathway. Popp itself enters candidates through others (INTERNAL_SOURCING for Search & Match, SPREADSHEET for an upload, and the withdrawn PUSH); you cannot author those, but a validation message may name one, and createAtsApplication accepts only some of them.
The API pathway can be expressed two equivalent ways:
entryPathways(current shape, used by the examples below): an array of pathways. The API pathway is{ "entrySource": "API", "entrySlug": "<node slug>" }, whereentrySlugis theslugof the node candidates start on — give that node an explicitslugso the pathway resolves deterministically.- Legacy shorthand —
startNodeId(the entry node'sid) plustriggerSource: "API". Deprecated in favour ofentryPathwaysand no longer used in the examples, but still fully accepted for back-compat: when a config declares noentryPathways, these define the implicit API pathway.
"entryPathways": [
{ "entrySource": "API", "entrySlug": "screen-availability" }
]Both forms resolve to the same behaviour: a candidate posted to POST /v1/workflows/{id}/runs starts at the entry node, and the run-start request and response are unchanged. A pathway targets its node by stable slug rather than id, so it keeps pointing at the right node across re-publishes.
An ATS pathway (entrySource: "ATS") connects the workflow to one or more ATS jobs so applicants enter the workflow automatically as they apply. It carries the entry node's entrySlug and the atsJobIds that route to it — nothing else:
"entryPathways": [
{ "entrySource": "ATS", "entrySlug": "screen-availability", "atsJobIds": ["<atsJobId>"] }
]Discover the atsJobIds available to your organisation with GET /v1/ats/jobs — that is all you need to author an ATS-driven workflow. Within an organisation an ATS job can be connected to a workflow or to a campaign or analysis, not both; publish fails with ATS_JOB_ALREADY_CONNECTED otherwise.
The ATS pathway carries every job that feeds the workflow. List each atsJobId on it and applicants from all of them enter at its entrySlug, so several vacancies can share one workflow. Pair it with as many non-ATS pathways as you need — an API pathway for candidates you push yourself, for instance.
Pathways are validated on create, update and publish:
| Error code | Cause |
|---|---|
MISSING_ENTRY | The config declares no entry — neither a startNodeId nor any entryPathways entry. |
PATHWAY_TARGET_NOT_FOUND | An entrySlug points at a node slug that does not exist in the graph. |
DUPLICATE_ENTRY_PATHWAY | Two pathways declare the same entrySource. |
MULTIPLE_ATS_ENTRY_PATHWAYS | A workflow declares more than one entrySource: "ATS" pathway. Put every atsJobId on a single ATS pathway instead. |
DUPLICATE_NODE_SLUG | Two nodes share the same slug. |
Supported Node Types
This guide documents six node types: screeningOutreach, engagementOutreach, condition, createAtsApplication, updateAtsApplication, and schedulingOutreach. The platform executes others that are not yet documented here, so treat the list as the currently documented set rather than the complete one — an error code naming a node type you do not recognise is not a bug. The two conversational outreach node types, screeningOutreach and engagementOutreach, share the same channel set, the same routing events, and the same webhook outcome surface; they differ in the data they require on the config and in what context the conversation engine receives at send time. The engine produces PASSED_CRITERIA and FAILED_CRITERIA outcomes only when a question carries preferredAnswerText — see engagementOutreach for the full field-by-field comparison.
screeningOutreach
screeningOutreachSends a screening conversation to the candidate over SMS, WhatsApp, or email and collects answers to a fixed set of questions.
Config fields
Required core:
| Field | Type | Required | Notes |
|---|---|---|---|
agentName | string | yes | Display name of the agent persona to use. On publish, an agent with this (name, tone) pair is reused if one already exists on the organisation, or created if not — see Agent reference. Up to 100 characters. |
agentTone | string | yes | One of CASUAL, ENCOURAGING, FRIENDLY, MOTIVATIONAL, NEUTRAL, PROFESSIONAL, CUSTOM. |
title | string | yes | Display label for the underlying campaign created at publish. Internal — does not appear in candidate messages. |
channel | string | yes | One of SMS, WHATSAPP, EMAIL. |
employerName | string | yes | Name of the hiring company. Grounds how the conversation engine refers to the employer in replies. |
roleTitle | string | yes | Title of the role. Grounds how the conversation engine refers to the role in replies. |
location | string | yes | Role location. Grounds how the conversation engine refers to where the role is based. |
outreachContext | string | yes | Job description text — the primary input the conversation engine uses to discuss the role with candidates. Not "tone notes" — see Context the conversation engine uses to generate replies. |
questions | array | yes | Ordered list of questions. See Questions below. |
Agent reference
Every outreach node must reference an agent persona — the assistant that drives generated replies and signs messages. The persona is identified by the (agentName, agentTone) pair; both fields are required on every outreach node.
On publish, the workflow service looks up an organisation-level agent matching the pair. If one already exists it is reused; if not, it is created. The same pair across multiple nodes resolves to a single agent. This means a new integration can publish its first workflow without any prior agent provisioning — the persona is materialised automatically.
Validation rejects any outreach node missing either field with MISSING_AGENT_REFERENCE (one issue per missing field, so a config missing both surfaces both errors on a single attempt).
Channel and templating:
| Field | Type | Required | Notes |
|---|---|---|---|
templateId | string | conditional | Required for WHATSAPP; rejected for EMAIL. |
customTemplatedMessage | string | conditional | Used as the opening message. For SMS the message must include STOP to satisfy opt-out compliance. |
customTemplatedSubjectLine | string | conditional | Required for EMAIL. |
Optional context for the conversation engine (see Context the conversation engine uses to generate replies):
| Field | Type | Required | Notes |
|---|---|---|---|
additionalContext | string | no | Free-form steering for the conversation engine — tone, constraints, edge-case guidance. Distinct from outreachContext, which is the job description itself. |
summaryOfRole | string | no | Short one-paragraph role summary. Sits alongside the full description and is useful when the description is long. |
contractType | string | no | Contract type (e.g. permanent, contract, bank shifts). Available to the conversation engine when contract terms come up. |
campaignType | string | no | Whether the recipients have already applied to this role. One of APPLICANT_OUTREACH (default) or NEW_CANDIDATE_OUTREACH — see Campaign type. |
Campaign type
campaignType sets which audience a screeningOutreach node is contacting.
| Value | Use when |
|---|---|
APPLICANT_OUTREACH | Recipients have applied to this role. Default when campaignType is omitted. |
NEW_CANDIDATE_OUTREACH | Recipients have not applied and are being approached about the role. |
Recipients who have not applied are introduced to the role and asked whether they are interested before the screening questions begin. Applicants go straight to the questions.
engagementOutreach nodes accept only ENGAGEMENT_OUTREACH, which is their default, so the field can be omitted.
A value the node type cannot run — for example SCHEDULING on a screeningOutreach node — is rejected with INCOMPATIBLE_CAMPAIGN_TYPE.
Optional lifecycle messages (copy the conversation engine uses at specific conversation transitions):
| Field | Type | Required | Notes |
|---|---|---|---|
criteriaSatisfiedClosingMessage | string | no | Copy the conversation engine uses when closing a conversation with a PASSED_CRITERIA outcome. |
criteriaNotSatisfiedClosingMessage | string | no | Copy the conversation engine uses when closing a conversation with a FAILED_CRITERIA outcome. |
candidateNotInterestedClosingMessage | string | no | Copy the conversation engine uses when a conversation closes after the candidate signalled disinterest. |
Optional pacing and auto-close:
| Field | Type | Required | Notes |
|---|---|---|---|
disableNudging | boolean | no | When true, no nudges are sent. |
timeToAutoCloseConversationsInHours | number | no | Hours of inactivity before a conversation auto-closes. Default: 24. |
Routing events
| Event | When emitted | Required? |
|---|---|---|
onComplete | Conversation reached its terminal state successfully | yes |
onNotInterested | Candidate signalled disinterest | optional |
onOptedOut | Candidate opted out (e.g. SMS STOP) | optional |
onDeliveryFailed | Delivery failed across all available channels | optional |
Optional events may be omitted from routing — when omitted the run terminates if that event fires.
Questions
Each question controls one prompt-answer step in the screening conversation.
Explicit-routing contract. Every question must declare its forward edge explicitly on every answer route. A route is valid only when its actions take one of these three canonical shapes:
[{ type: "CONTINUE", slug: "<sibling-slug>" }]— advance to a named sibling question within the same node.[{ type: "CLOSE_CONVERSATION", status: "<status>" }, { type: "ROUTE_TO", nodeId: "<target>" }]— close the current conversation and hand off to another node.[{ type: "CLOSE_CONVERSATION", status: "<status>" }]— terminate the run cleanly at this question.Entry question is
questions[0]. The first element of thequestionsarray is the question the conversation engine asks first. Every other question is reachable only via an explicitCONTINUEslug from another route — there is no implicit "next by position" fallback. Array position past element[0]is organisational; the runtime walks via explicit slug edges only.Publishes that omit
answerRoutesare rejected withMISSING_ANSWER_ROUTES; anyCONTINUEaction authored without aslugis rejected withBARE_CONTINUE_ROUTE.
| Field | Type | Required | Notes |
|---|---|---|---|
slug | string | yes | Unique within the node. Used in answers[].slug and as a CONTINUE target. |
content | string | yes | The question prompt. |
questionType | string | no | TEXT (default) or DOCUMENT. Determines which question shape applies — see Question shapes. |
expectedAnswer | string | no | IS_YES or IS_NO. Marks the question as a yes/no knockout and orients pass/fail around the expected answer. TEXT questions only — rejected on DOCUMENT with INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE. |
preferredAnswerText | string | no | Natural-language description of the ideal answer for an open-ended scoring question. Setting this opts the question into scoring, contributes the answer to the conversation's scorecardTotalValue, and enables score routes. Rejected on yes/no shapes (PREFERRED_ANSWER_ON_YES_NO_QUESTION) and DOCUMENT questions (INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE). |
documentTypes | string[] | no | Deprecated free-text list of accepted document kinds for DOCUMENT questions (e.g. ["cv", "passport"]). Prefer documentItems. Mutually exclusive with documentItems. Rejected on TEXT with INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE. |
documentItems | object[] | no | Typed references to DocumentType records for DOCUMENT questions — [{ "documentTypeId": "<uuid>" }]. Each id is verified to belong to the publishing organization and not be archived (rejected with 400 at publish otherwise); the server resolves each to its canonical name. Mutually exclusive with documentTypes. Rejected on TEXT with INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE. See Document Collection Flow for the DocumentType setup. |
strictness | string | no | LENIENT or STRICT. Tunes the conversation engine's AMBER → pass/fail mapping. Available on TEXT questions only — rejected on DOCUMENT. |
answerRoutes | array | yes | Per-question branching rules. At least one route is required, and every route must declare an explicit forward action. See Answer-route DSL. |
Question shapes
A question's combination of questionType, expectedAnswer, preferred-answer fields, and answerRoutes operators must land in one of four valid shapes. The validator rejects mixed shapes at publish so configurations that would silently no-op or misfire at runtime fail loudly.
| Shape | questionType | Required fields | Allowed answerRoutes operators | Rejected fields |
|---|---|---|---|---|
| Yes/no | TEXT (or omitted) | slug, content; expectedAnswer: IS_YES or IS_NO is optional | answerState=eq (yes/no) | preferredAnswerText |
| Open-ended (no scoring) | TEXT (or omitted) | slug, content, answerRoutes | answerState=eq=any (sole or catch-all); answerText mentions/does_not_mention + answerState=any catch-all | expectedAnswer, preferredAnswerText |
| Open-ended scoring | TEXT (or omitted) | preferredAnswerText (non-empty) | score (gte/lt, must partition 0–100) | expectedAnswer |
| Document upload | DOCUMENT | Exactly one of documentItems (typed, recommended) or the deprecated documentTypes (free-text array) | documentState=eq (uploaded/not_uploaded, complete binary pair) | expectedAnswer, preferredAnswerText, strictness |
Yes/no questions are binary knockouts. When expectedAnswer is set, the conversation engine skips scoring and orients pass/fail around the expected answer — replies the engine cannot classify surface as outcome: INCOMPLETE (see Workflow Events). When expectedAnswer is omitted, the engine still classifies the answer as yes or no but does not orient pass/fail — your answerRoutes must encode the pass/fail direction explicitly (e.g. by attaching CLOSE_CONVERSATION with PASSED_CRITERIA to the answerState=yes route and FAILED_CRITERIA to the answerState=no route, or vice versa). strictness is allowed on yes/no questions.
Open-ended (no scoring) questions capture the candidate's free-form answer without scoring it. Two routing patterns:
- Record-and-continue — a single
answerState=anyroute. The runtime stores the reply on the conversation and runs the route's actions (CONTINUEto the next question,CLOSE_CONVERSATIONto end the run, orROUTE_TO+CLOSE_CONVERSATIONto hand off). Use when the answer is for the record only and doesn't need to branch — e.g. "Tell me about your shift preferences" feeding a recruiter review. - Keyword branching — one or more
answerTextmentions/does_not_mentionroutes followed by a trailinganswerState=anycatch-all.mentions "X"fires when the reply mentions X;does_not_mention "X"fires when it does not. The catch-all handles replies that didn't match any prior route. Use when one or two keyword categories should drive routing — e.g. region branching (mentions "London"→ city-specific follow-up), or absence checks (does_not_mention "weekend"→ ask about weekend availability before proceeding).
Open-ended scoring questions are scored by the conversation engine against preferredAnswerText. Setting expectedAnswer here is meaningless (the engine skips scoring when it sees expectedAnswer), so the validator rejects the combination with EXPECTED_ANSWER_ON_NON_YES_NO_QUESTION.
Document questions are evaluated by upload state, not by reply text or score. expectedAnswer, preferred-answer fields, and strictness have no runtime effect on a document question and are rejected up front by INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE. Routes must use documentState; an answer-state, score, or text-mention route on a document question fires INCOMPATIBLE_ROUTE_FOR_QUESTION_TYPE.
See Example 3 for valid payloads and common rejections per shape.
engagementOutreach
engagementOutreachSends a re-engagement, nurture, or follow-up conversation. Useful for the steps that come after fit has been established — confirming a booking, requesting a compliance pack, or keeping a candidate warm for future roles.
Engagement nodes share the same channel set, the same routing events, and the same webhook outcome surface as screeningOutreach, but the underlying contract differs because engagement nodes don't carry job-description data.
Differences from screeningOutreach
| Aspect | screeningOutreach | engagementOutreach |
|---|---|---|
employerName, roleTitle, location | required | optional |
outreachContext | required | required — the conversation engine has nothing to steer on without it |
questions | required, non-empty array | required, non-empty array — the engine has nothing to score against on reply |
| Allowed template variables | full set — including {{JOB_TITLE}}, {{EMPLOYER_NAME}}, {{LOCATION}} | full set minus {{JOB_TITLE}}, {{EMPLOYER_NAME}}, {{LOCATION}} (rejected at publish with INVALID_TEMPLATE_VARIABLE) |
| Job-description context for the engine | outreachContext, roleTitle, employerName, location, summaryOfRole are all available to the conversation engine | The conversation engine does not receive any job-description context on engagement nodes — even if the fields are set on the config, they are removed before the engine generates replies. Use additionalContext for any steering you need instead. |
| Typical webhook outcomes | PASSED_CRITERIA, FAILED_CRITERIA, COMPLETED, plus the candidate-driven set | COMPLETED, plus the candidate-driven set (NOT_INTERESTED, OPTED_OUT, DELIVERY_FAILED) |
All other config fields (agentName, agentTone, title, channel, customTemplatedMessage, customTemplatedSubjectLine, templateId, criteriaSatisfiedClosingMessage, criteriaNotSatisfiedClosingMessage, candidateNotInterestedClosingMessage, timeToAutoCloseConversationsInHours, additionalContext) and all four routing events behave identically across both node types.
condition
conditionBranches based on the run's accumulated context.
Config fields
| Field | Type | Required | Notes |
|---|---|---|---|
branches | array | yes | Ordered list of branches. Each { id, label?, when, targetNodeId }. First match wins. |
else | string | yes | Node ID to route to when no branch matches. |
elseLabel | string | no | Display label for the else path. |
Each branch's when is a Condition-node expression.
Routing events
| Event | When emitted | Required? |
|---|---|---|
onConditionResolved | A branch (or else) was selected | yes |
The condition node's routing map always uses onConditionResolved to route to the next node. The actual routed node is determined by the matched branch's targetNodeId (or else).
createAtsApplication
createAtsApplicationAdds the run's candidate to a chosen stage in an ATS job. The Popp builder calls this node Add to job.
Config fields
| Field | Type | Required | Notes |
|---|---|---|---|
atsJobId | string | yes | The destination job. Opaque identifier — take it from GET /v1/ats/jobs. |
atsStageId | string | yes | The stage the candidate lands on within that job. Opaque identifier — take it from GET /v1/ats/jobs/{atsJobId}/stages, or from the stages[] array on the job itself. |
No other config keys are accepted. Unknown keys are rejected, so typos cannot publish silently.
The stage is required so candidates added by a workflow remain findable in the ATS rather than being indistinguishable from real applicants. Use a stage with a non-null atsStageId; some providers report stages without an ATS-native identifier, which cannot be destinations.
Discovering a destination
curl "https://api.joinpopp.com/v1/ats/jobs" \
-H "x-api-key: $POPP_API_KEY" \
-H "x-organization-id: $POPP_ORGANIZATION_ID"
curl "https://api.joinpopp.com/v1/ats/jobs/{atsJobId}/stages" \
-H "x-api-key: $POPP_API_KEY" \
-H "x-organization-id: $POPP_ORGANIZATION_ID"GET /v1/ats/jobs returns each job's stages[], so one call is enough to configure a node. Use the per-job endpoint when you have an atsJobId and need only its stages.
Routing events
| Event | When emitted | Required? |
|---|---|---|
onComplete | The candidate was added, or was already on the job | yes, when the node routes at all |
The node has one exit. Route onComplete to continue the journey, or use an empty routing map ({}) to end the run. It can be a mid-graph or final step.
Only an ATS or a Search & Match entry may reach this node. Every other entry source — API, SPREADSHEET, PUSH, and a legacy startNodeId no pathway covers — is refused with CREATE_ATS_APPLICATION_UNSUPPORTED_ENTRY.
The reason is the candidate profile, not the pathway. The node needs a profile the ATS will accept, and a spreadsheet or API run can carry one with no last name and no CV. Those runs fail at the ATS write, so the rule moves the refusal to a point where you can act on it.
The rule keys on reachability, not on the workflow's first trigger. A workflow may hold several entry pathways at once, so what matters is which sources have a path into this node. A workflow whose ATS pathway and spreadsheet pathway both reach the node is refused; one whose spreadsheet pathway reaches a different branch and cannot reach the node is valid. A node no entry can reach does not raise this error — UNREACHABLE_NODE already names that.
Search & Match is Popp's own sourcing pool. It is allowed because those profiles are richer in practice, not because the pathway guarantees it.
Adding someone to a job may start another workflow. This is intended. Popp enrols candidates by organisation and ATS job, so the application is an ATS entry event: a workflow, campaign, or analysis whose ATS entry pathway binds the destination job starts for that candidate. Use it as a hand-off from one workflow to the next job's workflow.
Therefore, the destination cannot be a job bound by this workflow's own ATS entry pathways. That would feed the workflow its own output and is rejected with ATS_DESTINATION_IS_ENTRY_JOB. The builder hides these jobs; the API does not, so check entryPathways before choosing a destination.
The node does not make an ATS application available to later nodes in the run. Popp records it from the ATS change notification, which arrives after the node completes. Later nodes that expect an ATS application, such as a stage change, will not see it. Put follow-on stage moves in the workflow triggered by the destination job instead.
When a run reaches the node, it calls the ATS and waits; there is no parking or polling. Both a newly added candidate and one already on the job are successes and route onComplete. The WORKFLOW_NODE_COMPLETED webhook distinguishes them — see Add to job outcome.
On permanent ATS refusal — the job or stage is gone, the job is closed, the ATS cannot create applications, or it rejects the submission — the run fails with errorCode: "ATS_CREATE_REJECTED"; lastFailedNodeId names the node. Transient failures are retried, and the run continues if a retry succeeds.
A profile the ATS cannot accept fails the run before the ATS is called. Creating an application needs a first name, a last name, and an email address. Popp falls back to the name parsed from the candidate's CV when the stored name is absent, but a candidate with none of them cannot be sent. The run record names the missing field, and that refusal is Popp's rather than your ATS's — the two read differently because they have different fixes. The entry-source restriction above reduces how often this happens; it does not remove it, because an ATS-sourced profile can be just as thin.
updateAtsApplication
updateAtsApplicationWrites back to your ATS: moves the run's own application to an interview stage, or rejects it with a reason. It is how a workflow records its outcome where your recruiters already work, rather than leaving that result only inside Popp.
The node acts on the application the run entered through — never the candidate's latest application, and never another job's. It therefore only does anything for runs that entered via an ATS pathway; see Runs with no ATS application for what happens to the others.
Config fields
One operation per node, discriminated on operation:
| Field | Type | Required | Notes |
|---|---|---|---|
operation | string | yes | Either "moveStage" or "reject". One node does one of the two; use two nodes if you need both. |
targets | array | yes | One entry per ATS job that can reach this node. At least one entry. Shape depends on operation. |
targets entries, by operation:
operation | Entry shape | Meaning |
|---|---|---|
"moveStage" | { atsJobId, atsInterviewStageId } | Move the application to this stage of this job's pipeline. |
"reject" | { atsJobId, atsRejectedReasonId } | Reject the application, recording this reason where the ATS supports rejection reasons. On an ATS that rejects by moving the application to a "Rejected" stage instead, the rejection is applied but the reason is not recorded, and the node still reports success. |
The config is strict: an unrecognised key is refused as INVALID_NODE_CONFIG rather than quietly dropped. A misspelled field name is therefore a 400 you can act on, not a silently ignored instruction you discover when a run does the wrong thing.
"move-to-interview": {
"id": "move-to-interview",
"type": "updateAtsApplication",
"label": "Move to first interview",
"slug": "move_to_interview",
"config": {
"operation": "moveStage",
"targets": [
{ "atsJobId": "<atsJobId>", "atsInterviewStageId": "<atsStageId>" }
]
},
"routing": { "onComplete": "notify-recruiter" }
}Why targets is a list
targets is a listA single ATS pathway can bind several jobs, and each job has its own pipeline. A stage id belonging to job A is meaningless on job B, so the node cannot hold one stage for everybody — it holds one target per job, and at runtime picks the entry whose atsJobId matches the job the run came in on.
The rule is exact: the set of atsJobIds in targets must equal the set of ATS jobs that can reach this node. Reachability is a graph question — it follows your entry pathways and the routing maps into this node — so adding a route can make a previously valid node invalid.
| Error code | Cause |
|---|---|
MISSING_ATS_JOB_MAPPING | An ATS job can reach the node but has no entry in targets. |
UNEXPECTED_ATS_JOB_MAPPING | A targets entry names a job that cannot reach the node — provided at least one ATS job reaches it. A node reachable only from a non-ATS pathway keeps its targets unrefused. |
DUPLICATE_ATS_JOB_MAPPING | The same atsJobId appears in targets more than once. |
Discovering targets
Every identifier the node needs is already published, and all three ids are your ATS's own ids:
| Config field | Where it comes from |
|---|---|
atsJobId | atsJobId from GET /v1/ats/jobs. |
atsInterviewStageId | atsStageId from GET /v1/ats/jobs/{atsJobId}/stages. The jobs response also carries the same list inline as stages, so one call can be enough. |
atsRejectedReasonId | atsReasonId from GET /v1/ats/rejection-reasons. |
Two consequences of where those ids are scoped:
- Stages are job-scoped. Read them per job, and never reuse a stage id across
targetsentries. - Rejection reasons are organisation-scoped. There is no per-job reason list, so the same
atsRejectedReasonIdlegitimately appears on every entry of arejectnode'stargets, while each entry still needs its ownatsJobId.
Two things to expect from the values themselves.
An id may be the human-readable name. Many ATS providers use a stage's or reason's own label as its identifier, so atsStageId often reads like "1st Interview" rather than an opaque key. Send it exactly as returned — that is the identifier, and an opaque-looking alternative would be rejected. The consequence is worth planning for: when the label is the id, renaming that stage in your ATS changes its id, which invalidates any published version targeting it. Runs reaching the node then fail with ATS_UPDATE_REJECTED (see When it fails). If your team renames pipeline stages, re-read discovery and publish a new version afterwards.
An id can be absent. Where your ATS gives Popp no identifier for a stage or reason, the field is null. A target you cannot name is one you cannot use, so choose a different stage or reason.
Routing events
| Event | When emitted | Required? |
|---|---|---|
onComplete | The ATS write finished, or the node was skipped. | yes |
onComplete is the node's only event, and there is no failure route — a failure fails the run rather than taking a branch.
To make the node a final step, route nothing: omit routing or leave it as {}. Both are valid, and required routes are only enforced once the map has at least one entry — so a map containing anything at all must include onComplete, or validation fails with MISSING_REQUIRED_ROUTE.
When a run reaches the node
The node calls your ATS and waits for the answer before the run moves on, so a downstream node can rely on the write having landed. An application already in the requested state counts as success — the operation is idempotent from your point of view.
On success the run emits WORKFLOW_NODE_COMPLETED and continues along onComplete (or completes, if the node is terminal).
Runs with no ATS application
A workflow can have one ATS pathway and non-ATS pathways, so a candidate started through POST /v1/workflows/{id}/runs can legitimately reach this node with no ATS application to update. That is not an error and not a refusal at publish: the node is skipped and the run carries on, reporting decision: "skipped_no_ats_application" on its node outcome. If you need the rest of the workflow to behave differently for those candidates, branch on a condition node before this one rather than expecting a refusal.
When it fails
A failure fails the run — no alternative route, and no downstream node executes. The WORKFLOW_RUN_FAILED event carries an errorCode saying which:
errorCode | Cause |
|---|---|
ATS_TARGET_UNCONFIGURED | The run's job has no entry in targets. Your ATS is never called. |
ATS_APPLICATION_UNAVAILABLE | A run that entered from the ATS reached the node without an application to act on. This should not happen — contact support if you see it. |
ATS_UPDATE_REJECTED | The ATS refused the write permanently: the application or the target no longer exists, your ATS cannot perform this operation, or it returned an error. |
SYSTEM_FAILURE | A transient ATS problem that did not clear after retries. |
A published version is frozen, and its targets are never re-checked. Targets are validated against the live ATS at publish (see ATS application targets) and not again. If a stage or reason is deleted or renamed in your ATS afterwards, runs that reach the node fail with ATS_UPDATE_REJECTED — including runs already in flight. The fix is to correct the configuration and publish a new version; editing the draft alone changes nothing for runs executing the published one.
In webhooks
The node needs no new subscription. It appears in the workflow events you already receive, with nodeType: "UPDATE_ATS_APPLICATION" and an atsApplicationUpdate node outcome — see Workflow Events.
schedulingOutreach
schedulingOutreachSends the candidate a booking link over SMS, WhatsApp, or email, so the candidate can book a meeting with your team. The node completes when the candidate books.
A scheduling node must be the last node in the workflow. Omit routing, or send {}.
Config fields
Agent and message:
| Field | Type | Required | Notes |
|---|---|---|---|
agentName | string | yes | Display name of the agent persona. Up to 100 characters. See Agent reference. |
agentTone | string | yes | One of CASUAL, ENCOURAGING, FRIENDLY, MOTIVATIONAL, NEUTRAL, PROFESSIONAL, CUSTOM. |
channel | string | yes | One of SMS, WHATSAPP, EMAIL. |
openingMessageTemplateId | string | conditional | Approved SCHEDULING opening-message template. Required for WHATSAPP. Not allowed for EMAIL. On SMS, use this field or customTemplatedMessage. Scheduling nodes use this field in place of templateId. |
customTemplatedMessage | string | conditional | The opening message. Required for EMAIL. Not allowed for WHATSAPP. Must contain {{MEETING_URL}} and the word STOP. |
customTemplatedSubjectLine | string | conditional | Required for EMAIL. |
language | string | no | Language code of the conversation, for example en. |
timeToAutoCloseConversationsInHours | number | no | Hours of inactivity before the conversation closes. Default: 168. |
Meeting:
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | yes | Title of the meeting. Available in messages as {{MEETING_TITLE}}. |
durationMinutes | integer | yes | Length of the meeting in minutes. 1 to 1440. |
timezone | string | yes | IANA time zone, for example Europe/London. The times in availability use this time zone. |
availability | object | yes | The weekly hours in which a candidate can book. See Availability. |
participants | array | yes | The people from your team who attend. See Participants. |
availabilityMethod | string | no | COLLECTIVE (default) or ROUND_ROBIN_MAX_AVAILABILITY. See Participants. |
description | string | no | Description of the meeting. |
location | string | no | Physical location. Use only when videoConference is false or absent. |
videoConference | boolean | no | true adds a video link to the meeting. Default: false. |
videoConferencingProvider | string | conditional | Google Meet or Microsoft Teams. Required when videoConference is true. If the organizer has a connected calendar, the meeting uses that calendar's provider. |
buffer | integer | no | Minutes kept free before and after each meeting. 0 to 120, in steps of 5. Default: 0. |
noticePeriodMinutes | integer | no | Minimum minutes between booking and the start of the meeting. Default: 0. |
availableDaysInTheFuture | integer | no | How many days ahead a candidate can book. Default: 30. |
sendMeetingReminder | integer | no | Minutes before the meeting to remind the candidate. SMS and WHATSAPP only. Default: 1440. |
availabilityOutreachSettings | object | no | Settings for the email that asks participants for their availability. See Availability Outreach Settings. |
guestLabel | string | no | Word the availability email uses for the person who books, for example recruiter. Up to 50 characters. Must not begin with "the". |
Availability
availability is the set of weekly hours in which a candidate can book. It repeats every week. noticePeriodMinutes and availableDaysInTheFuture set the first and last day a candidate can book.
Each key is a day of the week, from 0 (Sunday) to 6 (Saturday). Each value is a list of { "start", "end" } slots in HH:mm. Slots on the same day must not overlap. Leave out a day to make it unavailable. availability must contain at least one slot.
A candidate can book a time inside availability when the participants are free: every participant for a COLLECTIVE meeting, or at least one participant for a ROUND_ROBIN_MAX_AVAILABILITY meeting.
{
"timezone": "Europe/London",
"durationMinutes": 30,
"availability": {
"1": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"2": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"3": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"4": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"5": [{ "start": "09:00", "end": "12:00" }]
},
"noticePeriodMinutes": 1440,
"availableDaysInTheFuture": 14
}With this configuration, a candidate can book a 30-minute meeting on a weekday during these hours, London time, from 24 hours after booking up to 14 days ahead.
Participants
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | Email address of the participant. |
name | string | yes | Display name. |
isCalendarConnected | boolean | yes | true when the participant has connected a Google or Microsoft calendar to Popp. |
isOrganizer | boolean | no | Default: false. |
autoCollectAvailability | boolean | no | true to ask a participant with no connected calendar for their availability by email. See Scheduling with Availability Collection. |
availabilityOutreachSettings | object | no | Overrides the node-level availabilityOutreachSettings for this participant. |
Each participant needs a connected calendar or autoCollectAvailability: true.
availabilityMethod | Who attends | Participants |
|---|---|---|
COLLECTIVE (default) | Every participant | Exactly one with isOrganizer: true. |
ROUND_ROBIN_MAX_AVAILABILITY | One participant from the pool | 2 to 10. |
Routing events
A scheduling node is always the last node, so every event ends the run.
| Event | When emitted | Run-level outcome |
|---|---|---|
onComplete | The candidate booked a meeting | completed |
onNotInterested | The candidate declined | completed |
onOptedOut | The candidate opted out | opted_out |
onNoReply | The conversation closed before the candidate booked | no_response |
onDeliveryFailed | Delivery failed | delivery_failed |
To tell a booking from a decline, read the node's routingEvent.
In webhooks
The node appears in the workflow events you already receive, with nodeType: "SCHEDULING_OUTREACH". Its outcome is meeting when the candidate books, and empty otherwise. See Workflow Events. If the candidate cancels or moves the meeting later, the CALENDAR_MEETING_CANCELLED and CALENDAR_MEETING_RESCHEDULED scheduling events report it.
See Example 6 for a complete configuration.
Context the conversation engine uses to generate replies
When a candidate replies during an outreach, Popp's conversation engine generates the next message. The config fields you populate on a node are what shape that reply — without that context, replies are generic; with it, the engine can answer candidate questions about the role and phrase nudges, rejections, and closing notes with substance. Understanding which field plays which role is the difference between a reply that sounds informed and one that does not.
Each field maps to a specific role in the conversation engine's input:
| Role | Source field(s) | Purpose |
|---|---|---|
| Job description | outreachContext | The full role description. Lets the engine answer candidate questions about the role and phrase nudges with substance. Removed on engagementOutreach — provide this only on screeningOutreach if you want the engine to discuss the role. |
| Job summary | summaryOfRole | Short blurb that sits alongside the full description. Useful when the description is long and you want the engine to lead with a one-line framing. |
| Job title | roleTitle | Available to the engine as structured data, and exposed in messages via the {{JOB_TITLE}} template variable. Removed on engagementOutreach. |
| Employer name | employerName | Available to the engine as structured data, and exposed in messages via the {{EMPLOYER_NAME}} template variable. Removed on engagementOutreach. |
| Job location | location | Available to the engine as structured data, and exposed in messages via the {{LOCATION}} template variable. Removed on engagementOutreach. |
| Contract type | contractType | Available to the engine when contract terms come up in the conversation. |
| Steering | additionalContext | Free-form notes for the engine — tone, style, things to avoid, edge cases, anything you would brief a human recruiter on. Always sent to the engine, including on engagementOutreach. |
| Agent persona | agentName, agentTone | Selects the agent persona — drives the assistant's name, voice, and signature in generated replies. Both fields are required on every outreach node. |
| Opening copy | customTemplatedMessage (+ customTemplatedSubjectLine for email) | The verbatim first message sent to the candidate. Template variables in {{...}} are substituted at send time. |
| Lifecycle copy | criteriaSatisfiedClosingMessage, criteriaNotSatisfiedClosingMessage, candidateNotInterestedClosingMessage | Per-state copy the engine uses when closing on a passed candidate, closing on a failed candidate, or wrapping up after a not-interested signal. Provide these to control how those moments sound. |
Two practical implications:
- The role description belongs in
outreachContext, notadditionalContext. A common mistake is to put tone or style notes inoutreachContextand the role description inadditionalContext. The semantics are the inverse:outreachContextis the job description,additionalContextis the steering layer. Swapping them produces replies that sound off-brief. - On
engagementOutreach, lean onadditionalContext. Engagement nodes don't pass job-description fields to the conversation engine, soadditionalContextis your only steering channel. If you need the engine to know the broader recruiting context for a nurture or compliance step, put it there.
Routing Events
The supported node types emit the following routing events. Wire each event you care about in a node's routing map; events you do not wire terminate the run when they fire.
| Event | Emitted by | When it fires |
|---|---|---|
onComplete | screeningOutreach, engagementOutreach | The conversation reached a successful terminal state (passed, failed, or closed cleanly). |
onComplete | createAtsApplication | The candidate was added to the destination ATS job, or was already on it. |
onComplete | updateAtsApplication | The ATS write finished, or the node was skipped for a run with no ATS application. |
onNotInterested | screeningOutreach, engagementOutreach | The candidate explicitly declined. |
onOptedOut | screeningOutreach, engagementOutreach | The candidate opted out (e.g. replied STOP over SMS). |
onDeliveryFailed | screeningOutreach, engagementOutreach | Message delivery failed across the available channels (e.g. invalid mobile number). |
onConditionResolved | condition | A branch (or the else fallback) was selected. |
onComplete | schedulingOutreach | The candidate booked a meeting. |
onNotInterested, onOptedOut, onDeliveryFailed | schedulingOutreach | As for the outreach nodes above. |
onNoReply | schedulingOutreach | The conversation closed before the candidate booked. |
Every event a schedulingOutreach node emits ends the run.
Answer-route DSL
Answer routes attach to individual questions on screeningOutreach and engagementOutreach nodes. They control how the conversation behaves while the candidate is still answering — skip a follow-up if the candidate said "no", close the conversation early when a score requirement isn't met, or hand off to a different node mid-conversation.
Two layers of routing
Outreach nodes have two routing layers that work together:
- In-conversation routing — answer routes (this section). Fire during a conversation, in response to individual answers, scores, or document uploads. Control which question comes next, whether to close the conversation early, and (via
ROUTE_TO) whether to override the node's default downstream target. - Node-level routing — routing events (see Routing Events). Fire when the conversation terminates. The node's
routingmap decides which downstream node the run transitions to.
The two layers are connected through the actions an answer route runs:
| In-conversation action | Effect on node-level routing |
|---|---|
CONTINUE | None. The conversation continues; no node-level event fires. |
CLOSE_CONVERSATION with status: "PASSED_CRITERIA" | Conversation terminates. onComplete fires with webhook outcome PASSED_CRITERIA. Run transitions per routing.onComplete. |
CLOSE_CONVERSATION with status: "FAILED_CRITERIA" | Conversation terminates. onComplete fires with webhook outcome FAILED_CRITERIA. Run transitions per routing.onComplete. |
ROUTE_TO (paired with CLOSE_CONVERSATION) | Conversation terminates with the paired status, then the run transitions to the target nodeId — overriding the node's default routing.onComplete target. |
Some node-level events are emitted automatically by the conversation engine and are not configurable via answer routes:
onNotInterested— fires when the candidate explicitly declines.onOptedOut— fires when the candidate opts out (e.g. SMSSTOP).onDeliveryFailed— fires when delivery fails across all available channels.
Wire these on the node's routing map if you want to handle them; you don't trigger them from answer routes.
Route shape
Each entry in answerRoutes[] has shape:
{
"when": { "field": "...", "op": "...", "value": "..." },
"actions": [
{ "type": "CONTINUE", "slug": "next-question" }
],
"closingMessage": "Optional closing copy used when actions terminate the conversation"
}| Field | Type | Required | Notes |
|---|---|---|---|
when | object | yes | Single-leaf condition expression. See Condition vocabulary. |
actions | array | yes | Ordered list of one or more actions. Multiple actions in a single route are how ROUTE_TO pairs with CLOSE_CONVERSATION (see Actions). |
closingMessage | string | no | Up to 1000 characters. Used as the final message when the route's actions terminate the conversation. |
Evaluation order. Answer routes are walked in source order. The first route whose
whenmatches the candidate's answer runs itsactionsand the resolver returns — subsequent routes don't fire, even if they would also match. Order more specific routes before more permissive ones (e.g.mentions "Greater London"beforementions "London"); if the fan-out includes a catch-all (answerState=any), it must sit at the end of the array (the validator enforces this withCATCH_ALL_NOT_LAST). The same first-match-wins rule applies to condition-nodebranches— see Condition-node DSL.
Condition vocabulary
The when expression on an answer route is a single leaf — and, or, and not are not permitted. Use a condition node for compound logic.
field | Operators | Value type | Notes |
|---|---|---|---|
answerState | eq | "yes" | "no" | "any" | The conversation engine's classification of the candidate's answer. |
answerText | mentions, does_not_mention | non-empty string | Semantic match against the answer text (evaluated by the conversation engine). |
documentState | eq | "uploaded" | "not_uploaded" | Whether the candidate uploaded the requested document. |
score | gte, lt | integer 0–100 | Preferred-answer score. Requires the question to carry preferredAnswerText. Only gte and lt are accepted on answer routes — see note below. |
Note on score operators. Answer-route
scoreconditions accept onlygteandlt. Two consequences:
gtandlteonscoreare rejected at publish asINVALID_NODE_CONFIG(the schema's score-operator enum is closed, so unsupported operators fail the shape parse — they don't reach the business-rule layer that emitsMISSING_RUBRIC_SOURCE). Express thresholds as half-open intervals:score >= 70instead ofscore > 69,score < 70instead ofscore <= 69.- The full set (
gte,gt,lt,lte) is still available onconditionnodes, which use the recursive expression DSL — see Condition-node DSL.
Closed operator vocabulary. The operator sets in the table above are exhaustive on the answer-route surface. Other operators on a family field — for example
neqonanswerState,inondocumentState, oreqonscore— are rejected at publish withINVALID_OPERATOR_FOR_FIELD. Presence operators (exists,empty) are not available on answer routes; they remain valid oncondition-node expressions (see Condition-node DSL).
Actions
Three action types are supported. A route's actions array runs in order.
CONTINUE
CONTINUEMove on within the same conversation to a named sibling question. slug names the next question to ask within this node. There is no implicit fallback — a CONTINUE without a slug has no defined runtime target and is rejected at publish time (BARE_CONTINUE_ROUTE):
{ "type": "CONTINUE", "slug": "intent-to-register" }CONTINUE does not fire a node-level routing event — it stays inside the conversation.
Validation at publish time:
INVALID_CONTINUE_SLUG— the named slug does not exist in this node.CONTINUE_SLUG_CYCLE— explicitCONTINUEslug targets within a node form a cycle (e.g.q1 → q2 → q1).BARE_CONTINUE_ROUTE— aCONTINUEaction was authored without aslug. EveryCONTINUEmust name its sibling target — there is no implicit fallback. Add aslug, or if the route's intent is to close or hand off, replace theCONTINUEwithCLOSE_CONVERSATION(terminate the run) orROUTE_TO + CLOSE_CONVERSATION(hand off to another node).
CLOSE_CONVERSATION
CLOSE_CONVERSATIONTerminate the conversation cleanly. The status field uses the same vocabulary as the workflow-domain webhook outcome — whatever you set here is what onComplete fires with on the WORKFLOW_NODE_COMPLETED webhook.
status | Webhook outcome | Node-level event fired |
|---|---|---|
"PASSED_CRITERIA" | PASSED_CRITERIA | onComplete |
"FAILED_CRITERIA" | FAILED_CRITERIA | onComplete |
{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }After the conversation closes, the run transitions per the node's routing.onComplete target — unless a ROUTE_TO action is paired in the same route (see below).
ROUTE_TO
ROUTE_TOHand off to a different node when the conversation closes, overriding the node's default routing.onComplete target. Use this when a specific in-conversation outcome should branch into its own downstream journey rather than reusing the node's configured onComplete route.
{ "type": "ROUTE_TO", "nodeId": "engagement-london-compliance" }ROUTE_TO must always be paired with a CLOSE_CONVERSATION action in the same route — ROUTE_TO transfers run ownership to another node, and CLOSE_CONVERSATION declares the terminal status for the current conversation. Both are required; without CLOSE_CONVERSATION, the publish is rejected with ROUTE_TO_WITHOUT_CLOSE:
{
"when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [
{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" },
{ "type": "ROUTE_TO", "nodeId": "engagement-london-compliance" }
]
}Validation at publish time:
ROUTE_TO_WITHOUT_CLOSE— aROUTE_TOaction is missing its required siblingCLOSE_CONVERSATIONaction.INVALID_ROUTE_TO_ACTION_TARGET— thenodeIddoes not exist in the workflow graph.
Examples
Close the conversation as rejected if the candidate isn't NMC-registered:
{
"when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }]
}Skip directly to a shift-pattern deep-dive if the candidate scored highly on availability:
{
"when": { "field": "score", "op": "gte", "value": 70 },
"actions": [{ "type": "CONTINUE", "slug": "shift-pattern" }]
}Branch into a London-specific question if the candidate's region response mentions London:
{
"when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [{ "type": "CONTINUE", "slug": "london-trust-preference" }]
}Close the conversation and hand off to a London-specific compliance node when the answer mentions London:
{
"when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [
{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" },
{ "type": "ROUTE_TO", "nodeId": "engagement-london-compliance" }
],
"closingMessage": "Thanks — I'll hand you over to our London compliance team."
}Condition-node DSL
The when expression on each condition node branch is the full recursive expression DSL.
Operators
| Operator | Args | Description |
|---|---|---|
eq | field, value | Field equals value |
neq | field, value | Field not equal to value |
gt, gte, lt, lte | field, value (number) | Numeric comparison |
in | field, values[] | Field equals one of the listed values |
mentions | field, value (string) | String/array contains the substring or item |
does_not_mention | field, value (string) | Inverse of mentions |
exists | field | Field is present and non-null |
empty | field | Field is absent, null, or an empty string/array |
and | conditions[] | All sub-expressions must be true. conditions must be non-empty. |
or | conditions[] | At least one sub-expression must be true. conditions must be non-empty. |
not | condition | Inverts the sub-expression |
and and or reject empty conditions[] arrays at publish time — vacuous always-match catch-alls fail the schema parse and surface as INVALID_NODE_CONFIG on the path to the empty array.
Field paths
Field paths are dot-delimited and the leading segment must match a WorkflowRunContext namespace:
| Namespace | What it contains |
|---|---|
trigger | The original trigger metadata supplied to Start Workflow Runs. |
candidate | Identity and contact fields for the candidate enrolled in this run. |
job | Role/job-level fields associated with the run. |
conversations | Map of conversation outcomes keyed by upstream node ID. |
answers | Map of question outcomes keyed by question slug. |
analysis | Analysis outputs produced by upstream analysis steps. |
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 or an analysis output exists on an upstream node.
Within the answers.<slug>.* namespace, the routable leaves are:
| Leaf | Value type |
|---|---|
answerState | IS_YES, IS_NO, IS_ANY, INCLUDES, DOES_NOT_INCLUDE, DOCUMENT_UPLOADED, DOCUMENT_NOT_UPLOADED, NEEDS_CLARIFICATION |
answerText | string (semantic match via mentions / does_not_mention) |
score | integer 0–100 (only when the question carried preferredAnswerText) |
documentState | uploaded, not_uploaded |
outcome | PASSED, FAILED, NOT_COMPLETED, NEEDS_REVIEW, PENDING, INCOMPLETE — see Workflow Events |
INCOMPLETE is set when the conversation engine can't classify a yes/no answer (the candidate's reply is too vague to orient pass/fail). Route on it via { "op": "eq", "field": "answers.<slug>.outcome", "value": "INCOMPLETE" } to send those candidates down a clarification branch rather than to the default pass/fail downstream.
Depth limit
Expressions deeper than 10 nesting levels evaluate to false at runtime. Keep your branch logic flat — if you find yourself nesting more than a few layers, split into multiple condition nodes.
Examples
Fast-track candidates who scored at least 70 on the shift-availability question:
{ "op": "gte", "field": "answers.shift-availability.score", "value": 70 }Route candidates who hold an active NMC PIN AND signalled availability for full-time work:
{
"op": "and",
"conditions": [
{ "op": "eq", "field": "answers.nmc-pin.answerState", "value": "yes" },
{ "op": "gte", "field": "answers.shift-availability.score", "value": 60 }
]
}Configuration validation
The same validation runs on every call that accepts a configuration, not only at publish:
| Endpoint | When it validates |
|---|---|
POST /v1/workflows | on the configuration you supply — an invalid config is rejected at create, so the workflow is never stored |
PATCH /v1/workflows/{id} | on the replacement configuration |
POST /v1/workflows/{id}/publish | on the working draft, before any campaign or version is created |
An invalid configuration returns HTTP 400 listing every problem found, rather than stopping at the first. Each error names the node it came from, in the form [node-id] ERROR_CODE: message.
All three run the same structural checks, including those that verify references against your organisation such as WhatsApp template eligibility. Two families of configuration check are deferred to publish, so a configuration accepted by POST /v1/workflows has cleared almost — but not quite — everything publish will check. Publish also applies refusals that are not configuration checks at all (ATS_JOB_ALREADY_CONNECTED, RUBRIC_GENERATION_FAILED), listed with the endpoint:
documentItemsreferences are resolved at publish.- ATS node targets are checked against your live ATS at publish only (see ATS application targets). Create and update will accept a
moveStagetarget naming a stage your ATS does not have; publish is where that is refused. Your ATS is the authority on which stages and reasons exist, and it can change between the moment you save a draft and the moment you publish — so the check happens at the point it decides something. - An Add to job node's destination is checked against your live ATS at publish only, for the same reason. Create and update accept an
atsJobIdoratsStageIdyour ATS does not have; publish refuses it.
The error codes that appear in that message:
Graph-level
| Error code | Cause |
|---|---|
START_NODE_NOT_FOUND | startNodeId does not appear in nodes. |
UNSUPPORTED_NODE_TYPE | Node type is not one of the v1 wired types. |
MISSING_REQUIRED_ROUTE | A node did not route a routing event marked as required. |
UNKNOWN_ROUTE | A node's routing map contains an event name the node does not emit. |
UNREACHABLE_NODE | A node is defined but cannot be reached from any entry point (the start node or an entry pathway target). |
CYCLE_DETECTED | The graph contains a cycle. Workflow runs are forward-only and cannot revisit nodes. |
INVALID_GRAPH_EDGE_TARGET | A routing target points to a node ID that does not exist in the graph. |
Outreach config
| Error code | Cause |
|---|---|
MISSING_OUTREACH_CONFIG_FIELD | A required field on a screeningOutreach/engagementOutreach config is missing. |
MISSING_AGENT_REFERENCE | An outreach node config is missing agentName and/or agentTone. Both fields are required on every outreach node — one issue fires per missing field, so a config missing both surfaces two errors on a single publish attempt. See Agent reference. |
MISSING_NODE_CONFIG | A node has no config object at all. |
INVALID_NODE_CONFIG | Generic node config shape error that doesn't match a more specific code. |
MISSING_OUTREACH_QUESTIONS | The questions array is empty or missing on a conversational outreach node (screeningOutreach or engagementOutreach). |
MISSING_QUESTION_CONTENT | A question's content is missing or empty. |
MISSING_ANSWER_ROUTES | A question has no answerRoutes or an empty answerRoutes array. Every question must declare at least one route — there is no implicit "next by array position" fallback. Add at least one answerRoutes entry whose actions close the conversation, route to another node, or CONTINUE to a sibling question by slug. |
DUPLICATE_QUESTION_SLUG | Two or more questions in the same node share the same slug. |
MISSING_OUTREACH_MESSAGE | Neither templateId nor customTemplatedMessage provided (and channel is not WHATSAPP). |
MISSING_EMAIL_SUBJECT | channel: EMAIL without customTemplatedSubjectLine. |
INVALID_EMAIL_TEMPLATE | templateId provided on an EMAIL channel. |
MISSING_SMS_STOP | channel: SMS with customTemplatedMessage missing the STOP keyword. |
MISSING_WHATSAPP_TEMPLATE | channel: WHATSAPP without templateId. |
INCOMPATIBLE_CAMPAIGN_TYPE | campaignType names a campaign the node type cannot run — e.g. SCHEDULING or ENGAGEMENT_OUTREACH on a screeningOutreach node. The message lists the values that node type accepts. See Campaign type. |
MISSING_RUBRIC_SOURCE | Answer route uses field: "score" on a question with no preferredAnswerText. Add a non-empty preferredAnswerText to opt the question into scoring. (Unsupported score operators like gt/lte fail the schema parse and surface as INVALID_NODE_CONFIG, not this code.) |
RUBRIC_GENERATION_FAILED | A scoring question's preferredAnswerText was too vague or otherwise unusable to produce a scoring rubric (or a transient platform error occurred). The path identifies the offending question by slug. Retrying often resolves transient failures; if the error persists, sharpen the preferredAnswerText so it expresses concrete answer expectations. |
Question-shape coherence
These rules cross-check each question's fields and routes against the four valid question shapes. They fire at publish time and are distinct from INVALID_FIELD_NAMESPACE (which catches field/namespace mismatches across the answer-route and condition-node surfaces); the codes below catch in-shape misuse on a single question.
| Error code | Cause |
|---|---|
INCOMPATIBLE_FIELD_FOR_QUESTION_TYPE | A scalar field doesn't match the question's questionType. Examples: expectedAnswer, strictness, or preferred-answer fields on a DOCUMENT question; documentTypes on a TEXT question. The path identifies the offending field. |
INCOMPATIBLE_ROUTE_FOR_QUESTION_TYPE | The dominant answer-route operator family doesn't match the question's questionType. DOCUMENT questions accept only document-state routes; TEXT questions accept yes/no, text-mention, or scoring routes. |
EXPECTED_ANSWER_ON_NON_YES_NO_QUESTION | expectedAnswer is set on a question whose answerRoutes use scoring, text-mention, or document-state operators. expectedAnswer is a binary signal exclusive to yes/no routes — when set, the conversation engine skips scoring entirely. |
PREFERRED_ANSWER_ON_YES_NO_QUESTION | preferredAnswerText appears on a yes/no-shaped question (identified by expectedAnswer being set OR by directional yes/no answerRoutes — answerState eq yes / answerState eq no). Yes/no and scoring shapes are mutually exclusive. |
INVALID_OPERATOR_FOR_FIELD | A non-canonical value-comparison operator was used on a family field. Canonical sets: answerState/documentState accept only eq; answerText accepts only mentions/does_not_mention; score accepts gte/gt/lt/lte on condition nodes and gte/lt on answer routes. Presence operators (exists/empty) are not affected by this rule. |
Answer-route specific
| Error code | Cause |
|---|---|
INVALID_CONTINUE_SLUG | A CONTINUE action's slug does not match any question in the same node. |
CONTINUE_SLUG_CYCLE | Explicit CONTINUE slug targets within a node form a cycle. |
ROUTE_TO_WITHOUT_CLOSE | A ROUTE_TO action is missing its required sibling CLOSE_CONVERSATION action in the same route. |
INVALID_ROUTE_TO_ACTION_TARGET | A ROUTE_TO action's nodeId does not exist in the workflow graph. |
BARE_CONTINUE_ROUTE | A CONTINUE action is missing its slug. Every CONTINUE must name its sibling target — there is no implicit fallback. Add a slug to the CONTINUE action, or replace the action with CLOSE_CONVERSATION (terminate the run) or ROUTE_TO + CLOSE_CONVERSATION (hand off to another node) depending on the route's intent. |
Answer-logic coherence and reachability
These rules apply across the answer routes on a single question (and across the branches on a condition node). They prevent configurations that publish cleanly but misbehave at runtime — silent closes, never-fires routes, and ambiguous fan-outs.
| Error code | Cause |
|---|---|
MIXED_OPERATOR_FAMILIES | The answer routes (or condition branches) on a single node mix operators from different compatibility groups. Each fan-out must use one group throughout: answer-state (answerState=eq), answer-text (answerText mentions/does_not_mention), score (gte/lt), or document-state (documentState=eq). A trailing answerState=any catch-all is exempt from this rule — it may appear at the end of an answer-text fan-out without triggering a mixed-outcome error. |
MISSING_FALLBACK_ROUTE | The fan-out doesn't cover every input. Cases: • answer-text fan-out is missing the trailing catch-all ({ field: "answerState", op: "eq", value: "any" }).• score fan-out doesn't span the full 0–100 domain.• Binary-intent question (see Question shapes) isn't a complete pair: use documentState=uploaded + =not_uploaded on DOCUMENT, or answerState=yes + =no on TEXT with expectedAnswer. A sole catch-all, a missing half, or a redundant catch-all alongside the pair all fire this code. |
INVALID_FIELD_NAMESPACE | A field/operator pair does not match its source namespace. The recognised v1 surfaces are the unprefixed answer-route fields (answerState, answerText, score, documentState) and the namespace-prefixed condition fields (answers.<slug>.<leaf>). The analysis.* and meetings.* namespaces are reserved for forthcoming node types and are rejected today. |
MISSING_CLOSE_CONVERSATION | A path through an outreach node's question graph reaches the final question without an explicit CLOSE_CONVERSATION action. Without it the conversation engine closes silently — the candidate hears no closing message. Add a CLOSE_CONVERSATION action (with status: "PASSED_CRITERIA" or status: "FAILED_CRITERIA") on every terminal answer route. |
CATCH_ALL_NOT_LAST | A catch-all route (answerState=any) appears before another route in the answerRoutes array. The runtime resolver walks routes in source order and returns on the first match — any route after the catch-all is unreachable. Move the catch-all to the end of the array. |
SCORE_RANGE_UNREACHABLE | A score route's interval is fully covered by the union of preceding score routes. First-match-wins means the subsumed route never fires. Tighten or reorder the thresholds so each interval reaches some inputs. |
Worked example — a fan-out that fails today
{
"slug": "region",
"content": "Which UK region or trusts are you available to cover?",
"answerRoutes": [
{ "when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [{ "type": "CONTINUE", "slug": "london-shifts" }] },
{ "when": { "field": "answerText", "op": "mentions", "value": "Manchester" },
"actions": [{ "type": "CONTINUE", "slug": "manchester-shifts" }] }
]
}This passes the schema but is rejected at publish with MISSING_FALLBACK_ROUTE — an answer-text fan-out needs an explicit catch-all so candidates whose answer mentions neither term still have a defined route. Fix:
"answerRoutes": [
{ "when": { "field": "answerText", "op": "mentions", "value": "London" },
"actions": [{ "type": "CONTINUE", "slug": "london-shifts" }] },
{ "when": { "field": "answerText", "op": "mentions", "value": "Manchester" },
"actions": [{ "type": "CONTINUE", "slug": "manchester-shifts" }] },
{ "when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "shift-availability" }] }
]Condition-node specific
The strict expression schema rejects unknown operators, missing fields, and value-type mismatches. Field paths whose leading segment does not match a known WorkflowRunContext namespace are rejected with INVALID_CONDITION_FIELD.
| Error code | Cause |
|---|---|
INVALID_CONDITION_FIELD | A field path does not begin with a known WorkflowRunContext namespace (trigger, candidate, job, conversations, answers, analysis). |
UNREACHABLE_CONTEXT | A condition node references a context value (e.g. answers.<slug>.*) that is not produced by any upstream node reachable from the start node. The branch would always evaluate against missing data. |
Add to job config
The first four codes are structural and run on create, update, and publish. The last three require a live ATS read, so run only at publish.
| Error code | Cause |
|---|---|
MISSING_ATS_DESTINATION_JOB | atsJobId is missing, empty, or not a string on a createAtsApplication config. |
MISSING_ATS_DESTINATION_STAGE | atsStageId is missing, empty, or not a string. Each required destination field has its own error code. |
ATS_DESTINATION_IS_ENTRY_JOB | The destination is bound by this workflow's own ATS entry pathways, so adding a candidate would re-trigger the workflow. The message names the node and job; path is nodes.<node-id>.config.atsJobId. |
CREATE_ATS_APPLICATION_UNSUPPORTED_ENTRY | An entry source other than ATS or Search & Match can reach the node. The message names the node and every offending source; path is nodes.<node-id>. This one is refused on create and update as well as on publish, so it stops you saving the shape, not only publishing it. |
ATS_STAGE_NOT_FOUND | Publish only. The selected stage is unavailable for that job because it was deleted or belongs to another job. The message names the node and atsStageId, and distinguishes a stage unavailable for the job from one unavailable for the organisation. |
ATS_CAPABILITY_UNAVAILABLE | Publish only. The connected ATS cannot provide job stages, so no createAtsApplication node can work. This is an ATS limitation, not a config error. |
ATS_CHECK_UNAVAILABLE | Publish only; returns HTTP 503, not 400. Popp could not reach the ATS to validate the node, so the workflow was not published. The config may be valid; publish again. |
The stage is checked only at publish. If it is later deleted, the next candidate to reach the node finds the problem — see when it fails.
ATS application targets
Two layers apply to an updateAtsApplication node. The structural layer runs wherever a configuration is accepted; the live layer runs at publish only, because it reads your connected ATS.
Structural — create, update and publish
| Error code | Cause |
|---|---|
MISSING_ATS_JOB_MAPPING | An ATS job can reach the node but has no entry in targets. |
UNEXPECTED_ATS_JOB_MAPPING | A targets entry names a job that cannot reach the node — provided at least one ATS job reaches it. A node reachable only from a non-ATS pathway keeps its targets unrefused. |
DUPLICATE_ATS_JOB_MAPPING | The same atsJobId appears in targets more than once. |
MISSING_NODE_CONFIG | The node has no config at all. |
INVALID_NODE_CONFIG | The config does not match the shape: an unknown operation, an empty targets, an empty id, or any unrecognised key — the config is strict, so a stray field is refused rather than dropped. |
Live ATS — publish only
These name the offending node and the offending field, e.g. ATS_STAGE_NOT_FOUND on node 'move-to-interview' field 'atsInterviewStageId': ….
| Error code | HTTP | Cause |
|---|---|---|
ATS_STAGE_NOT_FOUND | 400 | A moveStage target names a stage that is not on the job's pipeline. |
ATS_REJECTION_REASON_NOT_FOUND | 400 | A reject target names a reason your organisation's ATS does not have. |
ATS_CAPABILITY_UNAVAILABLE | 400 | Your connected ATS cannot offer the list a node's targets need at all — for example an ATS with no rejection-reasons model cannot support a reject node. |
ATS_TARGET_JOB_LIMIT_EXCEEDED | 400 | The workflow's ATS nodes name more than 250 distinct ATS jobs between them. |
ATS_CHECK_UNAVAILABLE | 503 | Your ATS could not be reached, so the targets could not be checked and the workflow was not published. Your configuration may be perfectly valid — repeat the request. |
ATS_CHECK_UNAVAILABLE is the one refusal here to retry rather than fix. Every other code in this section means the configuration has to change.
Passing this check is not a guarantee. A target that can be neither confirmed nor contradicted is allowed through rather than refused, so publish can succeed on a target a run later fails on. That happens in two cases: when your ATS does not return the job a target names, and when it offers no identifier for the stages or reasons of that job. Treating either as proof of deletion would block workflows over an incomplete answer, so publish gives you the benefit of the doubt — which is why ATS_UPDATE_REJECTED remains possible at run time even after a clean publish.
Scheduling config
These codes apply to a schedulingOutreach node. Create, update, and publish all run these checks.
| Error code | Cause |
|---|---|
MISSING_AGENT_REFERENCE | agentName is missing, empty, or longer than 100 characters, or agentTone is missing or not one of the listed values. |
INVALID_NODE_CONFIG | A field does not match its type or range. The message names the field. |
MISSING_WHATSAPP_TEMPLATE | channel is WHATSAPP and openingMessageTemplateId is missing. |
INVALID_WHATSAPP_CUSTOM_MESSAGE | channel is WHATSAPP and customTemplatedMessage is set. |
MIXED_SCHEDULING_MESSAGE_SOURCES | channel is SMS and both openingMessageTemplateId and customTemplatedMessage are set. |
MISSING_SCHEDULING_MESSAGE | channel is SMS and neither message field is set, or channel is EMAIL and customTemplatedMessage is missing. |
INVALID_EMAIL_TEMPLATE | channel is EMAIL and openingMessageTemplateId is set. |
MISSING_EMAIL_SUBJECT | channel is EMAIL and customTemplatedSubjectLine is missing. |
MISSING_MEETING_URL_PLACEHOLDER | customTemplatedMessage does not contain {{MEETING_URL}}. |
MISSING_OPT_OUT_INSTRUCTION | customTemplatedMessage does not contain the word STOP in capital letters. |
INVALID_TEMPLATE_VARIABLE | A message uses a variable that the scheduling node cannot fill. See Template variables. |
INVALID_SCHEDULING_PARTICIPANTS | A COLLECTIVE meeting does not have exactly one organizer, or a ROUND_ROBIN_MAX_AVAILABILITY meeting does not have 2 to 10 participants. |
INFEASIBLE_AVAILABILITY_REQUEST | availability has no time slots, or requiredHours is more than availability offers in periodDays. |
SCHEDULING_NODE_HAS_SUCCESSOR | The node has a routing entry. A scheduling node must be the last node. |
SCHEDULING_PARTICIPANT_CALENDAR_NOT_FOUND | A participant has isCalendarConnected: true, but no calendar is connected for that email address. |
SCHEDULING_PARTICIPANT_UNREACHABLE | A participant has no connected calendar and no autoCollectAvailability: true. The message lists the email addresses. |
SCHEDULING_ORGANIZER_NOT_CONNECTED | The organizer's calendar is disconnected. Reconnect it. |
SCHEDULING_BOOKING_URL_TEMPLATE_NOT_FOUND | channel is WHATSAPP and your organisation has no approved WhatsApp booking-link template for language. |
SCHEDULING_ORGANIZATION_NOT_IN_GROUP | Your organisation is not set up for scheduling. Contact support. |
INVALID_OUTREACH_TEMPLATE | openingMessageTemplateId is not a SCHEDULING opening-message template of your organisation for this channel. |
Template variables
Both outreach types parse {{...}} template variables in customTemplatedMessage (and the other interpolated message fields: customTemplatedSubjectLine, criteriaSatisfiedClosingMessage, criteriaNotSatisfiedClosingMessage, candidateNotInterestedClosingMessage, and any per-route closingMessage) and reject unknown variables with INVALID_TEMPLATE_VARIABLE. Variable names are SCREAMING_SNAKE_CASE and must come from the supported set:
| Variable | Available on | Notes |
|---|---|---|
{{CANDIDATE_FIRST_NAME}} | both | Candidate's first name. |
{{ORGANIZATION_NAME}} | both | The organization running the workflow. |
{{AGENT_NAME}} | both | Name of the agent persona handling the outreach. |
{{CAMPAIGN_OWNER_NAME}} | both | The user who owns the underlying campaign. |
{{INTERVIEWER_NAME}} | both | Interviewer for any associated meeting. |
{{MEETING_TITLE}} | both | Title of the associated meeting (when one is configured). |
{{MEETING_URL}} | both | Booking link for the associated meeting. |
{{MEETING_AVAILABILITY_TEXT}} | both | Human-readable availability blob for the meeting. |
{{JOB_APPLICATION_URL}} | both | URL of the underlying job application. |
{{JOB_TITLE}} | screeningOutreach only | Rejected on engagementOutreach (no job-description data). |
{{EMPLOYER_NAME}} | screeningOutreach only | Rejected on engagementOutreach (no job-description data). |
{{LOCATION}} | screeningOutreach only | Rejected on engagementOutreach (no job-description data). |
Using {{JOB_TITLE}}, {{EMPLOYER_NAME}}, or {{LOCATION}} inside an engagementOutreach node will fail publish with INVALID_TEMPLATE_VARIABLE. Unknown placeholders (e.g. {{firstName}}, {{candidate.firstName}}) are rejected on either node type.
Template variables on schedulingOutreach
schedulingOutreachThe table above applies to the two conversational outreach types. A schedulingOutreach node has two kinds of message, and each kind accepts a different set of variables:
- Candidate messages:
customTemplatedMessageandcustomTemplatedSubjectLine. Popp sends these to the candidate. - Availability messages:
availabilityOutreachSettings.customTemplatedMessageandavailabilityOutreachSettings.customTemplatedSubjectLine, on the node and on each participant. Popp sends these to participants withautoCollectAvailability: true.
| Variable | Candidate messages | Availability messages |
|---|---|---|
{{CANDIDATE_FIRST_NAME}} | yes | yes |
{{CANDIDATE_LAST_NAME}} | yes | yes |
{{ORGANIZATION_NAME}} | yes | yes |
{{AGENT_NAME}} | yes | yes |
{{CAMPAIGN_OWNER_NAME}} | yes | yes |
{{INTERVIEWER_NAME}} | yes | yes |
{{MEETING_TITLE}} | yes | no |
{{MEETING_URL}} | yes (required in customTemplatedMessage) | no |
{{MEETING_AVAILABILITY_TEXT}} | no | yes |
{{JOB_APPLICATION_URL}} | no | no |
{{JOB_TITLE}} | no | no |
{{EMPLOYER_NAME}} | no | no |
{{LOCATION}} | no | no |
A variable marked "no" fails validation with INVALID_TEMPLATE_VARIABLE.
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.
{
"entryPathways": [{ "entrySource": "API", "entrySlug": "screen-availability" }],
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"slug": "screen-availability",
"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.",
"outreachContext": "Collecting outstanding compliance documents from candidates who have accepted a role but are not yet cleared to work.",
"questions": [
{
"slug": "docs-ready",
"content": "Do you have your compliance documents ready to upload?",
"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 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.",
"outreachContext": "Confirming shift preferences with RGN candidates who have already passed screening, before the bookings team calls.",
"questions": [
{
"slug": "confirm-region",
"content": "Which regions are you able to travel to for shifts?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
}
}
}screen-availability only routes onComplete here — opting out, signalling not interested, or delivery failure terminates the run cleanly. Example 2 shows what to do if you want each of those exit events to land in its own follow-up.
Example 2 — Branching journey with per-outcome fan-out
Cedar Health Recruitment wants every conversational outcome to land somewhere useful: candidates who say they're not interested go onto a nurture list, candidates who don't reply over SMS get retried over email, region-specific candidates land on a region-specific compliance pack, and everyone else goes through the standard one. This example shows three things at once:
- Multiple routing events on a single screening node, each connected to a different downstream node. The screening's
onComplete,onNotInterested, andonDeliveryFailedevents fan out to three different engagement journeys. - In-conversation pathways via answer routes. The
nmc-pinquestion branches mid-conversation: candidates without an active PIN are asked a follow-up about whether they're in the process of registering, and that follow-up can end the conversation early viaROUTE_TOpaired withCLOSE_CONVERSATION. - Content-based branching post-screening. A
conditionnode routes London candidates to a London-specific compliance pack, and everyone else to the standard one.
{
"entryPathways": [{ "entrySource": "API", "entrySlug": "screen-availability" }],
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"slug": "screen-availability",
"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.",
"outreachContext": "Collecting London-specific compliance documents from candidates placed on London bookings.",
"questions": [
{
"slug": "london-docs-ready",
"content": "Do you have your London compliance documents ready to upload?",
"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": "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.",
"outreachContext": "Collecting outstanding compliance documents from candidates who have accepted a role but are not yet cleared to work.",
"questions": [
{
"slug": "docs-ready",
"content": "Do you have your compliance documents ready to upload?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"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.",
"outreachContext": "Keeping in touch with RGN candidates who are not ready to pick up shifts yet, so they can re-engage when the timing suits them.",
"questions": [
{
"slug": "nurture-timing",
"content": "Roughly when would you like us to check back in with you?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"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.",
"outreachContext": "Re-reaching RGN candidates over email after SMS delivery failed, to confirm whether they are still interested.",
"questions": [
{
"slug": "fallback-interest",
"content": "Are you still interested in RGN shifts with Cedar Health?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
}
}
}Walking through the journey:
- A candidate enters at
screen-availability. - If they say they don't have an NMC PIN, the answer route on
nmc-pinskips them straight tointent-to-register. If they then say they're not even registering, the answer route closes the conversation withstatus: "FAILED_CRITERIA"and usesROUTE_TOto send the run directly toengagement-nurture— overriding the node's defaultonCompletetarget. The conversation engine still emitsonNotInterestedautomatically for candidates whose tone signals disinterest more generically (e.g. "not for me thanks"), and that path also lands inengagement-nurturevia the node'sroutingmap. - If they say they have a PIN (or that they're mid-registration), they continue through
regionandshift-availabilityand the conversation completes normally —onCompletefires and the run lands inbranch-on-region. branch-on-regionchecks whether the candidate's region answer mentions London and routes to eitherengagement-london-complianceorengagement-compliance-docs.- If the SMS never delivered (e.g. invalid mobile number),
onDeliveryFailedroutes the run toengagement-email-fallbackinstead, retrying via email. - Opting out (
onOptedOut) is intentionally unrouted, so the run terminates cleanly with no further messages.
Example 3 — Question shapes by example
Four valid question fragments — one per shape from the Question shapes table — followed by common rejection examples and the error code each produces. Fragments are shown in isolation; drop them into a node's config.questions[] array.
Yes/no question with expectedAnswer: IS_YES (knockout)
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered with an active PIN?",
"questionType": "TEXT",
"expectedAnswer": "IS_YES",
"strictness": "STRICT",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CONTINUE", "slug": "region" }] },
{ "when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}Open-ended (no scoring) question — record-and-continue
{
"slug": "current-shifts",
"content": "What does your current week look like — which shifts are you working now?",
"questionType": "TEXT",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "shift-availability" }] }
]
}For keyword-based branching, swap the sole catch-all for one or more answerText mentions/does_not_mention routes followed by a trailing answerState=any catch-all (see Question shapes).
Open-ended scoring question with preferredAnswerText and score routes
{
"slug": "shift-availability",
"content": "How many shifts per week are you looking to pick up over the next month, and which days work best for you?",
"questionType": "TEXT",
"preferredAnswerText": "Four or more shifts per week, including at least one weekend day, with availability for early or late starts.",
"answerRoutes": [
{ "when": { "field": "score", "op": "gte", "value": 70 },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] },
{ "when": { "field": "score", "op": "lt", "value": 70 },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}Document question with documentTypes and document-state routes
{
"slug": "right-to-work",
"content": "Please attach a clear photo of your right-to-work documentation (passport, BRP, or share code letter).",
"questionType": "DOCUMENT",
"documentTypes": ["passport", "biometric-residence-permit", "share-code-letter"],
"answerRoutes": [
{ "when": { "field": "documentState", "op": "eq", "value": "uploaded" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] },
{ "when": { "field": "documentState", "op": "eq", "value": "not_uploaded" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}Common rejections
These fragments publish-fail with the indicated error code.
expectedAnswer combined with a scoring rubric — fires PREFERRED_ANSWER_ON_YES_NO_QUESTION on path: ...questions[0].preferredAnswerText (and MISSING_FALLBACK_ROUTE on the routes, since expectedAnswer declares binary intent and a sole answerState=any catch-all is not a complete yes/no pair). Yes/no shape (expectedAnswer set) is mutually exclusive with scoring (preferredAnswerText set):
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered?",
"expectedAnswer": "IS_YES",
"preferredAnswerText": "Active PIN holder for at least 12 months",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}Document question with an answerState route — fires INCOMPATIBLE_ROUTE_FOR_QUESTION_TYPE on the route (and MISSING_FALLBACK_ROUTE, since questionType: DOCUMENT declares binary intent and the fan-out is not a complete documentState=uploaded + =not_uploaded pair):
{
"slug": "right-to-work",
"content": "Attach your right-to-work documentation.",
"questionType": "DOCUMENT",
"documentTypes": ["passport"],
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] }
]
}Non-canonical operator on an answer-route family field — fires INVALID_OPERATOR_FOR_FIELD on the route's when:
{
"when": { "field": "answerState", "op": "neq", "value": "yes" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }]
}The fix is to express the inverse with the canonical operator — answerState eq no — or, if you need richer logic across multiple leaves, lift the comparison into a condition node where the full DSL is available.
Example 4 — ATS write-back, mid-workflow and as a final step
Northgate Logistics feeds two driver vacancies into one workflow from their ATS. Applicants who finish the screen are moved along that job's pipeline and then nudged about induction dates; applicants who decline are rejected in the ATS with a withdrawal reason and the run ends there.
Both operations appear, and both positions: advance-in-ats sits mid-workflow and routes onward, withdraw-in-ats is terminal.
{
"entryPathways": [
{
"entrySource": "ATS",
"entrySlug": "screen-driver-eligibility",
"atsJobIds": ["job-hgv-class-1", "job-hgv-class-2"]
}
],
"nodes": {
"screen-driver-eligibility": {
"id": "screen-driver-eligibility",
"type": "screeningOutreach",
"slug": "screen-driver-eligibility",
"label": "Driver licence and CPC screen",
"routing": {
"onComplete": "advance-in-ats",
"onNotInterested": "withdraw-in-ats"
},
"config": {
"agentName": "Northgate Recruitment",
"agentTone": "PROFESSIONAL",
"title": "Driver licence and CPC screen",
"channel": "SMS",
"employerName": "Northgate Logistics",
"roleTitle": "HGV Driver",
"location": "Leeds, UK",
"outreachContext": "Screening HGV Class 1 and Class 2 driver applicants for licence category and a valid Driver CPC card before a recruiter call.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for applying to Northgate Logistics. Two quick questions to confirm your licence details. Reply STOP to opt out.",
"questions": [
{
"slug": "licence-category",
"content": "Which HGV licence categories do you currently hold?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CONTINUE", "slug": "cpc-card" }]
}
]
},
{
"slug": "cpc-card",
"content": "Is your Driver CPC card in date?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"advance-in-ats": {
"id": "advance-in-ats",
"type": "updateAtsApplication",
"slug": "advance_in_ats",
"label": "Move to telephone interview",
"routing": { "onComplete": "invite-to-induction" },
"config": {
"operation": "moveStage",
"targets": [
{
"atsJobId": "job-hgv-class-1",
"atsInterviewStageId": "stage-c1-telephone-interview"
},
{
"atsJobId": "job-hgv-class-2",
"atsInterviewStageId": "stage-c2-telephone-interview"
}
]
}
},
"invite-to-induction": {
"id": "invite-to-induction",
"type": "engagementOutreach",
"label": "Invite to induction",
"routing": {},
"config": {
"agentName": "Northgate Recruitment",
"agentTone": "PROFESSIONAL",
"title": "Invite to induction",
"channel": "SMS",
"outreachContext": "Confirming induction attendance for screened HGV driver applicants who have already passed the licence and CPC screen.",
"customTemplatedMessage": "Thanks {{CANDIDATE_FIRST_NAME}} — your details are confirmed and a recruiter will call you to arrange a telephone interview. Induction dates run every other Monday. Reply STOP to opt out.",
"questions": [
{
"slug": "induction-monday",
"content": "Are you able to attend an induction on a Monday?",
"answerRoutes": [
{
"when": { "field": "answerState", "op": "eq", "value": "any" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }]
}
]
}
]
}
},
"withdraw-in-ats": {
"id": "withdraw-in-ats",
"type": "updateAtsApplication",
"slug": "withdraw_in_ats",
"label": "Reject — candidate withdrew",
"routing": {},
"config": {
"operation": "reject",
"targets": [
{
"atsJobId": "job-hgv-class-1",
"atsRejectedReasonId": "reason-candidate-withdrew"
},
{
"atsJobId": "job-hgv-class-2",
"atsRejectedReasonId": "reason-candidate-withdrew"
}
]
}
}
}
}Why this configuration validates:
- Two jobs on one ATS pathway. Listing both
atsJobIds on the single ATS pathway is how two vacancies feed one workflow. - Both ATS nodes carry both jobs. Each is reachable from
screen-driver-eligibility, which both jobs enter, so each needs a target per job. Dropping either entry givesMISSING_ATS_JOB_MAPPING; adding a third job that no pathway binds givesUNEXPECTED_ATS_JOB_MAPPING. - Stage ids differ per job, the reason id does not. Pipelines are job-scoped, so
job-hgv-class-1andjob-hgv-class-2have different telephone-interview stages. Rejection reasons are organisation-scoped, so the samereason-candidate-withdrewis correct on both entries. withdraw-in-atsroutes nothing. AnupdateAtsApplicationnode with an emptyroutingmap is a valid final step; the run completes once the ATS write lands.
If a candidate were started on this workflow through POST /v1/workflows/{id}/runs instead of arriving from the ATS, they would reach advance-in-ats with no ATS application — the node is skipped, invite-to-induction still runs, and the node outcome reports decision: "skipped_no_ats_application".
Example 5 — Add to job
As a final step, handing a screened applicant to a second job
Applicants to the agency's Bank RGN — Spring intake job enter the workflow, are screened, and the ones who reach the end are added to the long-term RGN bank job at the Screened by Popp stage. The entry job and the destination job are different, which is what makes this publishable — see the rejection below.
{
"entryPathways": [
{ "entrySource": "ATS", "entrySlug": "screen-availability", "atsJobIds": ["job_spring_intake"] }
],
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"slug": "screen-availability",
"label": "RGN availability screen",
"routing": { "onComplete": "add-to-bank-job" },
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "RGN — availability screen",
"channel": "SMS",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts",
"outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — a couple of quick questions about your availability. Reply STOP to opt out.",
"questions": [
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered with an active PIN?",
"expectedAnswer": "IS_YES",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] },
{ "when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}
]
}
},
"add-to-bank-job": {
"id": "add-to-bank-job",
"type": "createAtsApplication",
"label": "Add to the RGN bank job",
"routing": {},
"config": {
"atsJobId": "job_8f21c0",
"atsStageId": "stage_screened_by_popp"
}
}
}
}routing: {} ends the run at the node. The run completes as soon as the ATS answers.
onComplete on the screening node fires whether the screen passed or failed, so this graph adds everyone it screens. To add only the candidates who passed, route the screening node into a condition node that branches on answers.nmc-pin.answerState and send only the matching branch to add-to-bank-job.
Mid-workflow, with a follow-up message
The same node with its exit wired. The candidate is added, then told so.
"add-to-bank-job": {
"id": "add-to-bank-job",
"type": "createAtsApplication",
"label": "Add to the RGN bank job",
"routing": { "onComplete": "confirm-added" },
"config": {
"atsJobId": "job_8f21c0",
"atsStageId": "stage_screened_by_popp"
}
}onComplete fires on both endings — the candidate was added, or was already on the job — so confirm-added runs either way. If the difference matters to you, read outcome.decision on the WORKFLOW_NODE_COMPLETED event rather than trying to branch on it inside the graph.
A rejection: the destination is the workflow's own trigger
{
"entryPathways": [
{ "entrySource": "ATS", "entrySlug": "add-to-bank-job", "atsJobIds": ["job_8f21c0"] }
],
"nodes": {
"add-to-bank-job": {
"id": "add-to-bank-job",
"slug": "add-to-bank-job",
"type": "createAtsApplication",
"routing": {},
"config": { "atsJobId": "job_8f21c0", "atsStageId": "stage_screened_by_popp" }
}
}
}Rejected with ATS_DESTINATION_IS_ENTRY_JOB: applicants to job_8f21c0 enter this workflow, and the node adds them back to job_8f21c0, so the workflow would feed itself. Point the node at the job you are handing candidates on to, not the one they came from. This rule keys on the entry pathways alone, not on which branch reaches the node, so it fires even where the node sits behind a condition that a given run never takes.
A rejection: a disallowed entry source reaches the node
{
"entryPathways": [
{ "entrySource": "ATS", "entrySlug": "screen-availability", "atsJobIds": ["job_spring_intake"] },
{ "entrySource": "API", "entrySlug": "screen-availability" }
],
"nodes": {
"screen-availability": { "id": "screen-availability", "slug": "screen-availability", "type": "screeningOutreach", "routing": { "onComplete": "add-to-bank-job" }, "config": { "…": "…" } },
"add-to-bank-job": {
"id": "add-to-bank-job",
"type": "createAtsApplication",
"routing": {},
"config": { "atsJobId": "job_8f21c0", "atsStageId": "stage_screened_by_popp" }
}
}
}Rejected with CREATE_ATS_APPLICATION_UNSUPPORTED_ENTRY: the ATS pathway is fine, but the API pathway lands on the same node and walks the same route into add-to-bank-job. The message names the node and every offending source, here 'API'.
The fix is to give the API pathway its own branch that does not reach the node — the rule is per-node reachability, so an API pathway elsewhere in the same workflow is not a problem. Remember this refusal arrives on POST /v1/workflows and PATCH /v1/workflows/{id} as well as on publish.
Example 6 — Screen, then book an interview
Candidates enter through the API and answer one screening question. After the screen, each candidate gets a link to book a 30-minute call. The call is a COLLECTIVE meeting with two participants:
- Priya Shah is the organizer and has a connected calendar.
- Tom Hughes has no connected calendar and gives availability by email.
{
"entryPathways": [
{ "entrySource": "API", "entrySlug": "screen-availability" }
],
"nodes": {
"screen-availability": {
"id": "screen-availability",
"type": "screeningOutreach",
"slug": "screen-availability",
"label": "RGN availability screen",
"routing": { "onComplete": "book-interview" },
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"title": "RGN — availability screen",
"channel": "SMS",
"employerName": "Cedar Health Recruitment",
"roleTitle": "Registered General Nurse (Band 5)",
"location": "Various UK NHS trusts",
"outreachContext": "Cedar Health places Band 5 RGNs into NHS trust bank-shifts.",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, this is Cedar Health Recruitment — a couple of quick questions about your availability. Reply STOP to opt out.",
"questions": [
{
"slug": "nmc-pin",
"content": "Are you currently NMC-registered with an active PIN?",
"expectedAnswer": "IS_YES",
"answerRoutes": [
{ "when": { "field": "answerState", "op": "eq", "value": "yes" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "PASSED_CRITERIA" }] },
{ "when": { "field": "answerState", "op": "eq", "value": "no" },
"actions": [{ "type": "CLOSE_CONVERSATION", "status": "FAILED_CRITERIA" }] }
]
}
]
}
},
"book-interview": {
"id": "book-interview",
"type": "schedulingOutreach",
"label": "Book a screening call",
"config": {
"agentName": "Cedar Health Recruiter",
"agentTone": "PROFESSIONAL",
"channel": "SMS",
"customTemplatedMessage": "Hi {{CANDIDATE_FIRST_NAME}}, thanks for your answers. Book a 30-minute call with our team here: {{MEETING_URL}} Reply STOP to opt out.",
"timeToAutoCloseConversationsInHours": 72,
"title": "RGN screening call",
"description": "A short call about your registration, shift pattern and preferred trusts.",
"durationMinutes": 30,
"timezone": "Europe/London",
"availability": {
"1": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"2": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"3": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"4": [{ "start": "09:00", "end": "12:00" }, { "start": "13:00", "end": "17:00" }],
"5": [{ "start": "09:00", "end": "12:00" }]
},
"noticePeriodMinutes": 1440,
"availableDaysInTheFuture": 14,
"buffer": 15,
"videoConference": true,
"videoConferencingProvider": "Microsoft Teams",
"availabilityMethod": "COLLECTIVE",
"participants": [
{
"email": "[email protected]",
"name": "Priya Shah",
"isOrganizer": true,
"isCalendarConnected": true
},
{
"email": "[email protected]",
"name": "Tom Hughes",
"isCalendarConnected": false,
"autoCollectAvailability": true
}
],
"availabilityOutreachSettings": { "requiredHours": 4, "periodDays": 14 }
}
}
}
}The configuration gives these results:
book-interviewhas norouting, so it is the last node.- A candidate can book a weekday slot inside
availability, from 24 hours after booking up to 14 days ahead, when both Priya and Tom are free. - The conversation closes after 72 hours of inactivity.
onComplete on the screening node fires whether the screen passed or failed, so this graph offers a call to every candidate it screens. To offer a call only to the candidates who passed, route the screening node into a condition node. Branch on answers.nmc-pin.answerState, and send only the matching branch to book-interview.
Conversation Outcomes in Workflow Webhooks
When a workflow node's conversation completes, the webhook payload carries a workflow-domain outcome on the NodeOutcome (outcome.kind: "conversation" → outcome.status). This enum is intentionally separate from the Conversations API's conversationStatus field — the workflow domain owns its own outcome contract so screening-themed labels do not leak into webhook consumers.
| Workflow outreach outcome | Meaning |
|---|---|
PASSED_CRITERIA | The candidate met the screening criteria the conversation was designed to test for. |
FAILED_CRITERIA | The candidate did not meet the screening criteria. |
COMPLETED | The conversation ran its course or was closed without a specific pass/fail signal. |
NOT_INTERESTED | The candidate explicitly declined. |
OPTED_OUT | The candidate opted out (e.g. SMS STOP). |
DELIVERY_FAILED | Delivery failed across the available channels. |
A schedulingOutreach node reports its own outcome: meeting when the candidate books, and empty when the node ends any other way. See Workflow Events.
If you need to read the underlying conversation directly, fetch it via the Conversations API using the conversationId carried on the NodeOutcome. The Conversations API uses its own public status enum (COMPLETED_SCREENING_PASSED, CLOSED, COMPLETED_SCREENING_FAILED, OPTED_OUT, etc.) which is documented separately on the Conversation schema.
Next Steps
- Workflow Events — webhook payloads emitted as runs progress.
- Webhook Authentication — verify the
x-signatureheader on incoming events.
Updated 1 day ago
