Nodes
The 12 node kinds an automation graph can contain — content, waits, live checks, logic, actions, HTTP, and flow control.
Node model
A node is a single step in the graph. Every node has:
key string — unique within the graph; referenced by edges
kind string — one of the 12 kinds below
title optional display title
canvas_x/y canvas coordinates (builder-only, not used at runtime)
config kind-specific JSON
ports derived server-side from config on every save
ui_state optional (notes, canvas color tag)You never hand-author the ports array. On save the server runs derivePorts(node) per kind and overwrites it. The builder reads the canonical ports back from the server response to draw handles.
Edges are port-based
Edges connect one output port on a source node to one input port on a target node:
{ "from_node": "greet", "from_port": "button.btn_large", "to_node": "order_large", "to_port": "in" }There is no label field. Resolution is exact-match: after a handler returns { result: "advance", via_port: "next" }, the runner looks for the edge where from_node == current && from_port == "next". If none, the run exits with completed — missing edges are not an error, they are an intentional end of a branch.
The 12 node kinds
1. message
Composite message built from ordered blocks, optional branch buttons, and message-level quick replies. The single message node replaces the old per-send-kind sprawl (message_text, message_media, instagram_send_buttons, etc.).
Ports
| Direction | Key | When present |
|---|---|---|
| input | in | always |
| output | next | always |
| output | error | delivery or recipient resolution failed |
| output | button.<id> | one per type: "branch" button across all blocks |
| output | quick_reply.<id> | one per entry in config.quick_replies |
| output | no_response | when no_response_timeout_min is set on a waiting message |
Config
{
"blocks": [ /* ordered array — see block types below */ ],
"quick_replies": [ { "id": "qr_1", "label": "Help", "icon": "❓" } ],
"wait_for_reply": true,
"no_response_timeout_min": 60,
"typing_indicator_seconds": 1
}Branch buttons and quick replies make the node wait implicitly and resume through their derived ports. Set wait_for_reply: true on a non-interactive message to resume through next when the next ordinary text or attachment reply arrives. no_response_timeout_min is optional; when set, its scheduler path uses no_response.
Block types (inside config.blocks)
| Type | Fields |
|---|---|
text | text (merge tags ok), optional buttons: [{ id, type, label, url?, phone? }] |
image | media_ref, optional caption |
video | media_ref, optional caption |
audio | media_ref |
file | media_ref |
card | media_ref?, title, subtitle?, buttons[] (max 3) |
gallery | cards[] (1–10) — carousel of cards |
delay | seconds (0.5–10) — in-message typing pause between blocks |
Button types
| Type | Effect | Creates port? |
|---|---|---|
branch | Opens a new path in the flow | ✓ button.<id> |
url | Opens URL | — |
call | Opens phone dialer | — |
share | Share the message | — |
Channel capability matrix
| Feature | IG | FB | WA | TG |
|---|---|---|---|---|
| Branch buttons | ✓ (3) | ✓ (3) | ✓ (3) | ✓ inline kb |
| Quick replies | ✓ (13) | ✓ (13) | ✗ | ✓ reply kb |
| Card | ✓ | ✓ | ✗ | ✗ |
| Gallery (10) | ✓ | ✓ | ✗ | ✗ |
| Image | ✓ | ✓ | ✓ | ✓ |
| Video | ✓ | ✓ | ✓ | ✓ |
| Audio | ✗ | ✓ | ✓ | ✓ |
| File | ✗ | ✓ | ✓ | ✓ |
delay block | ✓ | ✓ | ✓ | ✓ |
Unsupported features inline-warn in the composer and are skipped at send time. If nothing can be delivered, or a provider send fails, the node exits through error instead of advancing or parking on an input wait.
Example
{
"key": "greet",
"kind": "message",
"title": "Ask size",
"config": {
"blocks": [
{
"id": "b1",
"type": "text",
"text": "Hi {{contact.name}}! What size?",
"buttons": [
{ "id": "btn_large", "type": "branch", "label": "Large" },
{ "id": "btn_small", "type": "branch", "label": "Small" }
]
}
],
"quick_replies": [],
"wait_for_reply": true,
"no_response_timeout_min": 60
}
}2. input
Wait for the next ordinary inbound DM and capture the validated value into the run context. Put a message node before it when you need a prompt. Persist the captured value with a later contact_field_set or field_set action.
Ports
| Direction | Key | Fires when |
|---|---|---|
| input | in | always |
| output | captured | reply passed validation |
| output | invalid | max retries exhausted without a valid reply |
| output | timeout | no reply within timeout_min |
| output | skip | user explicitly skipped (channel-dependent) |
Config
{
"field": "email",
"input_type": "text | email | phone | number | choice | file",
"max_retries": 2,
"timeout_min": 60,
"skip_allowed": false,
"choices": [ // for input_type=choice
{ "value": "small", "label": "Small", "match": ["s"] },
{ "value": "large", "label": "Large", "match": ["l"] }
],
"validation": { "min": 1, "max": 100, "pattern": "^[A-Z]{3}$" },
"accepted_mime_types": ["image/jpeg"],
"max_size_mb": 16
}Despite the legacy field name, max_retries is the maximum total number of attempts (default 1), not the number of extra retries. For example, max_retries: 2 re-prompts after the first invalid reply and routes the second invalid reply through invalid. Choice values, labels, and aliases must be unambiguous when compared case-insensitively. File size limits apply only when the inbound provider supplies size metadata.
3. delay
Pause the run for a fixed duration. The run status flips to waiting; a scheduled job resumes it at resume_at.
Ports: in → next
Config
{ "seconds": 0, "minutes": 30, "hours": 1, "days": 0 }4. wait_event
Pause until the same contact produces one of the configured compatible events. The run stores waiting_for = inbound_event; the inbox pipeline resumes it through received, and an optional durable timeout job takes the timeout branch.
Ports: in → received / timeout / error
{
"event_kinds": ["story_mention"],
"timeout_min": 10080
}Supported event kinds are dm_received, comment_created, story_reply, story_mention, live_comment, share_to_dm, and ad_click. The API rejects channel/event combinations the provider cannot emit; share_to_dm, for example, is Instagram-only.
5. condition
Branch on a predicate expression evaluated against contact fields, tags, segments, and run context.
Ports: in → true / false
Config
{
"predicates": {
"all": [
{ "field": "contact.tags", "op": "contains", "value": "vip" },
{ "field": "state.order_total", "op": "gte", "value": 100 }
]
}
}Expression shape supports all, any, and none groups. Useful paths include contact.<field>, fields.<custom-field-slug>, tags, and state.<run-context-key>.
6. randomizer
Weighted random branch — useful for A/B splits.
Ports: in → one variant.<key> per configured variant.
Config
{
"variants": [
{ "key": "a", "weight": 1, "label": "Variant A" },
{ "key": "b", "weight": 3, "label": "Variant B" }
]
}The runner caches the chosen variant in run.context, so repeated visits to the node within the same run stay on the same branch. A later, separate enrollment makes a new weighted choice.
7. action_group
Ordered bundle of side-effect actions with per-action error handling. Replaces the old one-atomic-node-per-action pattern.
Ports
| Direction | Key | When present |
|---|---|---|
| input | in | always |
| output | next | always |
| output | error | iff at least one action has on_error: "abort" |
Config
{
"actions": [
{ "id": "a1", "type": "tag_add", "tag": "lead", "on_error": "abort" },
{ "id": "a2", "type": "field_set", "field": "stage", "value": "new", "on_error": "abort" },
{ "id": "a3", "type": "subscribe_list", "list_id": "sl_...", "on_error": "continue" },
{ "id": "a4", "type": "notify_admin", "message": "New lead: {{contact.email}}", "on_error": "continue" }
]
}Action catalog
| Group | Types |
|---|---|
| Contact data | tag_add, tag_remove, field_set, field_clear, contact_field_set |
| Segments | segment_add, segment_remove |
| Subscriptions | subscribe_list, unsubscribe_list, opt_in_channel, opt_out_channel |
| Conversation | assign_conversation, unassign_conversation, conversation_open, conversation_close, conversation_snooze, reply_to_comment |
| External | webhook_out, notify_admin |
| Automation controls | pause_automations_for_contact, resume_automations_for_contact |
| Destructive | delete_contact |
| Conversion | log_conversion_event |
| Messenger profile | change_main_menu (Facebook only) |
Every action has on_error: "abort" | "continue" (default "abort"). If an action with abort fails, the group stops and exits via error. Actions marked continue log their failure in the step-run payload and the group keeps going.
log_conversion_event writes an idempotent durable conversion row and emits an internal conversion_event, so another automation can react to it. contact_field_set persists the built-in name, email, or phone field. Opt-in/out actions write the canonical consent ledger; an opt-out suppresses later sends.
8. http_request
Call an external public HTTP endpoint. Sensitive URL parts, headers, and body are moved into encrypted write-only storage when the graph is saved. The response is stored in run.context[response_key] for downstream conditions.
Ports: in → success (2xx) / error (4xx, 5xx, network, timeout)
Config
{
"method": "POST",
"url": "https://crm.example.com/api/enrich",
"headers": { "X-API-Key": "abc" },
"body": "{\"email\":\"{{contact.email}}\"}",
"timeout_ms": 15000,
"response_key": "crm_response"
}The runtime caps timeout at 30 seconds, request body at 256 KiB, response body at 512 KiB, and rejects private/internal destinations. Network failures and non-2xx responses take error; a 2xx response takes success.
9. start_automation
Enroll the current contact in another flow. The current flow continues via next (fire-and-forget — the called flow runs independently).
Ports: in → next
Config
{
"target_automation_id": "aut_abc123",
"entrypoint_id": "aep_optional",
"pass_context": true
}The target must be active and use the same channel and exact workspace scope as the source. A flow cannot target itself. entrypoint_id is optional run attribution; it does not change the target graph's root node. The triggering social account is always preserved for correct multi-account delivery; pass_context controls whether the rest of the current run context is copied.
10. social_profile_check
Instagram-only live relationship check. It queries is_user_follow_business for the contact on the exact triggering account and never substitutes cached or synthetic follow state.
Ports: in → follows / not_follows / error
{ "field": "is_user_follow_business" }11. goto
Jump to another node in the same graph. Useful for loops (re-ask) and shared tails.
Ports: in → (no output — direct jump)
Config
{ "target_node_key": "ask_email" }The graph validator forbids cycles without an input, delay, or wait_event pause point. A goto alone is not a pause. The runtime also enforces a hard visit cap as defense in depth.
12. end
Terminate the run. Optional — running out of outgoing edges also ends the run naturally. Use end when you want an explicit exit_reason other than completed.
Ports: in
Config
{ "reason": "completed" }Root node constraints
The graph's root_node_key must point at one of: message, action_group, condition, http_request, start_automation, social_profile_check, or end. input, delay, wait_event, randomizer, and goto require a predecessor and cannot be the entry point.
Adding a node kind
Node kinds are a free-text column, not an enum — adding a new kind doesn't require a database migration. Register a handler implementing NodeHandler<Config, Payload> in apps/api/src/services/automations/nodes/ and add it to the manifest. derivePorts, validateConfig, and handle are the three hooks the runtime calls.
Found something wrong? Help us improve this page.