# AI Agent (AI Pipeline) — Authoring Specification

> **Purpose of this document.** This is a complete, self-contained reference for the JSON
> structure of an **AI Agent** (internally called an **AI Pipeline**). 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 AI Agent JSON that can be
> imported into the platform via **AI Agents → Import**.
>
> Companion documents: **JOURNEY_FLOW_SPEC.md** (the "Journey" / Flow system) and
> **STRUCTURED_DATA_SPEC.md**. The two automation systems
> are siblings with an almost identical JSON shape but different node catalogs. Read the
> "AI Agent vs Journey" section below to pick the right one.

---

## 1. What an AI Agent is

An **AI Agent** is a **directed graph of nodes** (a DAG) that runs once per inbound customer
message on a channel (WhatsApp, Instagram, or Web Chat). It is designed for **AI-first
automation**: calling LLMs, retrieving knowledge, classifying intent, extracting parameters,
branching on logic, and replying.

Execution model (important — it shapes how you must design the graph):

- The engine loads the graph, finds the **entry node**, and traverses node → node by following
  **transitions** until it reaches a node with no onward transition (or an `end` node).
- Traversal normally runs synchronously within one inbound message.
- Interactive nodes, `wait_input`, `wait_file`, and `dynamic_list` save a durable *wait state*
  and stop the current pass. The next compatible inbound message resumes the graph.
- All other nodes execute immediately and continue to the next node in the same pass.
- A **loop-protection cap of 50 node executions** per message aborts runaway graphs.

State is accumulated in a **context object** (a flat key→value map of "variables") that persists
across messages for the life of the chat session. Nodes read variables via `{{variable}}`
interpolation and write variables to named output variables.

### AI Agent vs Journey — which to use?

| | **AI Agent (AI Pipeline)** | **Journey (Flow)** |
|---|---|---|
| Primary use | AI-driven: LLM replies, RAG, intent routing | Deterministic scripted menus & rules |
| Mental model | "Reason then respond" DAG | "Menu tree / decision tree" |
| Waiting for user | Buttons/lists, `wait_input`, `wait_file`, `dynamic_list` | Buttons/lists, `wait_input`, `wait_file`, `dynamic_list`, timed follow-up |
| Import format | `orquesta-pipeline-v1` | `orquesta-flow-v1` |
| DB tables | `AiPipeline` / `AiPipelineNode` | `Flow` / `FlowNode` |

On WhatsApp, an AI Agent can hand control to a Journey with `execute_flow`. Journey execution from
an Instagram or Web Chat AI Agent is not currently implemented.

---

## 2. Top-level JSON structure (the import file)

This is exactly what the export endpoint produces and the import endpoint accepts.

```json
{
  "_format": "orquesta-pipeline-v1",
  "name": "Customer Support Agent",
  "description": "Answers FAQs from the knowledge base, escalates on request",
  "channel": "WHATSAPP",
  "sessionTimeoutMinutes": null,
  "entryNodeId": "start_1",
  "nodes": [
    { "id": "start_1", "type": "start", "name": "Start", "content": {}, "positionX": 0,   "positionY": 0,   "transitions": { "default": "llm_1" } },
    { "id": "llm_1",   "type": "llm",   "name": "Answer", "content": { /* … */ }, "positionX": 300, "positionY": 0, "transitions": { "default": "reply_1" } },
    { "id": "reply_1", "type": "text_response", "name": "Reply", "content": { "message": "{{llm_response}}" }, "positionX": 600, "positionY": 0, "transitions": {} }
  ],
  "exportedAt": "2026-07-07T00:00:00.000Z"
}
```

### Top-level fields

| Field | Type | Required | Notes |
|---|---|---|---|
| `_format` | string | **Yes** | Must be exactly `"orquesta-pipeline-v1"`. Import rejects anything else. |
| `name` | string | **Yes** | Agent name. On import it is suffixed with `(imported)`. |
| `description` | string \| null | No | Freeform. |
| `channel` | enum | No (default `WHATSAPP`) | One of `WHATSAPP`, `INSTAGRAM`, `WEBCHAT`. Governs which node types are valid (see §7). |
| `sessionTimeoutMinutes` | int \| null | No | Per-agent session timeout override in minutes; `null` = use org default. |
| `entryNodeId` | string | **Yes (effectively)** | The `id` of the node where traversal begins. Should point at the `start` node. If missing/unresolved, the agent does nothing. |
| `nodes` | array | **Yes** | Array of node objects (see §3). Must be a real array. |
| `exportedAt` | string (ISO) | No | Ignored on import; include it for round-tripping. |

> **On import**, every node `id` is regenerated and all `entryNodeId` + `transitions` references
> are remapped automatically. Therefore, when authoring by hand, you may use **any unique
> string ids** you like (e.g. `start_1`, `llm_answer`, `n_ask_name`). They only need to be
> **unique within the file** and **referenced consistently** by `entryNodeId` and by
> `transitions`. Readable slug ids are recommended for hand-authoring.

---

## 3. The node object

Every element of `nodes[]` has this shape (matches `AiPipelineNode`):

