Journey (Flow) — Authoring Specification
Purpose of this document. This is a complete, self-contained reference for the JSON structure of a Journey (internally called a Flow). Hand this file to any LLM (Claude, GPT, Gemini, Grok, …) together with a plain-language description of the automation you want, and it should be able to produce a valid Journey JSON that can be imported into the platform via Journeys → Import.
Companion document: AI_AGENT_PIPELINE_SPEC.md (the "AI Agent" / AI Pipeline system). The two systems share an almost identical JSON envelope but different node catalogs. See "Journey vs AI Agent" below to pick the right one.
1. What a Journey is
A Journey is a directed graph of nodes that scripts a conversation on a channel (WhatsApp, Instagram, or Web Chat). It is designed for deterministic, menu- and rule-driven automation: show buttons/menus, capture free-text input, branch on conditions, call APIs, send media, and hand off to humans. AI is available too (llm, knowledge_retrieval) but Journeys are the "scripted" sibling of AI Agents.
Execution model (this shapes how you design the graph):
The engine keeps a session per contact+chat with a current node pointer. It is a stateful walk, unlike the AI Agent's stateless-per-message pass.
A Journey is triggered by: a keyword the customer types (
keywordTriggers), being the auto-reply Journey (isAutoReply), an Instagram comment (commentTriggers), or a manual trigger from the chat UI.On trigger, the engine runs from the entry node, following transitions, executing nodes until it reaches a node that waits for the customer (buttons, list,
wait_input,wait_followup) or anendnode.When the customer responds, the engine resumes from the parked node and routes based on what they did (button id, list row id, or free text).
State is stored in the session context (a key→value map). Nodes read variables via {{variable}} interpolation and write to named output variables; context persists for the session.
Journey vs AI Agent — which to use?
Journey (Flow)AI Agent (AI Pipeline)Primary useDeterministic scripted menus & rulesAI-driven: LLM replies, RAG, intent routingWaiting for userButtons, lists, and wait_input (free-text) + wait_followup (timed nudge)Interactive buttons/list onlyTriggersKeywords, auto-reply, IG comments, manualRuns as the channel's AI auto-responderImport formatorquesta-flow-v1orquesta-pipeline-v1DB tablesFlow / FlowNodeAiPipeline / AiPipelineNode
A Journey and an AI Agent can call each other (Journey execute_flow → another Journey; AI Agent execute_flow → a Journey).
2. Top-level JSON structure (the import file)
Exactly what the export endpoint produces and the import endpoint accepts.
{
"_format": "orquesta-flow-v1",
"name": "Main Menu",
"description": "Greets the customer and shows the main menu",
"channel": "WHATSAPP",
"isAutoReply": false,
"keywordTriggers": ["menu", "start", "hi"],
"commentTriggers": null,
"sessionTimeoutMinutes": null,
"entryNodeId": "welcome",
"nodes": [
{ "id": "welcome", "type": "interactive", "name": "Welcome", "content": { /* … */ }, "positionX": 0, "positionY": 0, "transitions": { /* … */ } }
],
"exportedAt": "2026-07-07T00:00:00.000Z"
}
Top-level fields
FieldTypeRequiredNotes_formatstringYesMust be exactly "orquesta-flow-v1". Import rejects anything else.namestringYesJourney name. On import it is suffixed with (imported).descriptionstring | nullNoFreeform.channelenumNo (default WHATSAPP)WHATSAPP, INSTAGRAM, or WEBCHAT. Governs valid node types (see §7).isAutoReplybooleanNoWhether this is the global auto-reply Journey. Import always forces this to false — set it in the UI afterward.keywordTriggersstring[] | nullNoLowercased full-message keywords that trigger the Journey. Import always resets this to [] — set triggers in the UI afterward (keywords must be unique across Journeys).commentTriggersobject | nullNoInstagram only: { keywords: string[], mediaIds: string[], triggerOnFirstComment: boolean }. Reset on import.sessionTimeoutMinutesint | nullNoPer-Journey session timeout override (minutes); null = org default.entryNodeIdstringYes (effectively)id of the first node. If unresolved, the first node in nodes[] is used as a fallback.nodesarrayYesArray of node objects (§3).exportedAtstring (ISO)NoIgnored on import.
On import, every node
idis regenerated and all references (entryNodeId,transitions) are remapped automatically — including transition values shaped as{ targetNodeId: … }. So when authoring by hand, use any unique string ids (readable slugs recommended). They must be unique within the file and referenced consistently.
3. The node object
Every element of nodes[] (matches FlowNode):
{
"id": "unique_node_id",
"type": "interactive",
"name": "Optional label",
"content": { },
"positionX": 0,
"positionY": 0,
"transitions": { "btn_1": "next_node", "default": "fallback_node" }
}
FieldTypeRequiredNotesidstringYesUnique within the file.typestringYesOne of §6. Must match the channel (§7).namestring | nullNoDisplay label only.contentobjectYesType-specific configuration (§6). May be {} for trivial nodes (e.g. end with no message).positionX / positionYnumberNo (default 0)Canvas coordinates for the editor. Lay out left→right (~300px per step). Cosmetic.transitionsobject | nullNobranch key → target-node-id map (§4).
4. Transitions — how nodes connect (READ THIS CAREFULLY)
transitions maps a branch key to a target node id. The key depends on node type.
4.1 Linear nodes — default
Nodes with a single onward path (text, send_*, http_request, set_variable, delay, template, perform_action success path, etc.) follow transitions.default. Missing/empty default → the walk stops (valid ending).
"transitions": { "default": "next_node_id" }
4.2 Button nodes — one key per button id, plus default
interactive, multi_interactive (WhatsApp), web_button (Web Chat), ig_quick_reply, ig_button_template (Instagram): after sending, the walk parks until the customer taps a button. Route by button id, with default fallback.
"content": { "body": "Menu:", "buttons": [ { "id": "sales", "title": "Sales" }, { "id": "support", "title": "Support" } ] },
"transitions": { "sales": "sales_node", "support": "support_node", "default": "reprompt" }
4.3 List nodes — one key per row id, plus default
interactive_list (WhatsApp), web_list (Web Chat): route by the selected row id.
"content": { "body": "Pick:", "buttonText": "Menu", "sections": [ { "title": "T", "rows": [ { "id": "r1", "title": "Row 1" } ] } ] },
"transitions": { "r1": "node_for_r1", "default": "fallback" }
4.4 wait_input — captures free text, then default
Sends a prompt, parks, and stores the customer's typed reply into outputVariable. Then continues via default.
"content": { "prompt": "What's your email?", "outputVariable": "email" },
"transitions": { "default": "next_node" }
4.5 condition — one key per condition id, plus default
Same pattern as the AI Agent. Each content.conditions[] entry has an id used as the transition key (note: the Journey condition content also carries a targetNodeId per condition, but the canonical wiring is via transitions). First match wins; else default.
"content": { "conditions": [ { "id": "c1", "expression": "{{tier}} == \"vip\"", "targetNodeId": "vip" } ], "defaultTargetNodeId": "normal" },
"transitions": { "c1": "vip", "default": "normal" }
4.6 wait_followup — replied and timeout
Sends a message and waits N minutes (durable, not capped). Two branches:
replied→ the customer answered within the window.timeout→ nobody answered; the follow-up fires.
"content": { "message": "Still there? Reply to continue.", "minutesType": "fixed", "minutes": 60 },
"transitions": { "replied": "resume_node", "timeout": "nudge_node" }
4.7 safe_loop — body (loop) and default (after)
Iterates; wire the loop body to run per item and transition back to the loop node, with default for after the loop.
4.8 perform_action — success and failure
"transitions": { "success": "ok_node", "failure": "err_node" }
4.9 Terminal nodes
end (stops the Journey, optional goodbye message), handoff (human takeover), and execute_flow (hands off to another Journey) don't need outgoing transitions.
5. Variables, interpolation & expressions
Identical engine to the AI Agent (both share flow-engine/variables.ts).
5.1 Interpolation — {{ … }}
Simple:
{{email}}; dot:{{order.total}}; bracket:{{resp["data"][0]["name"]}}; nested (innermost-first):{{pricing["{{kecamatan}}"]}}.JSON-string variables are auto-parsed before accessor traversal. Missing → empty string.
5.2 Built-in / common variables
Unlike the AI Agent, a Journey does not seed a fixed set of message variables at start; context is built up as the customer progresses (each wait_input outputVariable, http_requestoutputVariable, set_variable, etc.). Notable engine-provided values:
Inside
http_request(and available generally once set), the engine injects contact helpers:{{name}}(contact name / waId),{{phone}}(waId),{{waId}}.Whatever you capture (e.g.
{{email}},{{user_input}}) is reusable downstream for the session.
5.3 Condition expressions
Single comparison: <left> <op> <right>, operators == != === !== > < >= <=. Operands may be {{variables}}, quoted strings, numbers, or booleans. Examples: {{choice}} == "yes", {{amount}} >= 100000, {{opted_in}} == true.
5.4 set_variable / code expressions (restricted evaluator)
Supports number/JSON literals, variable & property access, a few string methods (toUpperCase/toLowerCase/trim/length/toString), array .length, JSON.stringify/parse, Math.abs/round/floor/ceil/min/max/pow/sqrt, and a single binary arithmetic op (+ - * /). Seed lookup tables with a JSON literal in a set_variable expression, then index them: set_variable pricing = {"coblong":25000,"sukajadi":30000} → later {{pricing[flow_kecamatan]}}.
6. Node type catalog
Required fields are bold; ? marks optional. String fields support {{variable}} unless noted. The catalog is grouped by category; availability per channel is summarized in §7.
6.1 Messaging — WhatsApp
text
"content": { "message": "Hello {{name}}!" }
messagerequired. Transitions:default.
interactive
Reply buttons (max 3), then wait. See §4.2.
"content": { "header": "", "body": "How can I help?", "footer": "", "buttons": [ { "id": "btn_1", "title": "Help" } ] }
body,buttons[]({ id, title }, title ≤ 20 chars). Transitions: one per button id +default.Optional no-response follow-up (see §6.8): add
followUpEnabled,followUpMinutes,followUpMessage, and atimeouttransition.
multi_interactive
Sequence of button messages, then wait for any tap. See §4.2.
"content": { "messages": [ { "body": "Page 1", "buttons": [ {"id":"btn_1","title":"A"} ] }, { "body": "Page 2", "buttons": [ {"id":"btn_4","title":"B"} ] } ] }
Transitions: one key per button id across all messages +
default.
interactive_list
List menu, then wait for a row. See §4.3.
"content": { "header": "", "body": "Choose:", "footer": "", "buttonText": "Menu", "sections": [ { "title": "Options", "rows": [ { "id": "row_1", "title": "Option 1", "description": "" } ] } ] }
body,buttonText,sections[]. Transitions: one per row id +default.
template
Send an approved WhatsApp template.
"content": { "templateName": "order_update", "templateLanguage": "en", "variables": { "1": "{{order_id}}" } }
templateName,templateLanguagerequired.variablesmap template params → values. Transitions:default.
send_whatsapp_native_flow
Send a WhatsApp Flow form.
"content": { "nativeFlowId": "<id>", "bodyText": "Please complete", "ctaText": "Open", "headerText": "", "footerText": "", "startScreenId": "", "flowAction": "navigate", "prefill": [ { "key": "name", "value": "{{name}}" } ] }
nativeFlowIdrequired.flowAction:navigate|data_exchange. Transitions:default.
Media: send_image, send_video, send_audio, send_document
"content": { "sourceType": "url", "url": "https://…", "caption": "Optional" }
sourceTypeurl|upload; forurlseturl.send_documentaddsfileName;send_audiohas no caption. Transitions:default.
send_location
"content": { "latitude": "-6.2", "longitude": "106.8", "name": "Office", "address": "Jakarta" }
latitude,longituderequired. Transitions:default.
send_contacts
"content": { "contacts": [ { "name": { "formatted_name": "John Doe" }, "phones": [ { "phone": "+62…", "type": "CELL" } ] } ] }
Each contact requires
name.formatted_name. Transitions:default.
send_cta_url
"content": { "headerType": "none", "body": "Learn more:", "footer": "", "buttonText": "Open", "buttonUrl": "https://example.com" }
body,buttonText(≤20 chars),buttonUrl.headerType:none|text|image|document. Transitions:default.
6.2 Messaging — Instagram
ig_text
"content": { "message": "Hi! How can I help?" }
messagerequired. Transitions:default.
ig_quick_reply
Quick-reply buttons, then wait.
"content": { "text": "Choose:", "quickReplies": [ { "content_type": "text", "title": "Help", "payload": "qr_help" } ] }
Route by each
payload+default.
ig_generic_template
Carousel of cards.
"content": { "elements": [ { "title": "Product", "subtitle": "Desc", "image_url": "https://…", "buttons": [ { "type": "postback", "title": "Buy", "payload": "buy_1" } ] } ] }
Buttons:
typepostback(route bypayload) orweb_url(needsurl). Transitions: per payload +default.
ig_button_template
Text with buttons.
"content": { "text": "What next?", "buttons": [ { "type": "postback", "title": "Help", "payload": "btn_help" } ] }
ig_media
"content": { "mediaType": "image", "mediaUrl": "https://…" }
mediaType:image|video|audio. Transitions:default.
ig_like_heart
React with a heart. content: {}. Transitions: default.
6.3 Messaging — Web Chat
Use these when channel is WEBCHAT (instead of the WhatsApp/IG message nodes):
web_text—{ "message": "…" }.web_button— same shape asinteractive(buttons; waits).web_list— same shape asinteractive_list(list; waits).
6.4 Logic & control
wait_input
Capture free text. See §4.4.
"content": { "prompt": "Please enter your response:", "outputVariable": "user_input" }
promptrequired;outputVariablestores the typed reply. Supports follow-up config (§6.8). Transitions:default.
condition
Branch on expressions. See §4.5.
"content": { "conditions": [ { "id": "c1", "expression": "{{x}} == \"y\"", "targetNodeId": "yes" } ], "defaultTargetNodeId": "no" }
Transitions: per condition id +
default.
set_variable
Two accepted shapes:
// Preferred (multi-assignment)
"content": { "assignments": [ { "variableName": "greeting", "valueType": "static", "value": "Hi {{name}}" } ] }
// Legacy flat (single)
"content": { "variableName": "total", "value": "{{price}} * {{qty}}", "isExpression": true }
valueType:static(interpolate) orexpression(restricted evaluator). Transitions:default.
delay
"content": { "delayType": "fixed", "delaySeconds": 5, "maxDelaySeconds": 300 }
delayType:fixed(usedelaySeconds) orvariable(usedelayVariable). Capped at 300s. (The editor may also write{ duration, unit }.) Transitions:default.
wait_followup
Timed no-response nudge. See §4.6.
"content": { "message": "Still there?", "minutesType": "fixed", "minutes": 60 }
minutesType:fixed(useminutes) orvariable(useminutesVariable). Transitions:replied+timeout.
safe_loop
"content": { "loopType": "array", "arrayVariable": "items", "itemVariable": "item", "indexVariable": "i", "maxIterations": 100 }
loopType:static(usestaticCount) |array(usearrayVariable) |while(usewhileCondition). Transitions:body+default. Loop body ends by transitioning back to this node.
end
Stops the Journey; optional goodbye.
"content": { "message": "Thanks! 👋" }
messageoptional. Transitions: none.
6.5 Integration & AI
http_request
"content": { "method": "POST", "url": "https://api…/{{waId}}", "headers": [ { "key": "Authorization", "value": "Bearer {{token}}" } ], "params": [], "bodyType": "json", "body": "{ \"q\": \"{{user_input}}\" }", "timeout": 10000, "outputVariable": "api_result" }
method,url.bodyType:none|json|form-data|raw.outputVariablestores the parsed response →{{api_result["field"]}}. Transitions:default.
code
"content": { "expression": "{{price}} * {{qty}}", "outputVariable": "total" }
Restricted evaluator (§5.4). Transitions:
default.
llm
Generate text with an LLM.
"content": { "modelId": "<AiModel id from your org's catalog>", "systemPrompt": "You are…", "userPrompt": "{{user_input}}", "temperature": 0.7, "maxTokens": 512, "outputVariable": "ai_reply", "structuredOutput": { "enabled": false, "schema": {}, "outputVariable": "llm_structured" }, "thinking": { "enabled": false, "outputVariable": "llm_thinking" } }
modelId— the model id from the org's AI model catalog (AiModel.id, environment-specific). Use a"<MODEL_ID>"placeholder when authoring blind and have the user pick it in the editor.userPromptrequired;systemPromptoptional; both interpolate.outputVariablestores the reply.structuredOutput/thinkingas in the AI Agent spec. Transitions:default.
knowledge_retrieval
"content": { "datasetIds": ["<dataset id>"], "queryVariable": "user_input", "topK": 5, "scoreThreshold": 0.5, "outputVariable": "knowledge_results" }
Stores a JSON array of matches → typically fed into an
llmnode's prompt. Transitions:default.
6.6 Actions & handoff
perform_action
Side-effecting actions, then branch success/failure (see §4.8). Same action set and fields as the AI Agent spec: contact_field_set|contact_field_delete|contact_field_read|segment_add|end_session|ticket_create|ticket_modify|ticket_read, supports multi-step actions[] and errorMode (fail_fast|continue).
"content": { "action": "contact_field_set", "fieldLabel": "email", "fieldValue": "{{email}}", "errorMode": "fail_fast" },
"transitions": { "success": "ok", "failure": "err" }
handoff
Escalate to a human. Terminal.
"content": { "message": "Connecting you with an agent…", "assignmentMode": "auto", "assignToAgentId": "" }
assignmentMode:none|specific|auto. Transitions: none needed.
execute_flow
Hand off to another Journey.
"content": { "selectionMode": "static", "targetFlowId": "<Flow id>", "targetFlowVariable": "", "passContext": false, "unlimitedDepth": false }
selectionMode:static(targetFlowId) |dynamic(targetFlowVariable).passContextcarries variables into the target.unlimitedDepthbypasses the max recursion depth (default 10).
send_email
"content": { "to": "ops@acme.com,{{email}}", "subject": "New lead", "body": "…", "replyTo": "" }
to,subject,bodyrequired; all interpolate. Transitions:default.
6.7 Catalog (WhatsApp only)
Identical to the AI Agent catalog nodes. All take a local catalogId (ProductCatalog.id).
TypeKey content fieldsTransitionssend_single_productcatalogId, productRetailerId, bodyText?, footerText?defaultsend_multi_productcatalogId, headerText, bodyText, sections[] ({title, productRetailerIds[]})defaultsend_catalogcatalogId, bodyText, thumbnailProductRetailerIddefaultsearch_catalog_productcatalogId, searchQuery, searchField (name|retailer_id|category|all), maxResults, outputVariabledefaultget_product_listcatalogId, maxResults, outputVariabledefaultget_single_productcatalogId, productRetailerId, outputVariabledefault
6.8 Optional no-response follow-up (on waiting nodes)
interactive, multi_interactive, interactive_list, and wait_input support an opt-in follow-up nudge if the customer doesn't respond. Add these fields to content and a timeout transition:
"content": {
"body": "Do you need anything else?",
"buttons": [ { "id": "yes", "title": "Yes" }, { "id": "no", "title": "No" } ],
"followUpEnabled": true,
"followUpMinutesType": "fixed",
"followUpMinutes": 30,
"followUpMessage": "Just checking in — are you still there?"
},
"transitions": { "yes": "help", "no": "bye", "default": "bye", "timeout": "nudge_node" }
followUpMinutesType:fixed(usefollowUpMinutes) |variable(usefollowUpMinutesVariable).The follow-up is cancelled automatically once the customer responds. Defaults off.
7. Channel rules (which node types are valid)
WHATSAPP:
interactive,multi_interactive,interactive_list,text,template,send_whatsapp_native_flow,send_image/video/audio/document,send_location,send_contacts,send_cta_url,wait_input,wait_followup,http_request,code,llm,knowledge_retrieval,condition,set_variable,delay,safe_loop,handoff,perform_action,execute_flow,send_email, catalog nodes,end. (WA builders also allow theig_*nodes for dual-channel flows.) Noweb_*nodes.INSTAGRAM:
ig_text,ig_quick_reply,ig_generic_template,ig_button_template,ig_media,ig_like_heart, pluswait_input,http_request,code,condition,set_variable,delay,knowledge_retrieval,handoff,execute_flow,send_email,end. No WhatsApp message/media/catalog/template nodes.WEBCHAT:
web_text,web_button,web_list, pluswait_input,condition,set_variable,delay,wait_followup,safe_loop,http_request,code,perform_action,execute_flow,send_email,llm,knowledge_retrieval,handoff,end. No WhatsApp/IG or catalog nodes.
8. Complete worked example
A WhatsApp menu Journey: greet with buttons → branch to Sales / Support / capture a question and hand off.
{
"_format": "orquesta-flow-v1",
"name": "Main Menu Journey",
"description": "Greeting menu with Sales, Support, and human handoff",
"channel": "WHATSAPP",
"isAutoReply": false,
"keywordTriggers": ["menu", "hi", "start"],
"commentTriggers": null,
"sessionTimeoutMinutes": null,
"entryNodeId": "menu",
"nodes": [
{ "id": "menu", "type": "interactive", "name": "Main menu",
"content": { "header": "Welcome to Acme", "body": "How can we help you today?", "footer": "",
"buttons": [ { "id": "sales", "title": "Talk to Sales" }, { "id": "support", "title": "Get Support" }, { "id": "other", "title": "Something else" } ] },
"positionX": 0, "positionY": 0,
"transitions": { "sales": "sales_msg", "support": "support_msg", "other": "ask_question", "default": "ask_question" } },
{ "id": "sales_msg", "type": "text", "name": "Sales info",
"content": { "message": "Great! Our sales team will reach out. Anything specific you're interested in?" },
"positionX": 300, "positionY": -150, "transitions": { "default": "handoff" } },
{ "id": "support_msg", "type": "text", "name": "Support info",
"content": { "message": "No problem — let's get you help." },
"positionX": 300, "positionY": 0, "transitions": { "default": "ask_question" } },
{ "id": "ask_question", "type": "wait_input", "name": "Capture question",
"content": { "prompt": "Please describe your question in a message.", "outputVariable": "question" },
"positionX": 600, "positionY": 0, "transitions": { "default": "make_ticket" } },
{ "id": "make_ticket", "type": "perform_action", "name": "Create ticket",
"content": { "action": "ticket_create", "ticketTitle": "WA: {{question}}", "ticketPriority": "MEDIUM", "ticketNumberVariable": "ticket_no", "errorMode": "fail_fast" },
"positionX": 900, "positionY": 0, "transitions": { "success": "handoff", "failure": "handoff" } },
{ "id": "handoff", "type": "handoff", "name": "To agent",
"content": { "message": "Connecting you with an agent…", "assignmentMode": "auto" },
"positionX": 1200, "positionY": 0, "transitions": {} }
],
"exportedAt": "2026-07-07T00:00:00.000Z"
}
9. Authoring checklist (validate before importing)
_formatis exactly"orquesta-flow-v1";nameandnodes[]are present.entryNodeIdpoints at a real node (or rely on the "first node" fallback — but be explicit).Every node
idis unique; every id referenced byentryNodeId/transitionsexists.Transition keys match the node's contract (§4):
defaultfor linear; button ids; row ids; condition ids;replied/timeout;body;success/failure.Every button/row/quick-reply node has a transition for each id, plus a
default.wait_inputnodes set anoutputVariableif you need the captured text later.Loop bodies (
safe_loop) transition back to the loop node; add adefaultfor after.Node types are valid for the declared
channel(§7).Required
contentfields are present for each node type (bold fields in §6).Environment-specific ids (
modelId,datasetIds,catalogId,nativeFlowId,templateName,assignToAgentId,targetFlowId,segmentId) are real for the target org or clearly-marked placeholders.Remember: on import,
keywordTriggers,commentTriggers, andisAutoReplyare cleared — re-set triggers in the editor, and ensure keywords are unique across your Journeys.