The canvas
The Studio is an xyflow canvas with three panes — palette, canvas, inspector. Drag a node from the palette onto the canvas, connect handles, click any node to edit its config in the right rail. Save (⌘S) commits the graph; status stays DRAFT until you activate.
Node reference
| Kind | Purpose | Required config |
|---|---|---|
| TRIGGER | Entry point. One per automation. | triggerType (MANUAL / DUE_DATE / APPOINTMENT_TIME / RENEWAL_DATE / WEBHOOK). Date-based triggers also need source ({kind: CONTACT_METADATA, key} | CONTACT_CREATED_AT | WEBHOOK_PAYLOAD), offsetDays, timeOfDay, timeZone. Webhook triggers get a minted URL, optional HMAC secret, and payload-mapping config. |
| SEND_MESSAGE | Send to one or many contacts across one or many channels. | recipients (TRIGGER_CONTACT | SEGMENT | TAG_QUERY | AD_HOC), channels[] of {channel, templateId}, multiChannelMode (CASCADE | BROADCAST) |
| WAIT | Pause execution for a fixed duration. | delayMinutes |
| CONDITION | Branch on a predicate. | kind (HAS_TAG / HAS_EMAIL / HAS_PHONE / FIELD_EQUALS / METADATA_EQUALS) + parameters |
| SWITCH | Route to multiple branches by rule set. | rules[] (field/key/operator/value/handleLabel), fallbackHandleLabel |
| MERGE | Re-join branches into one path. | — |
| SET | Compute templated values for downstream steps. No I/O. | mode (MERGE | REPLACE), assignments[] of {target, value, type} |
| STOP_ERROR | Abort the run with a human-readable reason. | message (1–500 chars) |
| INTEGRATION | Call an external system (Google Sheets / HTTP). | provider + provider-specific config |
| END | Terminal node. Required. | — |
SEND_MESSAGE — recipients & channel cascade (W5.2)
SEND_MESSAGE now drives both who receives and how they receive in a single node. Two inspector panels in the studio:
- Recipients — pick one of four modes:
TRIGGER_CONTACT(legacy single-recipient — the run's target),SEGMENT(every contact matching a saved segment's filter),TAG_QUERY(ad-hoc match-ALL + match-ANY tag predicate),AD_HOC(manually-curated contactId list, capped at 2,000). - Channels — ordered list of up to 6
(channel, templateId)pairs.multiChannelModetoggles betweenCASCADE(try each in order, stop on first success per contact — lowest cost, no double-message) andBROADCAST(dispatch on every channel the contact has data for — maximum reach).
Channel options now include the six social DM platforms — Instagram, TikTok, Facebook Messenger, LinkedIn, X, Threads — alongside Email, SMS, WhatsApp, Voice, and Telegram. Social DM dispatch is provider-stubbed; the DeliveryAttempt row carries the recipient handle from {{contact.socials.<platform>}} and per-platform Meta Graph / X / LinkedIn wiring ships in follow-ups. Each social platform also gets a first-class render variable: {{instagram_handle}}, {{tiktok_handle}}, {{messenger_handle}}, {{linkedin_handle}}, {{x_handle}}, {{threads_handle}}.
Date-driven triggers (G3)
DUE_DATE, APPOINTMENT_TIME, and RENEWAL_DATE all share the same inspector. Pick where the date lives, how many days to shift, the time of day to fire, and the IANA timezone the time is interpreted in. The fire instant is computed per contact as source + offsetDays @ timeOfDay (timeZone).
- Source = Contact metadata field — read
{{contact.metadata.<key>}}. The inspector offers an autocomplete datalist of your workspace's most-used keys (top 50 by usage) so operators don't have to recall their CSV column names. - Source = Contact created at — built-in. Useful for “X days after signup” flows.
- Source = Webhook payload field — paired with a WEBHOOK-trigger workflow that ingests a date inside the payload (e.g. Stripe's subscription period end). The webhook ingestion path resolves the date at request time — the periodic sweeper does NOT scan contacts for this source kind.
A periodic sweeper (runs alongside the recurring-schedule poller) finds contacts whose fire instant just landed in the (lastSweep, now] window and creates one WorkflowRun per match. A per-(workflow, contact, fireAt) unique key gives idempotency: concurrent sweepers can race without double-firing. Freshly activated workflows only sweep forward from workflow.updatedAt; the lookback caps at 30 days so operators can't accidentally fan out years of historical contacts.
Webhook triggers (G4)
Set the trigger source to Inbound webhook and the inspector mints POST /v1/webhooks/in/<token>. The body of every accepted POST starts a WorkflowRun targeted at the contact the payload identifies. See Inbound webhooks for realistic Tally / Stripe / Shopify / Typeform examples and the full signing + payload-mapping reference.
Transform nodes (W5)
Two node kinds shipped in W5 (2026-05-30) close the “the data shape doesn't match my template” gap and the “I want this run to fail loudly when a precondition isn't met” gap.
- SET (Set fields) — writes templated values into the per-step output bag so downstream nodes can render
{{step.<id>.<field>}}. No credentials, no retries; the graph walker dispatches inline. The schema BLOCKS targets that matchcontact.marketingConsent/contact.transactionalConsent/contact.consent*— the consent capture path is the only way to flip a consent field. - STOP_ERROR (Stop & Error) — explicit failure terminator. Marks the run
FAILEDwith the configured message, bypasses retries — the abort is intentional. Pair with aCONDITION's false branch to fail-fast with a human-readable reason that lands in the Run history (/runs) and the audit log.
Edges & handles
Most nodes have a single output. CONDITION has two outputs labelled yes and no. Each output handle exposes a unique sourceHandle string so the runtime can decide which branch to follow.
Condition branches
[
{ "id": "e1", "source": "trigger-1", "target": "send-1" },
{ "id": "e2", "source": "send-1", "target": "cond-1" },
{ "id": "e3", "source": "cond-1", "sourceHandle": "yes", "target": "end-1", "label": "replied" },
{ "id": "e4", "source": "cond-1", "sourceHandle": "no", "target": "send-2", "label": "no reply" },
{ "id": "e5", "source": "send-2", "target": "end-1" }
]Graph shape
The whole graph is stored as JSON on the Automation row. Same shape is accepted by the Workflow table. The shared validation lives in workflowGraphSchema.
{
"name": "Welcome series",
"triggerKind": "MANUAL",
"graph": {
"nodes": [
{ "id": "t", "kind": "TRIGGER", "label": "Manual start", "config": { "triggerType": "MANUAL" }, "position": { "x": 0, "y": 200 } },
{ "id": "s", "kind": "SEND_MESSAGE", "label": "Welcome", "channel": "WHATSAPP",
"config": { "templateId": "tpl_abc" }, "position": { "x": 240, "y": 200 } },
{ "id": "e", "kind": "END", "label": "Done", "config": {}, "position": { "x": 480, "y": 200 } }
],
"edges": [
{ "id": "e1", "source": "t", "target": "s" },
{ "id": "e2", "source": "s", "target": "e" }
]
}
}Validation
- Exactly one TRIGGER node, ≥ one END node.
- Every non-TRIGGER node must have an incoming edge.
- Every non-END node must have an outgoing edge.
- Every
SEND_MESSAGEmust reference atemplateIdat activate time. - Every
SEND_MESSAGE.channelmust be in your plan's allow-list.
Keyboard shortcuts
| Key | Action |
|---|---|
⌘S | Save current graph |
Delete / Backspace | Delete selected node or edge |
Esc | Deselect |