```json
{
  "id": "unique_node_id",
  "type": "llm",
  "name": "Optional human label",
  "content": { },
  "positionX": 300,
  "positionY": 120,
  "transitions": { "default": "next_node_id" }
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | string | **Yes** | Unique within the file. |
| `type` | string | **Yes** | One of the node types in §6. Must match the channel (§7). |
| `name` | string \| null | No | Display label only. |
| `content` | object | **Yes** | Type-specific configuration. Shape defined per node in §6. May be `{}` for `start`/`end`. |
| `positionX` / `positionY` | number | No (default 0) | Canvas coordinates for the visual editor. Use a left→right layout (e.g. increment X by ~300 per step) so the imported graph is readable. Purely cosmetic. |
| `transitions` | object \| null | No | key → target-node-id map. See §4. |

---

## 4. Transitions — how nodes connect (READ THIS CAREFULLY)

`transitions` is a plain object mapping a **branch key** to a **target node id**. The branch key
depends on the node type. Getting these keys right is the single most important part of authoring.

### 4.1 Linear nodes — `default`

Most nodes (`start`, `llm`, `text_response`, `set_variable`, `datetime`, `http_request`, media nodes, etc.)
have exactly one outgoing path. They follow `transitions.default`:

```json
"transitions": { "default": "next_node_id" }
```

If `default` is missing/empty, traversal **stops** after that node (a valid way to end).

### 4.2 `condition` node — one key per condition `id`, plus `default`

Each entry in `content.conditions[]` has an `id`. The transition key **is that id**. Add a
`default` for the else-branch:

```json
"content": {
  "conditions": [
    { "id": "is_vip",    "name": "VIP",    "expression": "{{tier}} == \"vip\"" },
    { "id": "is_angry",  "name": "Angry",  "expression": "{{sentiment}} == \"negative\"" }
  ]
},
"transitions": {
  "is_vip":   "vip_handler",
  "is_angry": "escalate",
  "default":  "normal_handler"
}
```
Conditions are evaluated top-to-bottom; **first match wins**. If none match, `default` is used.

### 4.2b `condition_v2` node (rule-based) — one key per branch `id`, plus `default` (Else)

Same routing shape as `condition`, but each branch is built from structured **rules** (field /
operator / value) instead of a free-text expression. Each entry in `content.branches[]` has an
`id`; the transition key **is that id**. `default` is the **Else** branch.

```json
"content": {
  "branches": [
    { "id": "b_vip",   "name": "VIP",   "match": "all",
      "rules": [ { "left": "{{tier}}", "operator": "equals", "right": "vip" } ] },
    { "id": "b_adult", "name": "Adult", "match": "all",
      "rules": [ { "left": "{{age}}", "operator": "gte", "right": "18" } ] }
  ]
},
"transitions": { "b_vip": "vip_handler", "b_adult": "adult_handler", "default": "else_handler" }
```
Branches are evaluated top-to-bottom; **first branch whose rules pass wins**. A branch with no
rules never matches. See §6.1 `condition_v2` for the operator list and `match` semantics.

### 4.3 `question_classifier` node — one key per class `id`, plus `default`

The LLM classifies the input into one of `content.classes[]` (by `id`). Route each class id, plus
a `default` fallback:

```json
"content": {
  "modelId": "<model-id>",
  "inputVariable": "query",
  "classes": [
    { "id": "billing", "name": "Billing", "description": "Payments, invoices, refunds" },
    { "id": "support", "name": "Support", "description": "Technical problems" }
  ]
},
"transitions": { "billing": "billing_node", "support": "support_node", "default": "fallback_node" }
```
Class keys are matched case-insensitively.

### 4.4 Interactive nodes — one key per button/row id, plus `default` (these WAIT)

`interactive_response`, `multi_interactive_response`, `interactive_list_response`,
`web_interactive_response`, `web_list_response`: after sending, the engine **pauses** and waits
for the next inbound message. When the user taps a button (or picks a list row), it resumes and
routes via `transitions[<buttonId or rowId>]`, falling back to `transitions.default`.

```json
"content": {
  "body": "How can we help?",
  "buttons": [
    { "id": "sales",   "title": "Talk to Sales" },
    { "id": "support", "title": "Get Support" }
  ]
},
"transitions": { "sales": "sales_node", "support": "support_node", "default": "reprompt_node" }
```
> If a button/row has **no** matching transition and no `default`, the reply is ignored and the
> agent keeps waiting on the same node.

### 4.5 `wait_input` — `received`, `invalid_type`, and `timeout`

`wait_input` sends a prompt and accepts the next non-empty typed message. It stores the text in
`outputVariable`, then follows `received` (falling back to `default`). Media or button replies may
use `invalid_type`. `timeout` is used only when `timeoutMinutes` is configured.

```json
"content": { "prompt": "What country is the document for?", "outputVariable": "country", "timeoutMinutes": 1440 },
"transitions": {
  "received": "lookup_country",
  "invalid_type": "ask_country_again",
  "timeout": "follow_up",
  "default": "lookup_country"
}
```

### 4.6 `wait_file` — `received`, validation failures, and `timeout`

Valid files follow `received`. Invalid media follows `invalid_type` or `too_large`; both fall back
to `default` if their explicit branch is absent. A message without a file leaves the node waiting.

```json
"transitions": {
  "received": "analyze_document",
  "invalid_type": "request_file_again",
  "too_large": "request_smaller_file",
  "timeout": "follow_up",
  "default": "request_file_again"
}
```

### 4.7 `dynamic_list` — `selected`, `other`, `error`, and `timeout`

The engine creates channel-specific menu tokens. Navigation and typed search re-render the same
node; they do not require graph transitions. A real selection stores the **original selected
object** and follows `selected`. `other`, empty-list `error`, and optional `timeout` are separate
branches, each with `default` fallback except timeout.

### 4.8 Structured-data nodes — outcome branches

`table_lookup` follows `matched`, `not_found`, or `error`.
`decision_table` follows `matched`, `not_found`, `conflict`, or `error`.
Both fall back to `default` when an outcome-specific transition is absent.

### 4.9 `iteration` node — `body` and `done`

```json
"content": { "arrayVariable": "items", "itemVariable": "item", "indexVariable": "i", "maxIterations": 100 },
"transitions": { "body": "process_item_node", "done": "after_loop_node" }
```
- `body` → the first node of the loop body. **The last node of the loop body must transition
  back to the `iteration` node** (`"default": "<iteration node id>"`) so it can advance to the
  next item.
- `done` (or `default`) → node to run after the loop finishes. Cap: 100 iterations.

### 4.10 Terminal nodes (no transitions needed)

- `end` — stops the pipeline.
- `handoff` — hands the chat to a human agent.
- `execute_flow` — hands control to a Journey (Flow); the Flow now owns the chat.

These don't need outgoing transitions (any are ignored).

---

## 5. Variables, interpolation & expressions

### 5.1 Interpolation syntax — `{{ … }}`

Anywhere a content string is sent or evaluated, `{{variableName}}` is replaced with the value from
context. Supported accessors:

- Simple: `{{query}}`, `{{contact_name}}`
- Dot notation into JSON: `{{order.total}}`, `{{profile.address.city}}`
- Bracket notation: `{{response["data"][0]["name"]}}`
- **Nested** (resolved innermost-first): `{{pricing["{{kecamatan}}"]}}`

If a variable is a JSON string, it is auto-parsed before accessor traversal. Missing variables
resolve to an empty string. Values that are objects/arrays are stringified as JSON.

### 5.2 Built-in variables (available at the start of every run)

| Variable | Type | Meaning |
|---|---|---|
| `query` | string | The inbound message text (or media caption). |
| `file` | string \| null | Media attachment URL, if the inbound message had media. |
| `file_type` | string \| null | `image` \| `video` \| `audio` \| `document`. |
| `contact_name` | string | Contact display name. |
| `contact_phone` | string | WhatsApp ID (WA) or Instagram ID (IG). |
| `contact_ig_username` | string | Instagram username (IG only). |
| `message_type` | string | `text`, `image`, `button_reply`, `list_reply`, `quick_reply`, `postback`, … |
| `button_payload` | string \| null | The id/payload of a tapped button, if any. |
| `channel` | string | `WHATSAPP` \| `INSTAGRAM` \| `WEBCHAT`. |

Any variable your nodes write (LLM `outputVariable`, `set_variable`, `http_request`
`outputVariable`, etc.) is added to context and reusable downstream in the same run and in
later messages of the same session.

### 5.3 Condition expressions

Used by `condition` node expressions and the `set_variable` `expression` value type. A condition
expression is a single comparison:

```
<left> <operator> <right>
```
Operators: `==`, `!=`, `===`, `!==`, `>`, `<`, `>=`, `<=`. Operands may be `{{variables}}`, quoted
strings (`"vip"`), numbers, or booleans (`true`/`false`). Examples:

```
{{tier}} == "vip"
{{age}} >= 18
{{count}} != 0
{{opted_in}} == true
```

### 5.4 `set_variable` / `code` value expressions (safe evaluator)

The `set_variable` node with `valueType: "expression"` and the `code` node use a **restricted**
evaluator (not full JS). Supported:

- Number literals: `42`, `3.14`
- JSON literals: `{"a":1}`, `[1,2,3]` (great for seeding lookup tables)
- Variable references: `myVar`
- Property access: `obj.prop`, `obj["prop"]`
- A few string methods: `.toUpperCase()`, `.toLowerCase()`, `.trim()`, `.length`, `.toString()`
- Array `.length`
- `JSON.stringify(x)` / `JSON.parse(x)`
- `Math.abs/round/floor/ceil/min/max/pow/sqrt(...)`
- **Single** binary arithmetic: `a + b`, `a - b`, `a * b`, `a / b`

The `code` node also supports `language: "jsonata"` for richer expressions (array map/reduce,
`$sum`, `$join`, multi-term arithmetic, etc.). JSONata receives the complete context as both its
input document and variable bindings. Expression length is capped at 8,192 characters, serialized
input and output at 1 MB each, and evaluation at 500 ms. A code error stops that path unless the
surrounding execution layer provides a fallback.

---

## 6. Node type catalog

For each node: purpose, `content` schema (required fields **bold**), allowed values, transition
keys, and an example. `?` marks optional fields. Unless noted, string fields support `{{variable}}`
interpolation.

### 6.1 Control & logic

#### `start`
Entry point. Pass-through.
- `content`: `{}` (empty).
- Transitions: `{ "default": "<first node>" }`. **`entryNodeId` must equal this node's id.**

#### `end`
Stops the pipeline.
- `content`: `{}`.
- Transitions: none.

#### `condition`
Branch on expressions. See §4.2.
```json
"content": {
  "conditions": [ { "id": "c1", "name": "label", "expression": "{{x}} == \"y\"" } ],
  "defaultTargetNodeId": ""
}
```
- **`conditions[]`**: each `{ id, name, expression }`. `id` **required** (used as transition key).
- Transitions: one per condition `id` + `default`.

#### `condition_v2`
Rule-based branching — like `condition` but each branch uses structured rules instead of a
free-text expression. See §4.2b.
```json
"content": {
  "branches": [
    { "id": "b1", "name": "label", "match": "all",
      "rules": [ { "left": "{{intent}}", "operator": "contains", "right": "refund" } ] }
  ],
  "defaultTargetNodeId": ""
}
```
- **`branches[]`**: each `{ id, name?, match, rules[] }`. `id` **required** (used as transition key).
  - **`match`**: `"all"` (AND — every rule must pass) or `"any"` (OR — any rule passes).
  - **`rules[]`**: each `{ left, operator, right? }`. `left`/`right` interpolate `{{ }}`; a lone
    `{{var}}` keeps its typed value.
  - **`operator`** (one of): `equals`, `not_equals`, `contains`, `not_contains`, `gt`, `gte`,
    `lt`, `lte`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty`, `is_one_of`
    (`right` = comma-separated list). `is_empty`/`is_not_empty` ignore `right`. Text comparisons
    are case-insensitive; `gt`/`gte`/`lt`/`lte` coerce to numbers.
