# Simple Flow — AI API Guide

> Written for an AI agent. Read top to bottom once; then use §3 (playbook) and §5 (reference).
> Progressively updated — see the changelog at the end.

Simple Flow is a small n8n-style workflow builder. A **flow** is a graph of **nodes** joined by **edges**. This API lets you **list and create** flows and **read, search, create, edit, delete** nodes and edges of a flow, **test** a single node, and **run** the flow — using a personal API token the user created on **Settings → API**.

---

## 0. Quick start for agents

```text
1. GET  /me            → confirm the token works; note `access` (read | write)
   GET  /flows         → the user's flows; pick the flowId (or POST /flows { name } to create one — write)
2. GET  /node-types    → the live catalog: node types, config fields, defaults, merge-field syntax
3. GET  /flows/{flowId}/graph?includeOutput=1 → what exists now (+ last run outputs)
4. Edit: POST/PATCH/DELETE nodes & edges, or PUT /graph to rebuild the whole flow
5. POST /flows/{flowId}/nodes/{id}/test with sample items → check one node cheaply
6. POST /flows/{flowId}/execute → poll GET …/executions/{executionId} until status ≠ "running"
7. Report what you changed, using node ids (node1, node2 …) and labels

Outbound WhatsApp: `sendWhatsapp` node, reply mode by default (§4e). Inbound: `whatsappTrigger` (§4d). Log rows to a sheet: `googleSheetsAppend` (§5).
Grounded answers (RAG): `llmChain.config.tools` with a Knowledge binding (§4f) — the model searches the user's knowledge bases before answering.
Multi-turn conversations: `wait` node (§4g) — pause until the customer's next WhatsApp message, then continue with `{{body}}`.
Full worked build of a business bot (research → knowledge base → flow → tests): §4h.
```

Never assume config shapes from memory — `GET /node-types` is the source of truth.

---

## 1. Credentials

The user creates a **personal API token** in the app: avatar menu → **API** → **Create token** (name · access *Read only* / *Read & write* · resources *Knowledge* / *Workflows* · expiry). The page's **Copy JSON** gives:

```json
{
  "apiToken": "djc_…",
  "orgId": "d3fe674f-…",
  "userId": "Qrpc3k06…",
  "resources": {
    "workflows": { "access": "write", "baseUrl": "https://<domain>/api/ai/simple-flow", "guide": "apps/api/guides/simple-flow/AI_API_GUIDE_WORLFLOWS.md", "firstCall": "GET /me then GET /node-types" }
  }
}
```

A flow's own **⚙ Settings** dialog gives the same shape plus `flowId` for that flow.

| Value | Use |
|---|---|
| `apiToken` | Every request: `Authorization: Bearer djc_…` (or header `X-API-Key: djc_…`). Local dev base URL: `http://localhost:8787/api/ai/simple-flow`. |
| `access` | `read` → GET routes only (flows, graph, nodes, edges, executions). `write` → everything else (create flows, edit nodes/edges, `test`, `execute`). A write call with a read token → `403 INSUFFICIENT_SCOPE`. |
| `orgId`, `userId` | Informational. The token acts *as this user in this org* — it sees the flows this user owns there; LLM nodes spend **this user's credits**. |

Sanity check:

```bash
BASE=…; H='Authorization: Bearer djc_…'
curl -s -H "$H" $BASE/me
# {"userId":"…","orgId":"…","tokenKind":"token","access":"write","scopes":["workflows:write"],"flowId":null}
curl -s -H "$H" $BASE/flows        # [{ id, name, active, nodeCount, edgeCount, createdAt, updatedAt }]
```

**Legacy keys** (`sfk_…`) still work but are bound to one flow: `/me` returns that `flowId`, and `GET /flows` / `POST /flows` answer `403`. If the token is placeholder text (`<create or reveal a token …>`), ask the user to create one on Settings → API.

---

## 2. Data model — read this before editing

### Nodes, edges, ids
- **Node ids** are human-readable: `node1`, `node2`, … Auto-assigned when you omit `id`. Keep them stable — the user sees them on the canvas and prompts may reference them.
- **Edge** = `{ source, target, sourceHandle? }`. **One edge carries both execution order and data**: the source's output items flow into the target. No separate "exec" pins.
- **Start**: every node with no incoming edge is a start node — a manual `trigger` and/or an event trigger (`whatsappTrigger`). A manual **execute** seeds *every* start node (the WhatsApp trigger emits its `sampleData`); a real WhatsApp message seeds **only** the matching `whatsappTrigger` node(s).
- **End**: `output` ("Respond") nodes have no outgoing pin; whatever reaches them is the flow's result.
- Rules enforced by the API: no self-loops, no duplicate edge between the same pins, `trigger` / `whatsappTrigger` have no input pin, `output` has no output pin, unknown node types rejected.

### Items
Data moves as an **array of items**; each item is `{ "json": { …fields } }`. A node receives items and emits items.

| Node | Emits |
|---|---|
| `trigger` | Its `config.sampleData` — an object → 1 item; an array → one item per element. (Manual runs only.) |
| `whatsappTrigger` | Event-driven. Guarded per chat by `config.guards` (default 30/min, loop detector, 15-min cooldown — see §5). When the workflow is **active** and a WhatsApp Web account in the org receives a message matching the node's filters, a run starts with ONE item: `{ orgId, clientId, chatRoomId, messageId, timestamp, fromMe, isGroup, type, body, from: { lid, phone, name, username, jid }, media?: { url, mimeType, mediaType, fileName, fileSize, caption }, poll?, pollVote?, interactive?, quoted?, mentions?, isForwarded }`. Only the matching trigger node(s) are seeded — a plain `trigger` in the same flow does not run. Test step / manual Run emit `config.sampleData` instead (`GET /workflows/whatsapp-trigger/sample` on the org API returns the latest real message in that shape). |
| `llmChain` | **Replaces** the item: `{ text }` (outputType `text`) or the JSON fields you defined (outputType `json`), plus `_llm: { model, inputTokens, outputTokens, credits, steps? }`. Incoming fields are **not** carried through — reference them later with `{{[nodeN].field}}` if needed. Runs once per input item. With `config.tools` (§4f) the item also carries `_tools: [{ tool, mode, args: { query }, resultCount, ms, error? }]`. |
| `sendWhatsapp` | Sends one WhatsApp message per input item through a WhatsApp Web account (max 20/step). `mode: "reply"` (default) answers the message that started the run — same account and chat, no config needed; `mode: "custom"` uses `account` + `to`. `message` defaults to `{{text}}` so it can sit straight after an LLM node. Emits the input item plus `_whatsapp: { sent, messageId, account, to, body }`. Reply mode errors on Test step / manual Run (no triggering message). |
| `googleSheetsAppend` | **Add to Google Sheet.** One row per input item (max 200/step) using a Google account connected to the org (`connectionId`), aligned to the tab's header row (row 1; missing headers are added). `operation: "append"` (default) or `"upsert"` (match `identifierHeader`'s value → update that row, else append). `mode: "columns"` writes `columns[]` (`{ header, value }`, merge fields + `{{$now}}`); `mode: "auto"` writes every top-level field of the item. Emits each input item plus `_sheets: { spreadsheetId, sheetName, account, operation, action, row, updatedRange, updatedRows }`. Test step really writes. |
| `httpRequest` | One item. JSON responses → the parsed body **is** the item's `json` (e.g. `{{login}}` for a GitHub user). Other content types → `{ body: "<text>", status }`. Non-2xx throws (node error) unless `ignoreResponseCode`. Runs once per execution, not per input item. |
| `condition` (If) | Two outputs. Each item goes to `true` **or** `false`, with `_if: { result, matched[] }` added. |
| `wait` | **Pauses the run** (execution status `waiting`, no credits) until the next WhatsApp message in the conversation (`mode: "whatsappReply"`) or a fixed delay (`mode: "delay"`). Two outputs: `received` emits the reply as a `whatsappTrigger`-shaped item (`{ body, from, type, media… }` + `_wait: { mode, waitedMs, resumedBy }`); `timeout` passes the input item through with `_wait.timedOut`. While a run waits on a chat, the next message from that chat **resumes it instead of starting a new run** of the same flow (§4g). |
| `output` | Passes items through; terminal. |

