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 an end node.

  • 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 id is 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_followupreplied 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_loopbody (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_actionsuccess 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}}!" }
  • message required. 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 a timeout transition.

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, templateLanguage required. variables map 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}}" } ] }
  • nativeFlowId required. flowAction: navigate|data_exchange. Transitions: default.

Media: send_image, send_video, send_audio, send_document

"content": { "sourceType": "url", "url": "https://…", "caption": "Optional" }
  • sourceType url|upload; for url set url. send_document adds fileName; send_audio has no caption. Transitions: default.

send_location

"content": { "latitude": "-6.2", "longitude": "106.8", "name": "Office", "address": "Jakarta" }
  • latitude, longitude required. 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?" }
  • message required. 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: type postback (route by payload) or web_url (needs url). 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 as interactive (buttons; waits).

  • web_list — same shape as interactive_list (list; waits).

6.4 Logic & control

wait_input

Capture free text. See §4.4.

"content": { "prompt": "Please enter your response:", "outputVariable": "user_input" }
  • prompt required; outputVariable stores 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) or expression (restricted evaluator). Transitions: default.

delay

"content": { "delayType": "fixed", "delaySeconds": 5, "maxDelaySeconds": 300 }
  • delayType: fixed (use delaySeconds) or variable (use delayVariable). 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 (use minutes) or variable (use minutesVariable). Transitions: replied + timeout.

safe_loop

"content": { "loopType": "array", "arrayVariable": "items", "itemVariable": "item", "indexVariable": "i", "maxIterations": 100 }
  • loopType: static (use staticCount) | array (use arrayVariable) | while (use whileCondition). Transitions: body + default. Loop body ends by transitioning back to this node.

end

Stops the Journey; optional goodbye.

"content": { "message": "Thanks! 👋" }
  • message optional. 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. outputVariable stores 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.

  • userPrompt required; systemPrompt optional; both interpolate. outputVariable stores the reply. structuredOutput/thinking as 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 llm node'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). passContext carries variables into the target. unlimitedDepth bypasses the max recursion depth (default 10).

send_email

"content": { "to": "ops@acme.com,{{email}}", "subject": "New lead", "body": "…", "replyTo": "" }
  • to, subject, body required; 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 (use followUpMinutes) | variable (use followUpMinutesVariable).

  • 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 the ig_* nodes for dual-channel flows.) No web_* nodes.

  • INSTAGRAM: ig_text, ig_quick_reply, ig_generic_template, ig_button_template, ig_media, ig_like_heart, plus wait_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, plus wait_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)

  1. _format is exactly "orquesta-flow-v1"; name and nodes[] are present.

  2. entryNodeId points at a real node (or rely on the "first node" fallback — but be explicit).

  3. Every node id is unique; every id referenced by entryNodeId/transitions exists.

  4. Transition keys match the node's contract (§4): default for linear; button ids; row ids; condition ids; replied/timeout; body; success/failure.

  5. Every button/row/quick-reply node has a transition for each id, plus a default.

  6. wait_input nodes set an outputVariable if you need the captured text later.

  7. Loop bodies (safe_loop) transition back to the loop node; add a default for after.

  8. Node types are valid for the declared channel (§7).

  9. Required content fields are present for each node type (bold fields in §6).

  10. Environment-specific ids (modelId, datasetIds, catalogId, nativeFlowId, templateName, assignToAgentId, targetFlowId, segmentId) are real for the target org or clearly-marked placeholders.

  11. Remember: on import, keywordTriggers, commentTriggers, and isAutoReply are cleared — re-set triggers in the editor, and ensure keywords are unique across your Journeys.