- Branches evaluated top-to-bottom, **first match wins**; a rule-less branch never matches.
- Transitions: one per branch `id` + `default` (the Else branch).

#### `set_variable`
Assign one or more context variables.
```json
"content": {
  "assignments": [
    { "variableName": "greeting", "valueType": "static",     "value": "Hi {{contact_name}}" },
    { "variableName": "total",    "valueType": "expression", "value": "{{price}} * {{qty}}" }
  ]
}
```
- **`assignments[]`**: each `{ variableName, valueType, value }`.
  - `valueType`: `"static"` (interpolate `{{}}` then store as string) or `"expression"` (safe evaluator, §5.4).
- Transitions: `default`.

#### `variable_aggregator`
Merge several source variables into one (single source → value; multiple → array).
```json
"content": { "variableName": "all_answers", "sources": [ { "variableName": "a1" }, { "variableName": "a2" } ] }
```
- Transitions: `default`.

#### `iteration`
Loop over an array. See §4.5.
```json
"content": { "arrayVariable": "items", "itemVariable": "item", "indexVariable": "index", "maxIterations": 100 }
```
- **`arrayVariable`**: name of a context variable holding an array (or JSON-array string).
- `itemVariable` / `indexVariable`: where the current item/index are written each pass.
- `maxIterations`: default & hard cap 100.
- Transitions: `body` (loop body start) + `done` (or `default`). Loop body's last node must
  transition back to this node.