A branch that receives **zero items does not run** its downstream nodes.

### Merge fields (in any string config: prompts, URLs, condition operands)

| Syntax | Resolves to |
|---|---|
| `{{field}}`, `{{a.b.c}}` | value from the **incoming** item (direct parent's output) |
| `{{$json}}` | the whole incoming item as JSON |
| `{{[node1].field}}` | value from `node1`'s **first** output item — any earlier node in the run |
| `{{[node1].$json}}` | `node1`'s whole first item |

Missing values become empty strings. Text that looks like code (`{{ key: value }}`, quotes) is left alone.
**Typical mistake:** after an LLM node the incoming item is `{ text, _llm }` — so `{{name}}` is empty; use `{{[node1].name}}`.

### If node conditions
`config.conditions[] = { left, operator, right }`, joined by `combinator: "and" | "or"`, `caseSensitive`. Both sides accept merge fields or literals. Numbers, booleans (`true/yes/1`), and dates compare **by value**. Unary operators (`exists`, `is_empty`, `is_true`, …) ignore `right`. Full operator list: `GET /node-types` → `condition.configSchema`.

Edges leaving an If node must say which branch: `sourceHandle: "true" | "false"` (defaults to `"true"`).

---

## 3. Playbook — how to work on a user's flow

1. **Inspect first**: `GET /graph?includeOutput=1`. Summarise nodes (id · label · type) back to the user before changing anything they didn't ask you to change.
2. **Prefer surgical edits** to existing flows: `PATCH …/nodes/{id}` with `configPatch` (shallow merge) so unrelated settings survive. Use `PUT /graph` only to build a flow from scratch or when the user asked for a rebuild — it replaces **everything**, including nodes the user made.
3. **Keep ids and labels meaningful**: set `label` to what the node does ("Translate → Chinese"), `description` to a one-liner.
4. **Lay out left → right**: `position.x` in steps of ~320, `y ≈ 200`; branches go `y − 140` / `y + 140`. Positions are cosmetic but the user looks at the canvas.
5. **Test before running**: `POST …/nodes/{id}/test` with representative `inputItems` (and `nodeOutputs` if the node references `[nodeN]`). For an If node the response has `outputs: [trueItems, falseItems]`.
6. **Run and verify**: `POST /execute` → poll `GET …/executions/{id}` every 1–3 s until `status` is `success` or `error`. Read `log[]` per node (`output`, `outputs`, `error`).
7. **Costs**: `llmChain` spends the user's credits on every test and run. Use small `maxOutputTokens`, `temperature: 0` for deterministic tasks, and don't loop tests needlessly. For static text there is currently no "Set" node — a tiny fixed-prompt LLM node is the workaround.
8. **Concurrency**: the user's canvas live-syncs your edits within ~4 s. Their own saves are rejected (409) if they'd overwrite yours; **your** writes are last-wins, so re-`GET` before a `PUT /graph` if they may be editing.
9. **Don't**: invent node types, delete the user's nodes to "simplify", change the trigger's `sampleData` to something unrelated, or reference node ids you haven't seen in `GET /graph`.
10. **WhatsApp-triggered flows**: give the `whatsappTrigger` a realistic `sampleData` (the item shape in §2 — the user can also click *Load latest received message* in the editor) so `execute` and node tests exercise the flow without waiting for a real message. Keep `includeFromMe: false` unless the flow never replies on WhatsApp. Downstream, read the message with `{{body}}`, `{{from.name}}`, `{{from.phone}}`, `{{type}}`, `{{media.url}}` — or `{{[nodeN].body}}` after an LLM node has replaced the item.
11. **Sending on WhatsApp (`sendWhatsapp`)**: prefer `mode: "reply"` — it answers the customer on the same account and chat with zero config and can't be pointed at the wrong number. Put it *after* the node whose output is the text (its `message` defaults to `{{text}}`, an LLM node's output) and *before* the Respond node so the run log still shows what was sent. Use `mode: "custom"` only for notifications to a fixed number (an agent, a group) — and for **Test step**, since reply mode has no triggering message outside a real run. Never test a custom send against a customer's number; use the user's own number. Remember the flow must be `active` for replies to go out, and warn the user that activating a flow with a send node means real customers get automatic replies.

---

## 4. Worked examples

### 4a. Build from scratch: sentence → Chinese → Japanese → Korean

```bash
BASE=…; H='Authorization: Bearer sfk_…'; F=<flowId>
curl -s -H "$H" -H 'Content-Type: application/json' -X PUT $BASE/flows/$F/graph -d '{
  "nodes": [
    { "id":"node1","type":"trigger","label":"Sentence in",
      "config":{"sampleData":{"sentence":"The quick brown fox jumps over the lazy dog."}},"position":{"x":0,"y":200} },
    { "id":"node2","type":"llmChain","label":"Translate → Chinese",
      "config":{"model":"qwen3.7-flash","temperature":0.2,"maxOutputTokens":400,"outputType":"text",
        "systemPrompt":"You are a professional translator. Reply with the translation only.",
        "userPrompt":"Translate into Simplified Chinese:\n{{sentence}}"},"position":{"x":320,"y":200} },
    { "id":"node3","type":"llmChain","label":"Translate → Japanese",
      "config":{"model":"qwen3.7-flash","temperature":0.2,"maxOutputTokens":400,"outputType":"text",
        "systemPrompt":"You are a professional translator. Reply with the translation only.",
        "userPrompt":"Translate into Japanese:\n{{text}}"},"position":{"x":640,"y":200} },
    { "id":"node4","type":"llmChain","label":"Translate → Korean",
      "config":{"model":"qwen3.7-flash","temperature":0.2,"maxOutputTokens":400,"outputType":"text",
        "systemPrompt":"You are a professional translator. Reply with the translation only.",
        "userPrompt":"Translate into Korean:\n{{text}}"},"position":{"x":960,"y":200} },
    { "id":"node5","type":"output","label":"Respond","position":{"x":1280,"y":200} }
  ],
  "edges": [ {"source":"node1","target":"node2"},{"source":"node2","target":"node3"},
             {"source":"node3","target":"node4"},{"source":"node4","target":"node5"} ]
}'
```
Note `{{sentence}}` on the first LLM node (from the trigger) and `{{text}}` afterwards (each LLM emits `{ text }`).

