Documentation menu

For AI agents

Simple Flow — AI API Guide

Everything an AI agent needs to read, build, test and run Simple Flow workflows through the API — node catalog, WhatsApp and Knowledge bindings, worked builds and the full endpoint reference in one file.

For your AI agent

Paste this whole guide into your agent's context (Claude, ChatGPT, Cursor, a custom agent…) together with the credentials JSON from the token page. It is written for a model to read once, then act on.

Download .mdView raw≈ 7,398 words

curl -s /ai/workflows.md

Reading it yourself? The Workflows guide and API reference cover the same ground for people. This page mirrors apps/api/guides/simple-flow/AI_API_GUIDE_WORLFLOWS.md and is republished with every API release.

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

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:

{
  "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:

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

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”

{ "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

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.

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

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

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:

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)

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

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):

{ "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.

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

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

{ "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"

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.