#### `delay`
Blocking wait, then continue.
```json
"content": { "duration": 5, "unit": "seconds", "maxDelaySeconds": 300 }
```
- `duration` + `unit` (`"seconds"` | `"minutes"`). Capped at `maxDelaySeconds` (default **300s**).
- Skipped entirely in test/dry-run mode.
- 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`**: context variable that receives the formatted string.
- **`format`** (one of): `datetime` (`DD/MM/YYYY HH:mm:ss`), `date` (`DD/MM/YYYY`), `time`
  (`HH:mm:ss`), `time_short` (`HH:mm`), `hour` (`HH`), `minute` (`mm`), `day_of_week` (e.g.
  `Tuesday`), `iso` (ISO-8601 with offset), `unix` (epoch seconds), or `custom`.
- **`customPattern`**: [dayjs](https://day.js.org/docs/en/display/format) tokens, used only when
  `format` is `custom` (e.g. `DD MMM YYYY HH:mm`); empty falls back to the `datetime` preset.
- **`timezone`**: IANA name, default `Asia/Jakarta` (WIB).
- Transitions: `default`.

#### `wait_input`
Prompt for a typed message and pause the pipeline.
```json
"content": {
  "prompt": "Enter the destination country:",
  "outputVariable": "destination_country",
  "timeoutMinutes": 1440
}
```
- **`prompt`**: 1–4096 characters.
- `outputVariable`: defaults to `user_input`; valid variable name, maximum 100 characters.
- `timeoutMinutes`: optional integer from 1 to 525,600. Configure a `timeout` transition when set.
- Transitions: `received` (or `default`), optional `invalid_type`, optional `timeout`.

#### `wait_file`
Prompt for an inbound attachment, validate it, store structured metadata, and pause.
```json
"content": {
  "prompt": "Upload a clear photo or PDF of the 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`; defaults to image + document.
- `allowedMimeTypes`: optional exact MIME values or wildcards such as `image/*`.
- `maxSizeMb`: 1–100, default 15.
- `outputVariable`: defaults to `uploaded_files` and receives an array containing a structured file:
  `{ messageId, url, objectPath, type, mimeType, sizeBytes, fileName, sha256 }`.
- `fileUrlVariable` and `fileTypeVariable` optionally receive convenient scalar values.
- `maxFiles` is accepted (1–10), but the current inbound execution model resumes on the **first**
  valid attachment and stores one file. Do not design a multi-upload accumulator without an
  explicit loop or future platform support.
- Transitions: `received`, `invalid_type`, `too_large`, optional `timeout`, and `default` fallback.

#### `dynamic_list`
Build a searchable, paginated selection from an array variable and pause.
```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`**: array variable name/expression; JSON-array strings are parsed.
- **`titleExpression`** and optional id/description expressions evaluate with `item` and `index`.
- `outputVariable` receives the full original selected object, not only its menu token.
- Channel page caps: WhatsApp 7 data rows, Instagram 10, Web Chat 50. Navigation and `Other` are
  appended within each channel's practical interactive limit.
- Typed input performs search only when `searchEnabled` is true.
- Transitions: `selected`, optional `other`, `error`, `timeout`, and `default`.

#### `table_lookup`
Read the latest published revision of an organization-scoped Structured Data source.
```json
"content": {
  "dataSourceKey": "service_price_time",
  "match": [
    { "column": "service_name", "value": "{{requested_service}}", "operator": "alias", "aliasColumn": "aliases" }
  ],
  "matchMode": "all",
  "resultMode": "first",
  "selectColumns": ["service_code", "price", "processing_days"],
  "outputVariable": "service_quote",
  "fallbackResult": null
}
```
- **`dataSourceKey`**, **`match[]`**, and **`outputVariable`** are required.
- Operators: `equals`, `equals_ci`, `contains`, `starts_with`, `in`, `alias`.
- Each match may set `caseSensitive`; `alias` may set `aliasColumn`.
- `matchMode`: `all` (AND) or `any` (OR). `resultMode`: `first` or `all`.
- `selectColumns` projects the result; omit it to return the complete row.
- Writes the result to `outputVariable` and execution metadata to
  `<outputVariable>__meta`.
- Transitions: `matched`, `not_found`, `error`, and `default`.

#### `decision_table`
Evaluate workbook-style business rules without building dozens of condition nodes.
```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
}
```
- **`dataSourceKey`**, non-empty **`inputs`**, **`outputColumns`**, and
  **`outputVariable`** are required.
- `conditionColumns` optionally maps input names to different table column names.
- `blankMatchesAny` defaults to true, so blank rule cells act as wildcards.
- `enabledColumn` defaults to `enabled`; rows explicitly set to false are ignored.
- `priorityColumn` defaults to `priority`; lower numbers sort first.
- `hitPolicy`: `UNIQUE` (multiple matches → conflict), `FIRST`, or `COLLECT`.
- Writes result metadata to `<outputVariable>__meta`.
- Transitions: `matched`, `not_found`, `conflict`, `error`, and `default`.
- See `STRUCTURED_DATA_SPEC.md` for the import file and rule-table design contract.

### 6.2 AI / LLM

#### `llm`
Call an LLM. The core node of most agents.
```json
"content": {
  "modelId": "<AiModel id from your org's model catalog>",
  "systemPrompt": "You are a helpful support agent for Acme.",
  "userPrompt": "{{query}}",
  "temperature": 0.7,
  "maxTokens": 1024,
  "memory": { "enabled": true, "maxMessages": 20, "windowType": "message_count" },
  "contextVariables": ["kb_results"],
  "vision": { "enabled": false, "detail": "low" },
  "structuredOutput": { "enabled": false, "schema": {}, "outputVariable": "llm_structured" },
  "thinking": { "enabled": false, "outputVariable": "llm_thinking" },
  "outputVariable": "llm_response"
}
```
- **`modelId`** — the model identifier from the org's **AI model catalog** (`AiModel.id`). This is
  **environment-specific**; leave the picker to the UI or obtain a valid id from the target org.
  When authoring blind, use a placeholder like `"<MODEL_ID>"` and instruct the user to select the
  model in the editor.
- **`userPrompt`** — required. `systemPrompt` optional. Both interpolate `{{}}`.
- `temperature` — 0–2 (clamped), default 0.7.
- `maxTokens` — optional; defaults to the model's configured max.
- `memory` — `{ enabled, maxMessages, windowType }`. `windowType`: `"message_count"` (take last
  `maxMessages`) or `"session_lifetime"` (all messages this session, hard-capped at 50). History is
  injected as a framed block in the system prompt.
- `contextVariables[]` — names of variables (typically knowledge-retrieval output) whose contents
  are injected as a `<context>` block into the user prompt for grounding.
- `vision` — `{ enabled, detail }` (`detail`: `"low"`|`"high"`). Only used if the inbound `file` is
  an image URL and the model supports vision.
- `structuredOutput` — `{ enabled, schema, outputVariable? }`. `schema` is a JSON Schema object.
  When enabled (and supported), `outputVariable` gets the full JSON string; the plain
  `outputVariable` gets the `reply_text` field. Ignored if the model doesn't support it or if
  thinking is on.
- `thinking` — `{ enabled, outputVariable? }`. Captures model reasoning. Mutually exclusive with
  structured output.
- **`outputVariable`** — where the reply text is stored (e.g. `llm_response`).
- Transitions: `default`.
- **Failure behavior:** if out of AI credits or the call errors, the node **halts the pipeline**
  (does not advance, does not echo an error to the customer).

#### `knowledge_retrieval`
RAG search against knowledge datasets; store results for an `llm` node to ground on.
```json
"content": {
  "datasetIds": ["<KnowledgeDataset id>", "..."],
  "queryVariable": "query",
  "topK": 5,
  "scoreThreshold": 0.5,
  "outputVariable": "kb_results"
}
```
- **`datasetIds[]`** — knowledge dataset ids (environment-specific).
- `queryVariable` — context var holding the search query (default `query`).
- `topK` — results per dataset (default 5). `scoreThreshold` — min relevance 0–1 (default 0.5; `0`
  disables the floor).
- **`outputVariable`** — stores a JSON array of `{ text, documentName, score }`. Feed this variable
  name into a downstream `llm` node's `contextVariables`.
- Transitions: `default`. (Typical pattern: `knowledge_retrieval` → `llm` → `text_response`.)

#### `question_classifier`
LLM routes input into one of several classes. See §4.3.
```json
"content": { "modelId": "<MODEL_ID>", "inputVariable": "query",
  "classes": [ { "id": "billing", "name": "Billing", "description": "…" } ] }
```
- Transitions: one per class `id` + `default`.

#### `parameter_extractor`
LLM extracts structured parameters from input into a JSON object.
```json
"content": {
  "modelId": "<MODEL_ID>",
  "inputVariable": "query",
  "parameters": [
    { "name": "name",  "type": "string", "description": "Customer full name", "required": true },
    { "name": "email", "type": "string", "description": "Email address",      "required": false }
  ],
  "outputVariable": "extracted_params"
}
```
- `parameters[].type`: `"string"` | `"number"` | `"boolean"`.
- `outputVariable` stores a JSON string; access fields via `{{extracted_params.name}}`.
- Transitions: `default`.

### 6.3 Integration

#### `http_request`
Call an external API.
```json
"content": {
  "method": "POST",
  "url": "https://api.example.com/lookup?id={{customer_id}}",
  "headers": [ { "key": "Authorization", "value": "Bearer {{token}}" } ],
  "params":  [ { "key": "q", "value": "{{query}}" } ],
  "bodyType": "json",
  "body": "{ \"name\": \"{{contact_name}}\" }",
  "timeout": 10000,
  "outputVariable": "http_response"
}
```
- **`method`**: `GET|POST|PUT|DELETE|PATCH`. **`url`** required.
- `bodyType`: `none|json|form-data|raw`. `body` interpolates `{{}}` (JSON-safe escaping for `json`).
- `timeout` ms. `outputVariable` stores the parsed response — access via
  `{{http_response["field"]}}`.
- Transitions: `default`.

#### `code`
Evaluate a restricted expression (§5.4) and store the result.
```json
"content": { "expression": "{{price}} * {{qty}}", "inputVariables": ["price","qty"], "outputVariable": "total", "language": "legacy" }
```
- `language`: `"legacy"` (default, restricted evaluator) or `"jsonata"` (richer).
- Transitions: `default`.

### 6.4 Response / messaging — WhatsApp

Use the response and rich-media nodes in this section only when `channel` is `WHATSAPP`.
Instagram and Web Chat have dedicated node names below.

#### `text_response`
Send a text message.
```json
"content": { "message": "{{llm_response}}" }
```
- **`message`** required, interpolates `{{}}`. Transitions: `default`.

#### `interactive_response`
Send up to 3 reply buttons, then **wait** for a tap. See §4.4.
```json
"content": { "header": "", "body": "Choose:", "footer": "",
  "buttons": [ { "id": "opt1", "title": "Option 1" }, { "id": "opt2", "title": "Option 2" } ] }
```
- **`body`** required. `buttons[]`: each `{ id, title }` (title ≤ 20 chars; max 3 buttons).
- Transitions: one per button `id` + `default`.

#### `multi_interactive_response` *(WhatsApp only)*
Send a **sequence** of button messages (each ≤ 3 buttons), then wait for any tap.
```json
"content": { "messages": [
  { "body": "Page 1", "buttons": [ {"id":"a","title":"A"}, {"id":"b","title":"B"}, {"id":"c","title":"C"} ] },
  { "body": "Page 2", "buttons": [ {"id":"d","title":"D"}, {"id":"e","title":"E"} ] }
] }
```
- Transitions: one key per button `id` across **all** messages + `default`.

#### `interactive_list_response` *(WhatsApp only)*
Send a list menu, then wait for a row selection.
```json
"content": { "header": "", "body": "Pick one:", "footer": "", "buttonText": "Menu",
  "sections": [ { "title": "Options", "rows": [ { "id": "r1", "title": "Row 1", "description": "" } ] } ] }
```
- **`body`**, **`buttonText`**, **`sections[]`** required. Each row `{ id, title, description? }`.
- Transitions: one per row `id` + `default`.

#### Media nodes: `send_image`, `send_video`, `send_audio`, `send_document`
```json
"content": { "sourceType": "url", "url": "https://…/file.jpg", "caption": "Optional" }
```
- **`sourceType`**: `"url"` or `"upload"`. For `url`, set **`url`**. For `upload`, the editor sets
  `mediaId`/`objectPath` (not hand-authorable). `send_document` also has `fileName`. `send_audio`
  has no caption.
- Transitions: `default`.

#### `send_location`
```json
"content": { "latitude": "-6.20", "longitude": "106.81", "name": "Office", "address": "Jakarta" }
```
- **`latitude`**, **`longitude`** required (strings, interpolatable). Transitions: `default`.

#### `send_contacts`
Send one or more contact cards.
```json
"content": { "contacts": [ { "name": { "formatted_name": "John Doe", "first_name": "John" },
  "phones": [ { "phone": "+62812…", "type": "CELL" } ] } ] }
```
- Each contact requires `name.formatted_name`. Transitions: `default`.

#### `send_cta_url`
Interactive message with a URL button.
```json
"content": { "headerType": "none", "body": "Learn more:", "footer": "", "buttonText": "Open", "buttonUrl": "https://example.com" }
```
- **`body`**, **`buttonText`** (≤20 chars), **`buttonUrl`** required. `headerType`:
  `none|text|image|document` (+ matching `headerText`/`headerImageUrl`/`headerDocumentUrl`).
- Transitions: `default`.

#### `send_whatsapp_native_flow` *(WhatsApp only)*
Send a WhatsApp Flow (native form).
```json
"content": { "nativeFlowId": "<flow id>", "bodyText": "Complete this form", "ctaText": "Open",
  "headerText": "", "footerText": "", "startScreenId": "SCREEN_ONE", "flowAction": "navigate",
  "prefill": [ { "key": "name", "value": "{{contact_name}}" } ] }
```
- **`nativeFlowId`** required (environment-specific). `flowAction`: `navigate|data_exchange`.
  `prefill` values interpolate. Transitions: `default`.

### 6.5 Instagram response nodes *(INSTAGRAM channel only)*

- `ig_text_response` — `{ "message": "{{llm_response}}" }`; transition `default`.
- `ig_interactive_response` — `{ "body": "Choose:", "buttons": [{ "id": "help", "title": "Help" }] }`.
  It sends Instagram quick replies, pauses, and routes by button `id` plus `default`.
- `ig_image` — `{ "sourceType": "url", "url": "https://…/image.jpg", "caption": "Optional" }`;
  transition `default`.

Do not use WhatsApp media, catalog, native-flow, list, CTA, location, contact, or document nodes
in an Instagram agent.

### 6.6 Web Chat response nodes *(WEBCHAT channel only)*

Use these **instead of** the WhatsApp/IG response nodes when `channel` is `WEBCHAT`:

- `web_text_response` — `{ "message": "…" }`
- `web_interactive_response` — same shape as `interactive_response` (buttons; waits).
- `web_list_response` — same shape as `interactive_list_response` (list; waits).

Transition rules are identical to their WhatsApp equivalents (§4.4).

### 6.7 Catalog nodes *(WhatsApp only)*

All take a local **`catalogId`** (`ProductCatalog.id`, environment-specific).

| Type | Purpose | Key content fields | Transitions |
|---|---|---|---|
| `send_single_product` | Send one product | `catalogId`, `productRetailerId`, `bodyText?`, `footerText?` | `default` |
| `send_multi_product` | Product list message | `catalogId`, `headerText`, `bodyText`, `sections[]` (`{title, productRetailerIds[]}`) | `default` |
| `send_catalog` | Full catalog link | `catalogId`, `bodyText`, `thumbnailProductRetailerId` | `default` |
| `search_catalog_product` | Search → store matches | `catalogId`, `searchQuery`, `searchField` (`name\|retailer_id\|category\|all`), `maxResults`, `outputVariable` | `default` |
| `get_product_list` | Fetch list → store | `catalogId`, `maxResults`, `outputVariable` | `default` |
| `get_single_product` | Fetch one → store | `catalogId`, `productRetailerId`, `outputVariable` | `default` |

`productRetailerId` / `searchQuery` interpolate `{{}}` (e.g. `"{{query}}"`).

### 6.8 Actions & handoff

#### `perform_action`
Run one or more side-effecting actions, then branch `success`/`failure`.
```json
"content": {
  "actions": [
    { "action": "contact_field_set", "fieldLabel": "email", "fieldValue": "{{extracted_params.email}}" },
    { "action": "ticket_create", "ticketTitle": "Support: {{query}}", "ticketPriority": "MEDIUM", "ticketNumberVariable": "ticket_no" }
  ],
  "errorMode": "fail_fast"
}
```
- `actions[]` runs steps sequentially. (Legacy: a single action's fields may live directly on
  `content` with no `actions[]`.)
- `action` values: `contact_field_set`, `contact_field_delete`, `contact_field_read`,
  `segment_add`, `end_session`, `ticket_create`, `ticket_modify`, `ticket_read`.
- Per-action fields (only set the ones relevant to the chosen action):
  - Contact fields: `fieldLabel`, `fieldValue`, `fieldOutputVariable` (read → stores value).
  - `segment_add`: `segmentId`.
  - `end_session`: `endMessage`.
  - `ticket_create`: `ticketTitle`, `ticketPriority` (`LOW|MEDIUM|HIGH|URGENT`), `ticketNumberVariable`.
  - `ticket_modify`/`ticket_read`: `ticketTargetMode` (`variable|contact_latest`),
    `ticketNumberSource`, `ticketStatus`, `ticketSetPriority`, `ticketAssigneeId`, `ticketNote`,
    `ticketOutputPrefix`.
- `errorMode`: `fail_fast` (default) | `continue`.
- Transitions: `success` and `failure` (fall back to `default` if you only have one path).

#### `handoff`
Escalate the chat to a human. Terminal.
```json
"content": { "message": "Connecting you with an agent…", "assignmentMode": "none", "assignToAgentId": "" }
```
- `assignmentMode`: `none` (escalate only) | `specific` (assign to `assignToAgentId`) | `auto`
  (run org assignment rules). Transitions: none needed.

#### `execute_flow` *(WhatsApp only)*
Hand control to a Journey (Flow). Terminal for the agent.
```json
"content": { "selectionMode": "static", "targetFlowId": "<Flow id>", "targetFlowVariable": "", "passContext": true }
```
- `selectionMode`: `static` (use `targetFlowId`) | `dynamic` (use variable named in
  `targetFlowVariable`). `passContext`: carry current variables into the Flow's initial context.

### 6.9 Email

#### `send_email`
```json
"content": { "to": "ops@acme.com,{{contact_email}}", "subject": "New lead: {{contact_name}}", "body": "…\n…", "replyTo": "" }
```
- **`to`** (comma-separated), **`subject`**, **`body`** required; all interpolate. Transitions: `default`.

---

## 7. Channel rules (which node types are valid)

All channels support the shared control/data/AI/integration nodes:
`start`, `end`, `condition`, `condition_v2`, `datetime`, `set_variable`,
`variable_aggregator`, `iteration`, `delay`, `wait_input`, `wait_file`, `dynamic_list`,
`table_lookup`, `decision_table`, `llm`, `knowledge_retrieval`, `question_classifier`,
`parameter_extractor`, `http_request`, `code`, `perform_action`, `handoff`, and `send_email`.

Add only the response nodes for the declared channel:

- **WHATSAPP**: `text_response`, `interactive_response`, `multi_interactive_response`,
  `interactive_list_response`, `send_image`, `send_video`, `send_audio`, `send_document`,
  `send_location`, `send_contacts`, `send_cta_url`, `send_whatsapp_native_flow`, `execute_flow`,
  and catalog nodes.
- **INSTAGRAM**: `ig_text_response`, `ig_interactive_response`, `ig_image`.
- **WEBCHAT**: `web_text_response`, `web_interactive_response`, `web_list_response`.

The import endpoint preserves node JSON with minimal envelope validation, but publishing validates
node content and Structured Data dependencies. Do not rely on import alone as proof that a node is
valid for the target channel.

---

## 8. Complete worked example

A WhatsApp support agent that: retrieves knowledge → answers with an LLM → offers a "talk to
human" button → escalates on request.

```json
{
  "_format": "orquesta-pipeline-v1",
  "name": "Support Agent with KB + Handoff",
  "description": "RAG answer, then offer human handoff",
  "channel": "WHATSAPP",
  "sessionTimeoutMinutes": null,
  "entryNodeId": "start",
  "nodes": [
    { "id": "start", "type": "start", "name": "Start", "content": {},
      "positionX": 0, "positionY": 0, "transitions": { "default": "kb" } },

    { "id": "kb", "type": "knowledge_retrieval", "name": "Search KB",
      "content": { "datasetIds": ["<DATASET_ID>"], "queryVariable": "query", "topK": 5, "scoreThreshold": 0.5, "outputVariable": "kb_results" },
      "positionX": 300, "positionY": 0, "transitions": { "default": "answer" } },

    { "id": "answer", "type": "llm", "name": "Answer",
      "content": {
        "modelId": "<MODEL_ID>",
        "systemPrompt": "You are Acme's support assistant. Answer only from the provided context. If unsure, say you'll connect a human.",
        "userPrompt": "{{query}}",
        "temperature": 0.3,
        "memory": { "enabled": true, "maxMessages": 10, "windowType": "message_count" },
        "contextVariables": ["kb_results"],
        "vision": { "enabled": false, "detail": "low" },
        "outputVariable": "llm_response"
      },
      "positionX": 600, "positionY": 0, "transitions": { "default": "reply" } },

    { "id": "reply", "type": "text_response", "name": "Send answer",
      "content": { "message": "{{llm_response}}" },
      "positionX": 900, "positionY": 0, "transitions": { "default": "offer" } },

    { "id": "offer", "type": "interactive_response", "name": "Offer handoff",
      "content": { "body": "Did that help, or would you like to talk to a person?",
        "buttons": [ { "id": "human", "title": "Talk to a human" }, { "id": "done", "title": "All good" } ] },
      "positionX": 1200, "positionY": 0,
      "transitions": { "human": "escalate", "done": "bye", "default": "bye" } },

    { "id": "escalate", "type": "handoff", "name": "Escalate",
      "content": { "message": "Connecting you with an agent…", "assignmentMode": "auto" },
      "positionX": 1500, "positionY": -100, "transitions": {} },

    { "id": "bye", "type": "text_response", "name": "Goodbye",
      "content": { "message": "Thanks for reaching out! 👋" },
      "positionX": 1500, "positionY": 100, "transitions": {} }
  ],
  "exportedAt": "2026-07-07T00:00:00.000Z"
}
```

---

## 9. Authoring checklist (validate before importing)

1. `_format` is exactly `"orquesta-pipeline-v1"`; `name` and `nodes[]` are present.
2. There is exactly **one** `start` node and `entryNodeId` equals its `id`.
3. Every node `id` is **unique**.
4. Every id referenced in `entryNodeId` and in any `transitions` value **exists** in `nodes[]`.
5. Transition **keys** match the node type's contract (§4): `default` for linear; condition ids;
   class ids; button/row ids; wait outcomes; data outcomes; `body`/`done`; `success`/`failure`.
6. Interactive/list nodes that expect a reply have a transition for **each** button/row id, plus a
   `default`.
7. Loop bodies (`iteration`) end by transitioning **back** to the iteration node.
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`,
    `assignToAgentId`, `targetFlowId`, `segmentId`) are either real values for the target org or
    clearly-marked placeholders for the user to fill in the editor.
11. Keep the graph small enough to stay well under the **50-node-execution** cap per message.
12. Every `table_lookup`/`decision_table` `dataSourceKey` exists in the target organization and has
    a published revision before the agent is published.
13. A waiting node has explicit success, invalid/error, and timeout paths appropriate to its
    content; `wait_file` is not used as a multi-file accumulator.
14. Channel-specific response nodes match `channel`; do not mix WhatsApp, Instagram, and Web Chat
    messaging nodes.