### 4b. Branching: number > 100 → "You are a VIP", else "Hello"

```json
{ "nodes": [
  { "id":"node1","type":"trigger","label":"Number in","config":{"sampleData":[{"number":250},{"number":42}]},"position":{"x":0,"y":260} },
  { "id":"node2","type":"condition","label":"Bigger than 100?",
    "config":{"combinator":"and","caseSensitive":false,
      "conditions":[{"left":"{{number}}","operator":"greater_than","right":"100"}]},"position":{"x":320,"y":260} },
  { "id":"node3","type":"llmChain","label":"Say: You are a VIP",
    "config":{"model":"qwen3.7-flash","temperature":0,"maxOutputTokens":20,"outputType":"text",
      "systemPrompt":"Reply with exactly the text the user gives you.","userPrompt":"You are a VIP"},"position":{"x":660,"y":120} },
  { "id":"node4","type":"llmChain","label":"Say: Hello",
    "config":{"model":"qwen3.7-flash","temperature":0,"maxOutputTokens":20,"outputType":"text",
      "systemPrompt":"Reply with exactly the text the user gives you.","userPrompt":"Hello"},"position":{"x":660,"y":400} },
  { "id":"node5","type":"output","label":"VIP reply","position":{"x":1000,"y":120} },
  { "id":"node6","type":"output","label":"Hello reply","position":{"x":1000,"y":400} } ],
  "edges": [ {"source":"node1","target":"node2"},
             {"source":"node2","target":"node3","sourceHandle":"true"},
             {"source":"node2","target":"node4","sourceHandle":"false"},
             {"source":"node3","target":"node5"},{"source":"node4","target":"node6"} ] }
```
Run result: `250` → true → "You are a VIP"; `42` → false → "Hello".

### 4c. Incremental edit: add a structured-JSON parser after the trigger

```bash
curl -s -H "$H" -H 'Content-Type: application/json' -X POST $BASE/flows/$F/nodes -d '{
  "type":"llmChain","label":"Parse stock id","connectFrom":"node1",
  "config":{"model":"qwen3.7-flash","systemPrompt":"You are a data parser.",
    "userPrompt":"Message: <message>{{body}}</message>","outputType":"json",
    "outputFields":[{"name":"stockId","type":"string","description":"Product code mentioned"},
                    {"name":"stockIdReceived","type":"boolean","description":"Whether a code was found"}]}
}'
# → 201 { "id": "node2", … }   (add "connectTo": "node9" to also wire its output)
# Then test it:
curl -s -H "$H" -H 'Content-Type: application/json' -X POST $BASE/flows/$F/nodes/node2/test \
  -d '{"inputItems":[{"json":{"body":"What about PRODUCTC"}}]}'
# → {"status":"success","output":[{"json":{"stockId":"PRODUCTC","stockIdReceived":true,"_llm":{…}}}]}
```

---

### 4d. WhatsApp concierge: incoming message → triage → lead reply / escalation / general reply

Built with surgical calls (node ids given explicitly so edges can be added in the same batch). The trigger listens on every WhatsApp Web account in the org, text messages only.

```bash
# trigger (already on the canvas as node10) — filters + a captured sample message
curl -s -H "$H" -H 'Content-Type: application/json' -X PATCH $BASE/flows/$F/nodes/node10 -d '{
  "label":"WhatsApp message in",
  "configPatch":{"accounts":[],"includeFromMe":false,"messageTypes":["text"],
    "match":{"mode":"none","value":"","caseSensitive":false},
    "sampleData":{"event":"message.received","clientId":"daveckwgmailcom",
      "chatRoomId":"daveckwgmailcom-6012…-1234…","messageId":"3EB0SAMPLE01","timestamp":"2026-08-29T09:15:00Z",
      "fromMe":false,"isGroup":false,"type":"text",
      "body":"Hi, saya ada unit kat Mossaz Tower, nak cari short term rental operator. Urgent sikit sebab tenant keluar hujung bulan ni.",
      "from":{"lid":"1234…","phone":"60122211190","name":"Joe Chin","username":"","jid":"1234…@lid"},"isForwarded":false}}}'

# triage: one JSON LLM node reading the trigger item directly
curl -s -H "$H" -H 'Content-Type: application/json' -X POST $BASE/flows/$F/nodes -d '{
  "id":"node11","type":"llmChain","label":"Understand message","connectFrom":"node10",
  "config":{"model":"qwen3.7-flash","temperature":0,"maxOutputTokens":400,"outputType":"json",
    "systemPrompt":"You are the triage assistant for a Malaysian property agency WhatsApp line. Messages may be English, Malay, Chinese or mixed. isLead is true only when the sender wants to buy, sell, rent, rent out or engage a property service.",
    "userPrompt":"Sender: {{from.name}} ({{from.phone}})\nMessage:\n<message>{{body}}</message>",
    "outputFields":[{"name":"language","type":"string","description":"Primary language / mix"},
      {"name":"intent","type":"string","description":"enquiry | booking | complaint | greeting | spam | other"},
      {"name":"isLead","type":"boolean","description":"Potential customer?"},
      {"name":"urgency","type":"number","description":"1 (none) – 5 (human within the hour)"},
      {"name":"summaryEn","type":"string","description":"One-sentence English summary"}]}}'
```

Then: `node12` If `{{isLead}}` **is_true** → `node13` LLM "Draft reply → lead" (text, prompt uses `{{[node10].from.name}}`, `{{[node11].language}}`, `{{[node11].summaryEn}}`, `{{[node10].body}}`; *reply in the same language the customer used*) → `node14` output. The **false** branch goes to `node15` If `{{urgency}}` **greater_or_equal** `4` → `node16` LLM "Escalation note" → `node17` output; else `node18` LLM "Draft reply → general" → `node19` output.

Run result for the sample: `node11` → `{ language: "Mixed (Malay/English)", intent: "enquiry", isLead: true, urgency: 4, summaryEn: "Sender seeks a short-term rental operator for Mossaz Tower …" }` → `node12` true → `node13` → *"Hi Joe, saya boleh bantu cari short-term rental operator untuk unit Mossaz Tower anda … Bila masa yang paling convenient untuk you?"*. A greeting ("Hello, selamat pagi 😊") classifies as `intent: "greeting", isLead: false, urgency: 1` and takes the general-reply path.

To actually answer the customer, put a `sendWhatsapp` node (default config — reply mode, `message: "{{text}}"`) between each reply-drafting LLM node and its Respond node, e.g. `node13 → node20 (sendWhatsapp) → node14`.

Make it live: `PATCH /flows/{flowId} { "active": true }` — from then on every inbound WhatsApp Web text message in the org starts a run (see it under *Runs*).

### 4e. Auto-reply: draft with an LLM, send it on WhatsApp

Continuing 4d — turn the drafts into real replies. Insert a `sendWhatsapp` between the drafting LLM node and its Respond node with `connectFrom` / `connectTo` (the API does not auto-remove the old direct edge — delete it first).

```bash
# 1. drop the direct edge draft → respond
curl -s -H "$H" -X DELETE "$BASE/flows/$F/edges?source=node13&target=node14"

# 2. send node in REPLY mode: same account + chat as the triggering message, quotes it
curl -s -H "$H" -H 'Content-Type: application/json' -X POST $BASE/flows/$F/nodes -d '{
  "id":"node20","type":"sendWhatsapp","label":"Send reply → lead",
  "config":{"mode":"reply","quoteOriginal":true,"message":"{{text}}"},
  "position":{"x":1360,"y":760},"connectFrom":"node13","connectTo":"node14"}'
# → 201. Edges now: node13 → node20 → node14
```

