Nodes
The 10 node kinds an automation graph can contain — content, input capture, 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 10 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 10 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 | button.<id> | one per type: "branch" button across all blocks |
| output | quick_reply.<id> | one per entry in config.quick_replies |
| output | no_response | iff wait_for_reply=true and no_response_timeout_min is set |
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_delay_sec": 1
}wait_for_reply is auto-true when the message has any branch button or quick reply; it can be toggled explicitly otherwise.
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 silently at send time — they don't block save.
Example
{
"key": "greet",
"kind": "message",
"title": "Ask size",
"config": {
"blocks": [
{
"id": "b1",
"type": "text",
"text": "Hi {{contact.first_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
Prompt the contact and capture their reply into run.context or a contact custom field.
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
{
"prompt": "What's your email?",
"retry_prompt": "That didn't look like an email — try again.",
"input_type": "text | email | phone | number | choice | file",
"save_to_field": "email", // custom field slug, optional
"save_to_context": "email", // context key, optional
"max_attempts": 2,
"timeout_min": 60,
"choices": [ // for input_type=choice
{ "value": "small", "label": "Small" },
{ "value": "large", "label": "Large" }
],
"min": 1, "max": 100, // for input_type=number
"accepted_mime_types": ["image/*"], // for input_type=file
"max_size_mb": 16
}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
{ "duration": { "value": 1, "unit": "days" } }Units: minutes, hours, days.
4. condition
Branch on a predicate expression evaluated against contact fields, tags, segments, and run context.
Ports: in → true / false
Config
{
"if": {
"all": [
{ "field": "contact.tags", "op": "contains", "value": "vip" },
{ "field": "context.order_total", "op": "gte", "value": 100 }
]
}
}Expression shape supports all, any, and none groups. field paths: contact.<field>, contact.custom_fields.<slug>, contact.tags, context.<key>, run.<field>.
5. 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 the same contact always resolves the same variant on re-entry.
6. 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, 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 |
| External | webhook_out (fire-and-forget), notify_admin, log_conversion_event |
| Automation controls | pause_automations_for_contact, resume_automations_for_contact |
| Destructive | delete_contact |
| v1.1 stub | change_main_menu (visible in UI, disabled until platform sync ships) |
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.
7. http_request
Call an external HTTP endpoint. Response is stored in run.context[save_to_key] for downstream nodes.
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_sec": 15,
"save_to_context": "crm_response",
"extract": { "crm_tier": "$.customer.tier" }
}8. 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
{ "automation_id": "auto_abc123", "context_overrides": { "source": "upsell_branch" } }9. 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 that don't include at least one goto or input node — loops must contain a pause point to avoid infinite runs. The runtime also enforces a hard cap of 200 node visits per execution-loop iteration (exits as failed with exit_reason = "infinite_loop_cap").
10. 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
{ "exit_reason": "completed" }Root node constraints
The graph's root_node_key must point at one of: message, action_group, condition, http_request, start_automation, or end. input, delay, and goto all 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.