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.
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 omitid. 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
triggerand/or an event trigger (whatsappTrigger). A manual execute seeds every start node (the WhatsApp trigger emits itssampleData); a real WhatsApp message seeds only the matchingwhatsappTriggernode(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/whatsappTriggerhave no input pin,outputhas 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
- 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. - Prefer surgical edits to existing flows:
PATCH …/nodes/{id}withconfigPatch(shallow merge) so unrelated settings survive. UsePUT /graphonly to build a flow from scratch or when the user asked for a rebuild — it replaces everything, including nodes the user made. - Keep ids and labels meaningful: set
labelto what the node does (“Translate → Chinese”),descriptionto a one-liner. - Lay out left → right:
position.xin steps of ~320,y ≈ 200; branches goy − 140/y + 140. Positions are cosmetic but the user looks at the canvas. - Test before running:
POST …/nodes/{id}/testwith representativeinputItems(andnodeOutputsif the node references[nodeN]). For an If node the response hasoutputs: [trueItems, falseItems]. - Run and verify:
POST /execute→ pollGET …/executions/{id}every 1–3 s untilstatusissuccessorerror. Readlog[]per node (output,outputs,error). - Costs:
llmChainspends the user’s credits on every test and run. Use smallmaxOutputTokens,temperature: 0for 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. - 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-
GETbefore aPUT /graphif they may be editing. - Don’t: invent node types, delete the user’s nodes to “simplify”, change the trigger’s
sampleDatato something unrelated, or reference node ids you haven’t seen inGET /graph. - WhatsApp-triggered flows: give the
whatsappTriggera realisticsampleData(the item shape in §2 — the user can also click Load latest received message in the editor) soexecuteand node tests exercise the flow without waiting for a real message. KeepincludeFromMe: falseunless 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. - Sending on WhatsApp (
sendWhatsapp): prefermode: "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 (itsmessagedefaults to{{text}}, an LLM node’s output) and before the Respond node so the run log still shows what was sent. Usemode: "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 beactivefor 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 asearch_knowledge(query)function and decides when to call it (best for chat replies; needs a tool-calling model — every model exceptgemma-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.descriptionis 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/outputTokensare totals). KeepmaxToolStepsat 2–3. - Test it:
POST …/nodes/node13/testwith an item whose prompt asks something the base knows; check_toolsand 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:
- Customer writes → run starts, node2 drafts the question, node3 sends it, node4 pauses the run (
GET …/executions/{id}→status: "waiting", the wait entry haswaitingUntilandwaitingFor: "reply from Joe"). No credits are spent while waiting. - 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 onreceivedwith the trigger item shape, so{{body}}is the answer and{{[node1].body}}is still the first message. Reply-modesendWhatsappkeeps answering the same chat. - No answer within 30 min → the
timeoutpin runs node8/node9. A teammate replying from the Inbox (afromMemessage) cancels the wait by default (onAgentReply: "cancel"; the run ends witherror: "Cancelled — a teammate replied in this chat"). - 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 [email protected]. 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: [].alwayssearches on every message and injects the hits — deterministic, one model call. KeepuserPromptto 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
sendWhatsappreply 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). sendWhatsappis the only outbound-messaging node (WhatsApp Web accounts only).googleSheetsAppendappends/upserts rows — no read / lookup node yet. The oldactionplaceholder type was removed (2026-08-29): creating one now returns400 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 —
_toolsappears 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 /graphtriage/If/RAG/reply → per-node tests), with the gotchas hit on the way (toolvsalwaysretrieval mode,/importkeyword limit, UTF-8 test payloads, deactivate on rebuild). - 2026-08-29 —
googleSheetsAppendnode (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 —
waitnode (§4g): pause a run for a WhatsApp reply or a delay;received/timeoutpins; execution statuswaiting; replies resume the paused run instead of starting a new one. - 2026-08-29 —
llmChainTools tab:config.toolsKnowledge (RAG) bindings,maxToolSteps,_toolstrace on output items (§4f, §5 catalog viaGET /node-types). - 2026-08-29 — Personal API tokens (
djc_…, Settings → API) withworkflows:read/workflows:writescopes replace per-flow keys; tokens see every flow the user owns:GET /flows,POST /flows;/mereportsaccessandscopes;403 INSUFFICIENT_SCOPE. Legacysfk_keys still accepted (bound to one flow). - 2026-08-29 —
sendWhatsappnode (§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,
activenow gates event triggers. - 2026-08-29 —
whatsappTriggernode: active flows start when a WhatsApp Web (whatsmeow) account receives a message (account / type / body filters;fromMeoff 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 (
sourceHandleon 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.