`{{text}}` is node13's output (an LLM node in `text` mode emits `{ text }`). Anything else works too: `"message":"Hi {{[node11].senderName}}, {{text}}"`.

**Testing it.** Reply mode needs a triggering message, so a Test step on node20 returns
`{"status":"error","error":"No triggering WhatsApp message in this run …"}`. To check delivery, temporarily switch the node to a custom target — the user's own number, never a customer's — test, then switch back:

```bash
curl -s -H "$H" -H 'Content-Type: application/json' -X PATCH $BASE/flows/$F/nodes/node20   -d '{"configPatch":{"mode":"custom","account":"daveckwgmailcom","to":"601155085018"}}'
curl -s -H "$H" -H 'Content-Type: application/json' -X POST $BASE/flows/$F/nodes/node20/test   -d '{"inputItems":[{"json":{"text":"Test from Simple Flow"}}]}'
# → {"status":"success","output":[{"json":{"text":"Test from Simple Flow",
#      "_whatsapp":{"sent":true,"messageId":"3EB0…","account":"daveckwgmailcom","to":"+601155085018","body":"Test from Simple Flow"}}}]}
curl -s -H "$H" -H 'Content-Type: application/json' -X PATCH $BASE/flows/$F/nodes/node20   -d '{"configPatch":{"mode":"reply","account":"","to":""}}'
```
Accounts you can send from: `GET /api/orgs/{orgId}/chat/accounts` on the org API — entries with `phoneNumberId: null`; the node rejects any clientId not paired in the org.

**Custom mode** for notifications: `{"mode":"custom","account":"daveckwgmailcom","to":"60123456789","message":"🔥 Lead: {{[node11].summaryEn}} — {{[node10].from.phone}}"}`. `to` accepts digits with country code, `<lid>@lid`, or a merge field like `{{[node10].from.jid}}`. Add `"mediaUrl":"https://…/brochure.pdf"` to send a file with the text as caption.

**Loop safety.** The reply is sent from the same account the trigger listens on, so it comes back as a `fromMe` echo. The trigger ignores echoes unless `includeFromMe` is on — leave it off on any flow that sends.

### 4f. Ground the reply in a Knowledge base (RAG tool)

Knowledge bases are built in the app (Simple Flow → Knowledge; agents use `apps/api/guides/knowledge/AI_API_GUIDE_KNOWLEDGE.md`). List the org's bases with `GET /api/orgs/{orgId}/knowledge` on the org API (`[{ id, name, description, entryCount }]`) and bind one or more to an LLM node under `config.tools`:

```bash
curl -s -H "$H" -H 'Content-Type: application/json' -X PATCH $BASE/flows/$F/nodes/node13 -d '{
  "configPatch": {
    "tools": [{
      "id": "tool1", "type": "knowledge", "enabled": true,
      "name": "search_knowledge",
      "description": "Search the agency FAQ: services, fees, office hours, areas covered. Use it for any question about the business.",
      "knowledgeBaseIds": ["5327d9b2-…"],
      "topK": 5, "minScore": 0.35,
      "mode": "tool",
      "includeSourceTitle": false
    }],
    "maxToolSteps": 3
  }
}'
```

- `mode: "tool"` — the model gets a `search_knowledge(query)` function and decides when to call it (best for chat replies; needs a tool-calling model — every model except `gemma-4-31B-it`).
- `mode: "always"` — the node searches once with the resolved user prompt and injects the matches into the system prompt as context. Predictable, works with every model, one model call.
- `description` is what the model reads to decide whether to search — say what the bases contain.
- Output: `_tools: [{ tool: "search_knowledge", mode: "tool", args: { query: "office hours" }, resultCount: 2, ms: 640 }]` and `_llm.steps` (model rounds). Queries with no match are logged as gaps in the knowledge base.
- Credits: all rounds are billed as one LLM call (`_llm.inputTokens` / `outputTokens` are totals). Keep `maxToolSteps` at 2–3.
- Test it: `POST …/nodes/node13/test` with an item whose prompt asks something the base knows; check `_tools` and the answer.

### 4g. Conversation: ask a question, wait for the answer, continue (Wait node)

```bash
curl -s -H "$H" -H 'Content-Type: application/json' -X POST $BASE/flows -d '{
  "name": "Qualify lead",
  "nodes": [
    { "id":"node1","type":"whatsappTrigger","config":{"messageTypes":["text"]} },
    { "id":"node2","type":"llmChain","label":"Ask area + budget",
      "config":{"model":"qwen3.7-flash","temperature":0.3,"maxOutputTokens":120,"outputType":"text",
        "systemPrompt":"You are a property agency assistant. Reply in the customer language, one short WhatsApp message.",
        "userPrompt":"The customer wrote: {{body}}\nAsk which area they are looking at and their budget."} },
    { "id":"node3","type":"sendWhatsapp","config":{"mode":"reply","message":"{{text}}"} },
    { "id":"node4","type":"wait","label":"Wait for their answer",
      "config":{"mode":"whatsappReply","conversation":"trigger","messageTypes":["text"],
        "timeout":{"enabled":true,"amount":30,"unit":"minutes"}} },
    { "id":"node5","type":"llmChain","label":"Reply with a match",
      "config":{"model":"qwen3.7-flash","temperature":0.3,"maxOutputTokens":300,"outputType":"text",
        "systemPrompt":"You are a property agency assistant. Use the knowledge tool for listings and prices.",
        "userPrompt":"First message: {{[node1].body}}\nTheir answer: {{body}}\nWrite the next WhatsApp reply.",
        "tools":[{"id":"tool1","type":"knowledge","enabled":true,"name":"search_knowledge","description":"Listings, areas, prices.","knowledgeBaseIds":["<kbId>"],"topK":5,"minScore":0.35,"mode":"tool","includeSourceTitle":false}]} },
    { "id":"node6","type":"sendWhatsapp","config":{"mode":"reply","message":"{{text}}"} },
    { "id":"node7","type":"output" },
    { "id":"node8","type":"sendWhatsapp","label":"No reply","config":{"mode":"reply","message":"No worries — message us anytime and we will pick it up."} },
    { "id":"node9","type":"output","label":"Timed out" }
  ],
  "edges": [
    {"source":"node1","target":"node2"},{"source":"node2","target":"node3"},{"source":"node3","target":"node4"},
    {"source":"node4","target":"node5","sourceHandle":"received"},{"source":"node5","target":"node6"},{"source":"node6","target":"node7"},
    {"source":"node4","target":"node8","sourceHandle":"timeout"},{"source":"node8","target":"node9"}
  ]
}'
```

