# 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 documents: **AI_AGENT_PIPELINE_SPEC.md** (the "AI Agent" / AI Pipeline system) and
> **STRUCTURED_DATA_SPEC.md**. 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_file`, `dynamic_list`, `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 use | Deterministic scripted menus & rules | AI-driven: LLM replies, RAG, intent routing |
| Waiting for user | Buttons/lists, `wait_input`, `wait_file`, `dynamic_list`, `wait_followup` | Buttons/lists, `wait_input`, `wait_file`, `dynamic_list` |
| Triggers | Keywords, auto-reply, IG comments, manual | Runs as the channel's AI auto-responder |
| Import format | `orquesta-flow-v1` | `orquesta-pipeline-v1` |
| DB tables | `Flow` / `FlowNode` | `AiPipeline` / `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.

```json
{
  "_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

| Field | Type | Required | Notes |
|---|---|---|---|
| `_format` | string | **Yes** | Must be exactly `"orquesta-flow-v1"`. Import rejects anything else. |
| `name` | string | **Yes** | Journey name. On import it is suffixed with `(imported)`. |
| `description` | string \| null | No | Freeform. |
| `channel` | enum | No (default `WHATSAPP`) | `WHATSAPP`, `INSTAGRAM`, or `WEBCHAT`. Governs valid node types (see §7). |
| `isAutoReply` | boolean | No | Whether this is the global auto-reply Journey. **Import always forces this to `false`** — set it in the UI afterward. |
| `keywordTriggers` | string[] \| null | No | Lowercased full-message keywords that trigger the Journey. **Import always resets this to `[]`** — set triggers in the UI afterward (keywords must be unique across Journeys). |
| `commentTriggers` | object \| null | No | Instagram only: `{ keywords: string[], mediaIds: string[], triggerOnFirstComment: boolean }`. Reset on import. |
| `sessionTimeoutMinutes` | int \| null | No | Per-Journey session timeout override (minutes); `null` = org default. |
| `entryNodeId` | string | **Yes (effectively)** | `id` of the first node. If unresolved, the first node in `nodes[]` is used as a fallback. |
| `nodes` | array | **Yes** | Array of node objects (§3). |
| `exportedAt` | string (ISO) | No | Ignored 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`):

```json
{
  "id": "unique_node_id",
  "type": "interactive",
  "name": "Optional label",
  "content": { },
  "positionX": 0,
  "positionY": 0,
  "transitions": { "btn_1": "next_node", "default": "fallback_node" }
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | string | **Yes** | Unique within the file. |
| `type` | string | **Yes** | One of §6. Must match the channel (§7). |
| `name` | string \| null | No | Display label only. |
| `content` | object | **Yes** | Type-specific configuration (§6). May be `{}` for trivial nodes (e.g. `end` with no message). |
| `positionX` / `positionY` | number | No (default 0) | Canvas coordinates for the editor. Lay out left→right (~300px per step). Cosmetic. |
| `transitions` | object \| null | No | branch 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`, `datetime`,
`delay`, `template`, `perform_action` success path, etc.) follow `transitions.default`. Missing/empty
`default` → the walk stops (valid ending).

```json
"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.

```json
"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`.

```json
"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`.

```json
"content": { "prompt": "What's your email?", "outputVariable": "email" },
"transitions": { "default": "next_node" }
```

### 4.4b `wait_file` — `received`, validation failures, and `timeout`

Sends a text prompt and waits specifically for media. A valid attachment follows `received`.
Invalid files follow `invalid_type` or `too_large`; a message without a file leaves the Journey
parked. When `timeoutMinutes` is set, add `timeout`.

```json
"content": {
  "prompt": "Upload a clear photo or PDF.",
  "allowedTypes": ["image", "document"],
  "allowedMimeTypes": ["image/jpeg", "image/png", "application/pdf"],
  "maxSizeMb": 15,
  "outputVariable": "uploaded_files"
},
"transitions": {
  "received": "analyze_document",
  "invalid_type": "request_file_again",
  "too_large": "request_smaller_file",
  "timeout": "follow_up",
  "default": "request_file_again"
}
```

### 4.4c `dynamic_list` — `selected`, `other`, `error`, and `timeout`

Builds a channel-native menu from an array and waits. Pagination and typed search re-render the
same node. A selection stores the original object, then follows `selected`.

```json
"transitions": {
  "selected": "continue",
  "other": "capture_other",
  "error": "list_fallback",
  "timeout": "follow_up",
  "default": "list_fallback"
}
```

### 4.4d Structured-data nodes — outcome branches

- `table_lookup`: `matched`, `not_found`, `error`, then `default` fallback.
- `decision_table`: `matched`, `not_found`, `conflict`, `error`, then `default` fallback.

### 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`.

```json
"content": { "conditions": [ { "id": "c1", "expression": "{{tier}} == \"vip\"", "targetNodeId": "vip" } ], "defaultTargetNodeId": "normal" },
"transitions": { "c1": "vip", "default": "normal" }
```

### 4.5b `condition_v2` (rule-based) — one key per branch id, plus `default` (Else)

Same routing as `condition`, but each branch is built from structured **rules** (field / operator /
value) rather than a free-text expression. Each `content.branches[]` entry has an `id` used as the
transition key; `default` is the **Else** branch. First branch whose rules pass wins.

```json
"content": {
  "branches": [
    { "id": "b1", "name": "VIP adult", "match": "all", "rules": [
      { "left": "{{tier}}", "operator": "equals", "right": "vip" },
      { "left": "{{age}}",  "operator": "gte",    "right": "18" }
    ] }
  ]
},
"transitions": { "b1": "vip_adult_node", "default": "else_node" }
```
`match` is `all` (AND) or `any` (OR). A branch with no rules never matches. See §6.4 `condition_v2`
for the full operator list.

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

```json
"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`

```json
"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_request`
`outputVariable`, `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]}}`.

The `code` node also accepts `language: "jsonata"`. JSONata evaluates against the complete Journey
context and supports structured transformations, filtering, mapping, aggregation, and multi-term
expressions. Limits: 8,192 expression characters, 1 MB serialized input, 1 MB serialized output,
and 500 ms evaluation time.

---

## 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`
```json
"content": { "message": "Hello {{name}}!" }
```
- **`message`** required. Transitions: `default`.

#### `interactive`
Reply buttons (max 3), then wait. See §4.2.
```json
"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.
```json
"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.
```json
"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.
```json
"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.
```json
"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`
```json
"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`
```json
"content": { "latitude": "-6.2", "longitude": "106.8", "name": "Office", "address": "Jakarta" }
```
- **`latitude`**, **`longitude`** required. Transitions: `default`.

#### `send_contacts`
```json
"content": { "contacts": [ { "name": { "formatted_name": "John Doe" }, "phones": [ { "phone": "+62…", "type": "CELL" } ] } ] }
```
- Each contact requires `name.formatted_name`. Transitions: `default`.

#### `send_cta_url`
```json
"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`
```json
"content": { "message": "Hi! How can I help?" }
```
- **`message`** required. Transitions: `default`.

#### `ig_quick_reply`
Quick-reply buttons, then wait.
```json
"content": { "text": "Choose:", "quickReplies": [ { "content_type": "text", "title": "Help", "payload": "qr_help" } ] }
```
- Route by each `payload` + `default`.

#### `ig_generic_template`
Carousel of cards.
```json
"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.
```json
"content": { "text": "What next?", "buttons": [ { "type": "postback", "title": "Help", "payload": "btn_help" } ] }
```

#### `ig_media`
```json
"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.
```json
"content": { "prompt": "Please enter your response:", "outputVariable": "user_input" }
```
- **`prompt`** required; `outputVariable` stores the typed reply. Supports follow-up config (§6.8).
  Transitions: `default`.

#### `wait_file`
Capture and validate an inbound image, document, audio file, or video.
```json
"content": {
  "prompt": "Please upload a clear photo or PDF of your document.",
  "allowedTypes": ["image", "document"],
  "allowedMimeTypes": ["image/jpeg", "image/png", "application/pdf"],
  "maxFiles": 1,
  "maxSizeMb": 15,
  "outputVariable": "uploaded_files",
  "fileUrlVariable": "uploaded_document_url",
  "fileTypeVariable": "uploaded_document_type",
  "timeoutMinutes": 1440
}
```
- `allowedTypes`: 1–4 values from `image|document|audio|video`; default image + document.
- MIME entries may be exact or wildcard values such as `image/*`.
- `maxSizeMb`: 1–100, default 15.
- `outputVariable` defaults to `uploaded_files` and receives an array containing
  `{ messageId, url, objectPath, type, mimeType, sizeBytes, fileName, sha256 }`.
- `fileUrlVariable` and `fileTypeVariable` optionally receive scalar convenience values.
- `maxFiles` accepts 1–10, but the current executor resumes after the **first** valid attachment.
  Do not represent this as a multi-upload accumulator.
- Transitions: `received`, `invalid_type`, `too_large`, optional `timeout`, and `default`.

#### `dynamic_list`
Build a paginated list from an array variable.
```json
"content": {
  "body": "Select the destination country:",
  "buttonText": "Choose country",
  "itemsVariable": "country_results",
  "idExpression": "{{item.code}}",
  "titleExpression": "{{item.name}}",
  "descriptionExpression": "{{item.process}}",
  "outputVariable": "selected_country",
  "pageSize": 7,
  "searchEnabled": true,
  "otherEnabled": true,
  "timeoutMinutes": 1440
}
```
- `itemsVariable` must resolve to an array or JSON-array string.
- Item expressions receive `item` and `index`.
- The full selected object is written to `outputVariable`.
- Channel page caps: WhatsApp 7 data rows, Instagram 10, Web Chat 50. Navigation and Other are
  appended within each channel's interactive-message limits.
- When search is enabled, typed text filters and re-renders the list.
- Transitions: `selected`, optional `other`, `error`, `timeout`, and `default`.

#### `table_lookup`
Read a record or records from the latest published Structured Data revision.
```json
"content": {
  "dataSourceKey": "country_legalization",
  "match": [
    { "column": "country_name", "value": "{{destination_country}}", "operator": "alias", "aliasColumn": "aliases" }
  ],
  "matchMode": "all",
  "resultMode": "first",
  "selectColumns": ["country_code", "country_name", "process"],
  "outputVariable": "country_rule",
  "fallbackResult": null
}
```
- Operators: `equals`, `equals_ci`, `contains`, `starts_with`, `in`, `alias`.
- `matchMode`: `all|any`; `resultMode`: `first|all`.
- Omit `selectColumns` to return full rows.
- Writes metadata to `<outputVariable>__meta`.
- Transitions: `matched`, `not_found`, `error`, `default`.

#### `decision_table`
Evaluate many workbook-style business rules as data.
```json
"content": {
  "dataSourceKey": "apostille_rules",
  "inputs": {
    "document_type": "{{document_type}}",
    "has_electronic_signature": "{{has_electronic_signature}}",
    "document_state": "{{document_state}}"
  },
  "outputColumns": ["required_services", "human_review_required"],
  "hitPolicy": "UNIQUE",
  "blankMatchesAny": true,
  "priorityColumn": "priority",
  "enabledColumn": "enabled",
  "outputVariable": "decision_result",
  "fallbackResult": null
}
```
- `conditionColumns` optionally maps input names to different data columns.
- `blankMatchesAny` defaults true; blank condition cells are wildcards.
- `enabledColumn` defaults to `enabled`; false rows are ignored.
- `priorityColumn` defaults to `priority`; lower numbers sort first.
- `hitPolicy`: `UNIQUE`, `FIRST`, or `COLLECT`.
- Writes metadata to `<outputVariable>__meta`.
- Transitions: `matched`, `not_found`, `conflict`, `error`, `default`.
- See `STRUCTURED_DATA_SPEC.md` for the table-import contract.

#### `condition`
Branch on expressions. See §4.5.
```json
"content": { "conditions": [ { "id": "c1", "expression": "{{x}} == \"y\"", "targetNodeId": "yes" } ], "defaultTargetNodeId": "no" }
```
- Transitions: per condition id + `default`.

#### `condition_v2`
Rule-based branching — like `condition` but each branch uses structured rules. See §4.5b.
```json
"content": {
  "branches": [
    { "id": "b1", "name": "label", "match": "all",
      "rules": [ { "left": "{{intent}}", "operator": "contains", "right": "refund" } ] }
  ],
  "defaultTargetNodeId": ""
}
```
- **`branches[]`**: each `{ id, name?, match, rules[] }`. `id` = transition key.
  - **`match`**: `all` (AND) or `any` (OR).
  - **`rules[]`**: each `{ left, operator, right? }`; `left`/`right` interpolate `{{ }}` (a lone
    `{{var}}` keeps its typed value).
  - **`operator`**: `equals`, `not_equals`, `contains`, `not_contains`, `gt`, `gte`, `lt`, `lte`,
    `starts_with`, `ends_with`, `is_empty`, `is_not_empty`, `is_one_of` (`right` = comma list).
    `is_empty`/`is_not_empty` ignore `right`; text ops are case-insensitive; `gt`/`gte`/`lt`/`lte`
    coerce to numbers.
- First matching branch wins; rule-less branch never matches. Transitions: per branch id + `default` (Else).

#### `set_variable`
Two accepted shapes:
```json
// 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`
```json
"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`.

#### `datetime`
Write the current date/time into a variable, formatted for a timezone.
```json
"content": { "outputVariable": "current_datetime", "format": "datetime", "customPattern": "", "timezone": "Asia/Jakarta" }
```
- **`outputVariable`**: variable that receives the formatted string.
- **`format`**: `datetime` (`DD/MM/YYYY HH:mm:ss`), `date`, `time` (`HH:mm:ss`), `time_short`
  (`HH:mm`), `hour`, `minute`, `day_of_week`, `iso`, `unix`, or `custom`.
- **`customPattern`**: [dayjs](https://day.js.org/docs/en/display/format) tokens, only when
  `format` is `custom` (e.g. `DD MMM YYYY HH:mm`); empty → `datetime` preset.
- **`timezone`**: IANA name, default `Asia/Jakarta` (WIB). Transitions: `default`.

#### `wait_followup`
Timed no-response nudge. See §4.6.
```json
"content": { "message": "Still there?", "minutesType": "fixed", "minutes": 60 }
```
- `minutesType`: `fixed` (use `minutes`) or `variable` (use `minutesVariable`). Transitions:
  `replied` + `timeout`.

#### `safe_loop`
```json
"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.
```json
"content": { "message": "Thanks! 👋" }
```
- `message` optional. Transitions: none.

### 6.5 Integration & AI

#### `http_request`
```json
"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`
```json
"content": { "expression": "$number(price) * $number(qty)", "inputVariables": ["price", "qty"], "outputVariable": "total", "language": "jsonata" }
```
- `language`: `legacy` (restricted evaluator) or `jsonata`; see §5.4. Transitions: `default`.

#### `llm`
Generate text with an LLM.
```json
"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`
```json
"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`).
```json
"content": { "action": "contact_field_set", "fieldLabel": "email", "fieldValue": "{{email}}", "errorMode": "fail_fast" },
"transitions": { "success": "ok", "failure": "err" }
```

#### `handoff`
Escalate to a human. Terminal.
```json
"content": { "message": "Connecting you with an agent…", "assignmentMode": "auto", "assignToAgentId": "" }
```
- `assignmentMode`: `none|specific|auto`. Transitions: none needed.

#### `execute_flow`
Hand off to another Journey.
```json
"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`
```json
"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`).

| Type | Key content fields | Transitions |
|---|---|---|
| `send_single_product` | `catalogId`, `productRetailerId`, `bodyText?`, `footerText?` | `default` |
| `send_multi_product` | `catalogId`, `headerText`, `bodyText`, `sections[]` (`{title, productRetailerIds[]}`) | `default` |
| `send_catalog` | `catalogId`, `bodyText`, `thumbnailProductRetailerId` | `default` |
| `search_catalog_product` | `catalogId`, `searchQuery`, `searchField` (`name\|retailer_id\|category\|all`), `maxResults`, `outputVariable` | `default` |
| `get_product_list` | `catalogId`, `maxResults`, `outputVariable` | `default` |
| `get_single_product` | `catalogId`, `productRetailerId`, `outputVariable` | `default` |

### 6.8 Optional no-response follow-up (on waiting nodes)

`interactive`, `multi_interactive`, `interactive_list`, `wait_input`, and `wait_file` support an **opt-in**
follow-up nudge if the customer doesn't respond. Add these fields to `content` **and** a `timeout`
transition:

```json
"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)

All three channel builders expose these shared nodes:
`wait_input`, `wait_file`, `dynamic_list`, `condition`, `condition_v2`, `table_lookup`,
`decision_table`, `datetime`, `set_variable`, `delay`, `wait_followup`, `safe_loop`,
`http_request`, `code`, `llm`, `knowledge_retrieval`, `perform_action`, `execute_flow`,
`send_email`, `handoff`, and `end`.

Channel-specific messaging:

- **WHATSAPP**: `interactive`, `multi_interactive`, `interactive_list`, `text`, `template`,
  `send_whatsapp_native_flow`, `send_image`, `send_video`, `send_audio`, `send_document`,
  `send_location`, `send_contacts`, `send_cta_url`, and all catalog nodes.
- **INSTAGRAM**: `ig_text`, `ig_quick_reply`, `ig_generic_template`, `ig_button_template`,
  `ig_media`, `ig_like_heart`. Instagram quick replies support up to 13 entries; button templates
  support up to 3 buttons per message/card.
- **WEBCHAT**: `web_text`, `web_button`, `web_list`. Web Chat uses product-owned rendering, so
  these nodes are not constrained by Meta message templates, but the graph must still use the
  dedicated `web_*` node names.

Do not mix channel-specific message nodes in one Journey. Shared `dynamic_list`, `wait_input`, and
`wait_file` adapt their outbound prompt/menu to the Journey channel.

Import performs only basic envelope validation. Publishing performs node-content and published
Structured Data dependency validation, so a successful import is not proof that every node is
valid.

---

## 8. Complete worked example

A WhatsApp menu Journey: greet with buttons → branch to Sales / Support / capture a question and
hand off.

```json
{
  "_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; wait/data outcomes; `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.
12. Every Structured Data key exists in the target organization and has a published revision.
13. `wait_file` has explicit invalid-type and too-large handling and is not treated as a
    multi-file accumulator.
14. Dynamic lists have `selected`, empty/error, Other (when enabled), and timeout paths.
15. Channel-specific nodes match the declared channel; shared nodes may adapt, but message nodes
    are not interchangeable between WhatsApp, Instagram, and Web Chat.
