RelayAPI
GuidesAutomations

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

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

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

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

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

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, segment_add, segment_remove
Subscriptionssubscribe_list, unsubscribe_list, opt_in_channel, opt_out_channel
Conversationassign_conversation, unassign_conversation, conversation_open, conversation_close, conversation_snooze
Externalwebhook_out (fire-and-forget), notify_admin, log_conversion_event
Automation controlspause_automations_for_contact, resume_automations_for_contact
Destructivedelete_contact
v1.1 stubchange_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: 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_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: innext

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.

On this page

Submit an Issue
Requires a GitHub account.View repo