How it behaves once `active`:
1. Customer writes → run starts, node2 drafts the question, node3 sends it, **node4 pauses the run** (`GET …/executions/{id}` → `status: "waiting"`, the wait entry has `waitingUntil` and `waitingFor: "reply from Joe"`). No credits are spent while waiting.
2. The customer's next text in that chat is delivered to the paused run — it does **not** start a second run of this flow (`onOtherMessage: "ignore"`, the default). node4 emits it on `received` with the trigger item shape, so `{{body}}` is the answer and `{{[node1].body}}` is still the first message. Reply-mode `sendWhatsapp` keeps answering the same chat.
3. No answer within 30 min → the `timeout` pin runs node8/node9. A teammate replying from the Inbox (a `fromMe` message) cancels the wait by default (`onAgentReply: "cancel"`; the run ends with `error: "Cancelled — a teammate replied in this chat"`).
4. Deactivating or deleting the flow cancels its waiting runs. Test step on a Wait node returns a simulated reply immediately (`_wait.simulated: true`) so downstream nodes can be tested.

Rules: one waiting run per (flow, chat) — a newer wait supersedes an older one; hard cap 7 days per wait and 200 waiting runs per org; `mode: "delay"` (`delay: { amount, unit }`) resumes on `received` after the time.

### 4h. Case study — Mana Mana Hotel customer service, end to end (research → knowledge → flow → tests)

What an agent actually did on 2026-08-29 when the user said *"use this token to create a workflow for Mana Mana customer service — Mana Mana is a hotel — study manamana.my and create the knowledge"*. Both APIs were used: this one (`$BASE` = `/api/ai/simple-flow`) and the Knowledge AI API (`$KB_BASE` = `/api/ai/knowledge`, guide `apps/api/guides/knowledge/AI_API_GUIDE_KNOWLEDGE.md`), with one personal token that carried both `workflows:write` and `knowledge:write`.

**1. Orient (no writes).** `GET /me` → `access: "write", scopes: ["knowledge:write","workflows:write"]`. `GET /flows` → three flows; the user pointed at `b0f44936…` ("DJC Knowledge Reply", 6 nodes) and later said *"you can clear the existing nodes"* — so `PUT /graph` on that flow, not `POST /flows`. `GET $KB_BASE/bases` → only a "DJC" base existed, so a new base was needed. Note: the *org* API (`/api/orgs/{orgId}/knowledge`) answers `401` to a personal token — list bases through `$KB_BASE/bases`.

**2. Research the business before writing a single entry.** Fetched manamana.my (home, `/properties`, every property page, `/about`, `/contact`, `/offers`, `/kaki`, `/terms`, `/book`, `/dine`) and kept a fact list *with the gaps marked* (no room rates, no per-property house rules, no payment methods on the site). Emails were Cloudflare-obfuscated (`[email protected]`) — decoded from the page source (`data-cfemail`) to `info@manamana.my`. Rule: what the site does not say, the knowledge base must not claim; those entries say "depends on property/rate — ask the team".

**3. Create the base and import curated Q&A verbatim (no LLM credits).**

```bash
curl -s -H "$H" -H 'Content-Type: application/json' -X POST $KB_BASE/bases \
  -d '{"name":"Mana Mana","description":"Mana Mana Hotel guest services FAQ: room types and rates, booking and cancellation policy, check-in/check-out times, breakfast and dining, facilities (pool, gym, parking, Wi-Fi), location and directions, airport transfer, payment methods, house rules."}'
# → { "id": "bf626ecf-…", … }   KB=bf626ecf-…

# 35 rows written by hand from the research — one fact per entry, altQuestions in Malay/Chinese for recall
curl -s -H "$H" -H 'Content-Type: application/json; charset=utf-8' -X POST $KB_BASE/bases/$KB/import --data-binary @rows.json
# → { "inserted": 10, "duplicates": 0, "conflicts": 0 }   (sent in batches; see gotcha below)
curl -s -H "$H" $KB_BASE/bases/$KB            # entryCount: 35
curl -s -H "$H" "$KB_BASE/bases/$KB/entries?status=conflicts"   # { items: [], total: 0 }
curl -s -H "$H" -H 'Content-Type: application/json' -X POST $KB_BASE/bases/$KB/search -d '{"query":"what time can I check in","topK":3}'
# → "What time is check-in and check-out?" first — retrieval works before any LLM is involved
```

Entry shape used (subjects reused consistently: Company, Contact, Properties, Rooms, Check-in, Booking, Policies, Offers, Long Stay, Membership, Facilities, Parking, Dining, Breakfast, Events, Payment, House Rules, Location, plus one subject per property):

```json
{ "subject": "Check-in", "question": "What time is check-in and check-out?",
  "altQuestions": ["Check in time", "Can I check in early or check out late?", "Pukul berapa check in?", "几点入住退房？"],
  "keywords": ["3:00 PM", "11:00 AM", "early check-in"],
  "answer": "Standard check-in is 3:00 PM and check-out is 11:00 AM at all Mana Mana properties. Early check-in, late check-out and other special requests are subject to availability — message us on WhatsApp with your booking details…" }
```

Gotcha hit here: one row had 16 `keywords` (every property name). `/import` rejected the **whole batch** with `rows: ["Array must contain at most 10 element(s)"]` — that message is about the row's `keywords` (≤ 10), not the number of rows. Trim and resend; nothing from the failed call was stored.

**4. Replace the graph in one atomic call.** Layout: x in steps of 320, main lane y = 200, branches y = 60 / 340. Every text the customer could see is written for a *hotel* (front desk, reservations, stay dates), and the trigger carries a realistic hotel `sampleData` so `test` / `execute` work without a live message.

