RelayAPI
GuidesAutomations

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

DirectionKeyWhen present
inputinalways
outputnextalways
outputerrordelivery or recipient resolution failed
outputbutton.<id>one per type: "branch" button across all blocks
outputquick_reply.<id>one per entry in config.quick_replies
outputno_responsewhen 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)

TypeFields
texttext (merge tags ok), optional buttons: [{ id, type, label, url?, phone? }]
imagemedia_ref, optional caption
videomedia_ref, optional caption
audiomedia_ref
filemedia_ref
cardmedia_ref?, title, subtitle?, buttons[] (max 3)
gallerycards[] (1–10) — carousel of cards
delayseconds (0.5–10) — in-message typing pause between blocks

Button types

TypeEffectCreates port?
branchOpens a new path in the flowbutton.<id>
urlOpens URL
callOpens phone dialer
shareShare the message

Channel capability matrix

FeatureIGFBWATG
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

DirectionKeyFires when
inputinalways
outputcapturedreply passed validation
outputinvalidmax retries exhausted without a valid reply
outputtimeoutno reply within timeout_min
outputskipuser 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: innext

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: inreceived / 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: intrue / 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

DirectionKeyWhen present
inputinalways
outputnextalways
outputerroriff 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

GroupTypes
Contact datatag_add, tag_remove, field_set, field_clear, contact_field_set
Segmentssegment_add, segment_remove
Subscriptionssubscribe_list, unsubscribe_list, opt_in_channel, opt_out_channel
Conversationassign_conversation, unassign_conversation, conversation_open, conversation_close, conversation_snooze, reply_to_comment
Externalwebhook_out, notify_admin
Automation controlspause_automations_for_contact, resume_automations_for_contact
Destructivedelete_contact
Conversionlog_conversion_event
Messenger profilechange_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: insuccess (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: innext

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: infollows / 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.

On this page