```bash
curl -s -H "$H" -H 'Content-Type: application/json' -X PUT $BASE/flows/$F/graph --data-binary @graph.json
# graph.json (abridged — full config shapes come from GET /node-types)
{ "nodes": [
  { "id":"node1","type":"whatsappTrigger","label":"WhatsApp message in","position":{"x":0,"y":200},
    "config":{ "accounts":["daveckwgmailcom"],"messageTypes":["text"],"includeFromMe":false,
      "sampleData":{ …, "body":"Hi, do you have a deluxe room available this Saturday for 2 adults? What is the rate and is breakfast included? Also what time is check-in?", "from":{"name":"Sample Guest","phone":"60123456789",…} } } },
  { "id":"node2","type":"llmChain","label":"Understand message","position":{"x":320,"y":200},
    "config":{ "model":"qwen3.7-flash","temperature":0,"maxOutputTokens":300,"outputType":"json",
      "systemPrompt":"You are the triage assistant for Mana Mana Hotel guest services WhatsApp line in Malaysia … needsHuman is true ONLY when: the guest wants to confirm, modify or cancel an actual reservation; reports a problem during their stay or is upset; asks to speak to a person; or asks for something an FAQ cannot settle (group/event bookings, corporate rates …). General questions about rooms, rates, facilities, check-in times … are NOT needsHuman.",
      "userPrompt":"Customer: {{from.name}} ({{from.phone}})\nMessage:\n<message>{{body}}</message>",
      "outputFields":[ {"name":"language","type":"string"}, {"name":"intent","type":"string","description":"room_enquiry | booking | modify_or_cancel | check_in_out | facilities | dining | location_transport | in_stay_issue | complaint | feedback | greeting | spam | other"},
                       {"name":"needsHuman","type":"boolean"}, {"name":"stayDates","type":"string"}, {"name":"summaryEn","type":"string"} ] } },
  { "id":"node3","type":"condition","label":"Needs a human?","position":{"x":640,"y":200},
    "config":{ "combinator":"and","conditions":[{"left":"{{needsHuman}}","operator":"is_true","right":""}] } },
  { "id":"node4","type":"llmChain","label":"Front desk handover reply","position":{"x":960,"y":60},
    "config":{ "outputType":"text","temperature":0.3,"maxOutputTokens":250,
      "systemPrompt":"… reply in the same language the guest used ({{[node2].language}}) … say the Mana Mana Hotel front desk / reservations team will get back to them shortly in this chat. Do not confirm availability, prices, refunds, upgrades or timings …",
      "userPrompt":"Guest name: {{[node1].from.name}}\nWhat they want: {{[node2].summaryEn}}\nStay dates mentioned: {{[node2].stayDates}}\nTheir message:\n<message>{{[node1].body}}</message>" } },
  { "id":"node5","type":"sendWhatsapp","label":"Reply on WhatsApp","position":{"x":1280,"y":60},"config":{"mode":"reply","quoteOriginal":true,"message":"{{text}}"} },
  { "id":"node6","type":"output","label":"Handed to front desk","position":{"x":1600,"y":60} },
  { "id":"node7","type":"llmChain","label":"Answer from hotel knowledge","position":{"x":960,"y":340},
    "config":{ "outputType":"text","temperature":0.2,"maxOutputTokens":400,"maxToolSteps":3,
      "tools":[{ "id":"tool1","type":"knowledge","enabled":true,"name":"search_knowledge","mode":"always","topK":6,"minScore":0.35,
                 "knowledgeBaseIds":["bf626ecf-…"],
                 "description":"Mana Mana Hotel guest FAQ: room types and rates, booking and cancellation policy, check-in/check-out times, breakfast and dining, facilities, location, airport transfer, payment methods, house rules." }],
      "systemPrompt":"You are the guest services assistant of Mana Mana Hotel on WhatsApp, talking to {{[node1].from.name}}. Relevant entries from the hotel FAQ are provided as context. Answer ONLY from that context. Never invent room rates, availability, policies, times, facilities or directions. You cannot confirm a booking … If the context does not answer a question, say you will check with the hotel team and get back to them here, and ask one useful clarifying question … Reply in the same language the guest used ({{[node2].language}}) … max 4 short sentences, plain text.",
      "userPrompt":"{{[node1].body}}" } },
  { "id":"node8","type":"sendWhatsapp","label":"Reply on WhatsApp","position":{"x":1280,"y":340},"config":{"mode":"reply","quoteOriginal":true,"message":"{{text}}"} },
  { "id":"node9","type":"output","label":"Answered","position":{"x":1600,"y":340} } ],
  "edges": [ {"source":"node1","target":"node2"}, {"source":"node2","target":"node3"},
             {"source":"node3","sourceHandle":"true","target":"node4"}, {"source":"node4","target":"node5"}, {"source":"node5","target":"node6"},
             {"source":"node3","sourceHandle":"false","target":"node7"}, {"source":"node7","target":"node8"}, {"source":"node8","target":"node9"} ] }

curl -s -H "$H" -H 'Content-Type: application/json' -X PATCH $BASE/flows/$F -d '{"name":"Mana Mana Hotel Customer Service","active":false}'
```

Why these choices:
- **Triage as JSON first, then branch** — the If node reads `{{needsHuman}}` straight from node2's item; the reply nodes reach back with `{{[node1].body}}` / `{{[node2].language}}` because LLM nodes replace the item (§2).
- **Knowledge binding in `mode: "always"`**, not `"tool"`. The first version used `"tool"` with a system prompt saying *ALWAYS call search_knowledge*; the model still answered with `_tools: []`. `always` searches on every message and injects the hits — deterministic, one model call. Keep `userPrompt` to just `{{[node1].body}}` in this mode: the resolved user prompt *is* the search query, so wrappers like "Customer name: …" only dilute retrieval (put the name in the system prompt instead).
- **The knowledge node cannot confirm bookings** and every "not in context" case routes to the team; the handover node is forbidden from promising refunds/timings. Both replies are `sendWhatsapp` reply mode, quoting the guest.
- **Deactivated on save** (`active: false`): the flow previously ran live on the user's own number; activating a rebuilt bot mid-work would have answered real customers. The user flips it on and points node1 at the hotel's number.

**5. Test each LLM node with representative items (cheap: ~0.001–0.004 credits each).** Non-ASCII test text must go through a UTF-8 file (`--data-binary @file`) — inline Chinese in a Windows shell arrived garbled and triaged as "unreadable characters".

```bash
# triage — English room enquiry (the trigger sampleData)
curl -s -H "$H" -H 'Content-Type: application/json' -X POST $BASE/flows/$F/nodes/node2/test -d '{"inputItems":[{"json":<sampleData>}]}'
# → { language:"English", intent:"room_enquiry", needsHuman:false, stayDates:"this Saturday", summaryEn:"Guest is inquiring about room availability, rates, breakfast inclusion, and check-in time…" }
# triage — Chinese in-stay problem  ("我在1203房，空调坏了很热，可以请人来看一下吗？")
# → { language:"Chinese", intent:"in_stay_issue", needsHuman:true, summaryEn:"Guest in room 1203 reports broken air conditioning…" }

# knowledge answer — pass the triage item as input AND both upstream outputs for the {{[nodeN]}} references
curl -s -H "$H" -H 'Content-Type: application/json' -X POST $BASE/flows/$F/nodes/node7/test \
  -d '{"inputItems":[<node2 item>],"nodeOutputs":{"node1":[{"json":<sampleData>}],"node2":[<node2 item>]}}'
# before the import → hits 0: "…I will check with the hotel team and get back to you here. Could you confirm your arrival date?"
# after the import  → hits 6: "I do not have real-time availability or specific rates for this Saturday… Standard check-in is at 3:00 PM."
# Malay, Paxtonz long stay → hits 6: "…tawaran Long Stay "Stay 6, Pay 5" … malam keenam percuma. Kos parkir maksimum RM6 setiap kali masuk…"
# handover — Malay complaint → "Terima kasih kerana menghubungi Mana Mana… seorang ahli pasukan kami akan menghubungi anda sebentar lagi melalui chat ini."
```

Read `_tools[0].resultCount` on every knowledge test: 0 hits with a well-phrased question means the base lacks the entry (it is also logged under `GET $KB_BASE/bases/$KB/unanswered`) — fix the base, not the prompt.

**6. Report.** Nodes by id and label, the base id and entry count, what was hedged because the website is silent (rates, breakfast per rate, house rules, deposits), that the flow is inactive and still on the user's own number, and the two remaining user actions (review the base in the studio; activate + switch the trigger account).

---

## 5. Reference

All bodies/responses are JSON. Errors:

```json
{ "success": false, "error": "human-readable message", "code": "BAD_REQUEST", "details": { …zod field errors… } }
```
`400` invalid body / rule violation · `401` missing or revoked key · `403` key bound to another flow · `404` flow/node/edge/execution not found.

### Discovery
| Method | Path | Returns |
|---|---|---|
| GET | `/me` | `{ userId, orgId, tokenKind: "token"\|"legacy", access: "read"\|"write", scopes, flowId: string\|null }` — `flowId` is set only for legacy keys |
| GET | `/node-types` | `{ nodeTypes: [{ type, label, description, category, pins, outputs?, configSchema, defaultConfig, notes }], mergeFields, nodeIdConvention }` |

### Flows (list / create)
| Method | Path | Body → Returns |
|---|---|---|
| GET | `/flows` | `[{ id, name, active, nodeCount, edgeCount, createdAt, updatedAt }]` — flows this user owns in the org |
| POST | `/flows` | `{ name, active?, nodes?: Node[], edges?: Edge[] }` (same shapes as `PUT /graph`) → `201` summary + `nodes` + `edges`. Write. No delete through this API. |

### Flow
| Method | Path | Body → Returns |
|---|---|---|
| GET | `/flows/{flowId}` | `{ id, name, active, nodeCount, edgeCount, createdAt, updatedAt }` |
| PATCH | `/flows/{flowId}` | `{ name?, active? }` → summary. `active` gates event triggers: a `whatsappTrigger` only fires while the flow is active. |
| GET | `/flows/{flowId}/graph?includeOutput=1` | summary + `nodes[]` + `edges[]`. With `includeOutput=1` each node has `lastRun: { status, output, error }` |
| PUT | `/flows/{flowId}/graph` | `{ nodes: [NodeInput], edges: [EdgeInput] }` → the saved graph. Atomic: validates everything, saves nothing on error. Resets run state. |

**Node (response)** `{ id, type, nodeType, label, description, config, position }` (+ `lastRun` when requested)
**NodeInput** `{ id?, type, label?, description?, config?, position? }` — omitted fields use the type's defaults; a partial `config` is merged over `defaultConfig`.
**EdgeInput** `{ source, target, sourceHandle? }` · **Edge (response)** `{ id, source, target, sourceHandle? }` with id `e-{source}[-{handle}]-{target}`.

### Nodes
| Method | Path | Body → Returns |
|---|---|---|
| GET | `/flows/{flowId}/nodes?q=&type=&includeOutput=1` | `{ count, nodes[] }`; `q` matches id, label, description, type and config text (case-insensitive) |
| GET | `/flows/{flowId}/nodes/{nodeId}?includeOutput=1` | node + `incoming[]` / `outgoing[]` edges |
| POST | `/flows/{flowId}/nodes` | NodeInput + `connectFrom?`, `connectFromHandle?` ("true"/"false" when connecting from an If), `connectTo?` → `201` node |
| PATCH | `/flows/{flowId}/nodes/{nodeId}` | `{ label?, description?, config?, configPatch?, position? }` — `config` **replaces**, `configPatch` **shallow-merges** → node |
| DELETE | `/flows/{flowId}/nodes/{nodeId}` | → `{ deleted, removedEdges[] }` (all touching edges removed) |
| POST | `/flows/{flowId}/nodes/{nodeId}/test` | `{ inputItems?: Item[], nodeOutputs?: { [nodeId]: Item[] } }` → `{ status:"success", startedAt, finishedAt, output: Item[], outputs?: Item[][] }` or `{ status:"error", …, error }`. No execution record; LLM nodes still bill. |

### Edges
| Method | Path | Body → Returns |
|---|---|---|
| GET | `/flows/{flowId}/edges` | `{ count, edges[] }` |
| POST | `/flows/{flowId}/edges` | EdgeInput → `201` edge |
| DELETE | `/flows/{flowId}/edges/{edgeId}` | `{ deleted }` |
| DELETE | `/flows/{flowId}/edges?source=&target=&sourceHandle=` | `{ deleted }` |

### Execution (asynchronous)
| Method | Path | Returns |
|---|---|---|
| POST | `/flows/{flowId}/execute` | `202 { executionId, poll }` — the run starts in the background |
| GET | `/flows/{flowId}/executions` | last 20 runs, newest first |
| GET | `/flows/{flowId}/executions/{executionId}` | `{ id, workflowId, status: "running"\|"waiting"\|"success"\|"error", startedAt, finishedAt?, log: [{ nodeId, status, startedAt, finishedAt?, waitingUntil?, waitingFor?, output?: Item[], outputs?: Item[][], error? }] }`. `waiting` = paused on a Wait node (§4g); it flips back to `running` when the reply arrives. |

Runs of a few LLM nodes finish in seconds; a node error stops the run with `status: "error"` and the failing node's `error` in its log entry.

### `whatsappTrigger.config.guards` — automation guards (anti-loop)
| Field | Default | Meaning |
|---|---|---|
| `perMinute` | 30 | max inbound messages **per chat** per minute that may start or resume runs (0 = off; hard cap 120) |
| `loopDetect` | true | trips when 5 replies in a row arrive within 10 s of our own message, or the same text arrives 4× in a row — the signature of an AI answering an AI |
| `cooldownMinutes` | 15 | once tripped, automation ignores that chat for this long; messages still land in the Inbox, no run starts, a run waiting on that chat is cancelled |

Applies before trigger matching **and** before Wait-node resumes, once per inbound message; with several active flows the strictest values win. The Inbox shows *Automation paused — … · Resume now* for a paused chat (`GET/DELETE /api/orgs/{orgId}/chat/guards?chatRoomId=` on the org API). Nothing is checked when no active flow has a WhatsApp trigger. Note: messages from your own other accounts are **not** filtered (testing from a second phone must keep working) — the guards are what stop two of your bots looping.

### `sendWhatsapp` config
| Field | Type | Meaning |
|---|---|---|
| `mode` | `"reply"` \| `"custom"` | `reply` (default): same account + chat as the triggering `whatsappTrigger` message. `custom`: use `account` / `to`. |
| `quoteOriginal` | boolean | reply mode — quote the customer's message |
| `account` | string | custom mode — WhatsApp Web `clientId` paired in the org; merge fields allowed |
| `to` | string | custom mode — `"60123456789"`, `"<lid>@lid"`, or a merge field |
| `message` | string | text (merge fields); default `{{text}}`; caption when `mediaUrl` is set |
| `mediaUrl` | string? | public image / video / document URL |

Output per item: input `json` + `_whatsapp: { sent: true, messageId, account, to, body, mediaUrl? }`. Errors (node `error`): reply mode without a triggering message · account not paired in this org · empty message · `WhatsApp send failed (409|502): …` when the account is offline/unreachable. Max 20 messages per step.

### `googleSheetsAppend` config
| Field | Type | Meaning |
|---|---|---|
| `connectionId` | string | Google account connected to the org — Settings → Connect Google Sheet; list with `GET /api/orgs/{orgId}/google` (org API, `data[].id` / `email` / `status`). Required. |
| `spreadsheetId` | string | spreadsheet id **or** the full `https://docs.google.com/spreadsheets/d/<id>/edit` URL; merge fields allowed. To browse by name (what the editor's dropdown does): `GET /api/orgs/{orgId}/google/{connectionId}/spreadsheets?q=` → `{ data: [{ id, name, modifiedTime }], needsReconnect }` and `…/spreadsheets/{spreadsheetId}/sheets` → `{ title, data: [{ sheetId, title, index }] }` (org API, session auth). `needsReconnect: true` = the account was connected before the list permission existed. |
| `spreadsheetName` | string? | display name of the picked spreadsheet (card label only) |
| `sheetName` | string | tab name (default `Sheet1`); merge fields allowed |
| `operation` | `"append"` \| `"upsert"` | `append` (default): always add a row · `upsert`: find `identifierHeader`'s value in that column — update the first matching row, else append (v2's `upsertRow`) |
| `identifierHeader` | string | upsert — header of the identifying column (e.g. `phone`); must be one of the mapped columns; merge fields allowed |
| `mode` | `"columns"` \| `"auto"` | `columns` (default): write `columns[]` · `auto`: one column per top-level field of the item (fields starting with `_` skipped, objects as JSON, headers from the first item) |
| `columns` | `[{ header, value }]` | columns mode. **Header-aware**: row 1 of the tab is read first and each `header` is matched to a sheet column case-insensitively; headers the sheet lacks are appended to row 1 (read the sheet's headers with `GET …/spreadsheets/{spreadsheetId}/headers?sheet=` on the org API). `value` takes merge fields (`{{text}}`, `{{[node1].from.phone}}`) and `{{$now}}` (ISO timestamp) |
| `writeHeaderIfEmpty` | boolean | default `true` — if the tab is empty, write the header row before the data |
| `valueInputOption` | `"USER_ENTERED"` | `"RAW"` | default `USER_ENTERED` (numbers/dates parsed as if typed) |

Output per item: input `json` + `_sheets: { spreadsheetId, sheetName, account, operation, action: "appended" | "updated", row, updatedRange, updatedRows }`. Errors (node `error`): no account selected · connection not in this org / `needs to be reconnected` (refresh token revoked — the user reconnects in Settings) · `Google Sheets failed (404|403|400): …` for a wrong id, no edit access, or a missing tab. Max 200 rows per step. Typical placement: after the LLM/trigger whose fields you log, e.g. `node1 (whatsappTrigger) → node2 (googleSheetsAppend) columns [{"header":"Time","value":"{{$now}}"},{"header":"Phone","value":"{{from.phone}}"},{"header":"Message","value":"{{body}}"}]`.

### `llmChain` tools (`config.tools[]`, Tools tab)
| Field | Type | Meaning |
|---|---|---|
| `id` | string | stable inside the node (`tool1`, `tool2` …) |
| `type` | `"knowledge"` | only tool type today (HTTP tool, contact lookup, calculator are planned) |
| `enabled` | boolean | off = ignored |
| `name` | string | function name the model sees, snake_case (default `search_knowledge`) |
| `description` | string | what the bases contain / when to search — drives tool selection; a sensible default is used when empty |
| `knowledgeBaseIds` | string[] | bases in this org — `GET /api/orgs/{orgId}/knowledge` (org API); empty = tool skipped |
| `topK` | 1–20 | entries returned per search (default 5) |
| `minScore` | 0–1 | cosine floor for vector-only hits (default 0.35) |
| `mode` | `"tool"` \| `"always"` | model calls search when needed · search once with the user prompt and inject as context |
| `includeSourceTitle` | boolean | append the source document title / page to each result |

`config.maxToolSteps` (1–8, default 3) caps tool rounds per item. Output: `_tools: [{ tool, mode, args: { query }, resultCount, ms, error? }]`, `_llm.steps`. Models without tool calling (`gemma-4-31B-it`) run every Knowledge binding in `always` mode. Searches that find nothing are logged as gaps on the base (Knowledge → Gaps / `GET …/unanswered` on the Knowledge AI API).

### Models (llmChain `config.model`)
`qwen3.7-flash` — shown in the app as **DJC 3.7 Flash** (default, fast, tool calling). Always use this id. Older ids (`qwen3.5-flash`, `seed-2-0-*`, `llama-4-maverick`, `gemma-4-31B-it`) are still accepted by the server for existing flows but are no longer offered in the editor; a missing provider key surfaces as a node error like `ALIBABA_AI_TOKEN is not configured`.

### Current limitations (so you don't look for them)
- No "Set / Text" node, no generic HTTP-triggered (webhook) start, no loops, no sub-flows. Wait node webhook-resume mode is not available yet. The only event trigger is `whatsappTrigger` (WhatsApp Web accounts; WhatsApp Business / WABA numbers not wired yet).
- `sendWhatsapp` is the only outbound-messaging node (WhatsApp Web accounts only). `googleSheetsAppend` appends/upserts rows — no read / lookup node yet. The old `action` placeholder type was removed (2026-08-29): creating one now returns `400 Unknown node type`; flows that still contain one run it as a pass-through and show it as "Unsupported node".
- LLM nodes don't carry incoming fields through (use `{{[nodeN].field}}`).
- Tools: only Knowledge (RAG) exists; the model cannot call HTTP APIs or other flows yet. Tool results are not streamed — `_tools` appears when the node finishes.
- Merge fields read only the **first** item of a referenced node.

---

## 6. Changelog

- **2026-08-29** — §4h case study: Mana Mana Hotel customer service built end to end with both AI APIs (website research → curated Knowledge import → `PUT /graph` triage/If/RAG/reply → per-node tests), with the gotchas hit on the way (`tool` vs `always` retrieval mode, `/import` keyword limit, UTF-8 test payloads, deactivate on rebuild).
- **2026-08-29** — `googleSheetsAppend` node (Add to Google Sheet, §2 / §5 config table): append one row per item using an org-connected Google account (Settings → Connect Google Sheet); `{{$now}}` timestamp merge field.
- **2026-08-29** — `whatsappTrigger.config.guards`: per-chat rate limits, bot-to-bot loop detector and cooldown; Inbox banner with *Resume now*.
- **2026-08-29** — `wait` node (§4g): pause a run for a WhatsApp reply or a delay; `received` / `timeout` pins; execution status `waiting`; replies resume the paused run instead of starting a new one.
- **2026-08-29** — `llmChain` Tools tab: `config.tools` Knowledge (RAG) bindings, `maxToolSteps`, `_tools` trace on output items (§4f, §5 catalog via `GET /node-types`).
- **2026-08-29** — Personal API tokens (`djc_…`, Settings → API) with `workflows:read` / `workflows:write` scopes replace per-flow keys; tokens see every flow the user owns: `GET /flows`, `POST /flows`; `/me` reports `access` and `scopes`; `403 INSUFFICIENT_SCOPE`. Legacy `sfk_` keys still accepted (bound to one flow).
- **2026-08-29** — `sendWhatsapp` node (§4e, §5 config table): reply to the triggering message or send to a custom account/recipient; text or media; sent messages land in the Inbox tagged Simple Flow.
- **2026-08-29** — §4d worked example (WhatsApp concierge), playbook item 10, `active` now gates event triggers.
- **2026-08-29** — `whatsappTrigger` node: active flows start when a WhatsApp Web (whatsmeow) account receives a message (account / type / body filters; `fromMe` off by default). Deterministic execution id per message → duplicate deliveries don't re-run.
- **2026-08-27** — Guide rewritten for agents: quick start, data model (items, what each node emits), playbook, worked examples, response shapes, limitations.
- **2026-08-27** — If node: per-item true/false branching (`sourceHandle` on edges, `connectFromHandle`, `outputs[]` in logs and test results); canvas fits view after live-sync updates.
- **2026-08-27** — Settings dialog shows credentials as one JSON; canvas live-syncs API edits; editor saves use `expectedUpdatedAt` (409 on conflict).
- **2026-08-27** — Initial version: per-flow API keys, node/edge CRUD, search, graph replace, node test, execute + executions.