# Workflow nodes

Every workflow node type, its config fields, its outputs and the platforms it runs on, from send_message and conditions to webhooks, AI and calls.

A [workflow](https://pozy.ai/docs/automations/workflows) is made of nodes. Each node is `{ "id", "type", "config" }`, plus an optional `label` and canvas `position`. This page lists all 17 types and the fields their `config` takes. Every string field accepts `{{variable}}` interpolation, resolved when the node runs.

| Group | Nodes |
| --- | --- |
| Messaging | [`send_message`](#send-message) |
| Control flow | [`trigger`](#trigger), [`condition`](#condition), [`delay`](#delay), [`wait_for_reply`](#wait-for-reply), [`a_b_split`](#a-b-split), [`end`](#end) |
| Data | [`set_variable`](#set-variable), [`set_field`](#set-field), [`add_tag` and `remove_tag`](#add-tag-and-remove-tag), [`enroll_sequence`](#enroll-sequence) |
| Integrations | [`webhook`](#webhook), [`hubspot_upsert_contact`](#hubspot-upsert-contact), [`ai`](#ai), [`handoff`](#handoff), [`start_call`](#start-call) |

## trigger

Exactly one per workflow. It decides what starts a run. Outputs: one edge.

| Field | Type | Notes |
| --- | --- | --- |
| `triggerType` | string | `inbound_message` (default), `referral`, `comment`, `story_mention`, `reaction`, `whatsapp_event`, `lead_received`, `api_call` |
| `keywords` | string[] | For `inbound_message`. Empty matches every message |
| `matchType` | string | `any`, `contains`, `exact` or `regex` |
| `onlyFirstMessage` | boolean | Fire only on a contact's first message |
| `referral` | object | `{ ref, matchType }` with `matchType` `exact`, `prefix` or `any` (default) |
| `comment` | object | `{ keywords, matchType, platformPostId }` |
| `reaction` | object | `{ emojis }` to react only to some emojis |
| `eventType` | string | For `whatsapp_event`: `message_sent`, `message_delivered`, `message_read`, `message_failed`, `reaction` |
| `leadReceived` | object | `{ formIds, pageAccountIds, consentCheckboxKey, defaultCountry, allowCrossProfile }`. Needs `consentCheckboxKey` and at least one of `formIds` or `pageAccountIds` |
| `cooldownHours` | integer | 1 to 720. Default 24 on every trigger except `comment` and `api_call` |

A `whatsapp_event` workflow never starts on the status of a message sent by a workflow, sequence or comment automation, so it cannot loop on itself.

## send_message

Sends one message into the run's conversation. Outputs: one edge.

| Field | Type | Notes |
| --- | --- | --- |
| `messageType` | string | `text`, `media`, `template`, `interactive` or `cards` |
| `text` | string | The message text. Up to 640 characters when `buttons` are attached |
| `media` | object | `{ mediaType, url, caption }` with `mediaType` `image`, `video`, `audio` or `document`. Media cannot carry buttons |
| `template` | object | WhatsApp: `{ name, language, variableMapping }` for an approved template. Instagram and Facebook with `messageType: "cards"`: `{ type: "generic", elements: [{ title, subtitle, imageUrl, buttons }] }`, 1 to 10 cards |
| `interactive` | object | WhatsApp only: list, reply buttons, call-to-action URL, flow and other interactive messages |
| `buttons` | object[] | Instagram and Facebook, on `text`: 1 to 3 `{ type, title, url, payload, phone, workflowId }` with `type` `url`, `postback` or `phone` (Facebook only) |
| `quickReplies` | object[] | Instagram and Facebook: 1 to 13 `{ title, payload, imageUrl, workflowId }`. Not with `buttons` |

Button and quick reply titles are capped at 20 characters, card titles and subtitles at 80. A postback with no `payload` gets `pozy:workflow:<this workflow id>`, so a tap answers this workflow's pending `wait_for_reply`. `workflowId` on a button starts that workflow instead.

Workflows can only send within the platform's messaging window. `messageTag` is not available: `HUMAN_AGENT` is reserved for replies typed by a person. On WhatsApp, a run that may start outside the 24-hour window must open with a template.

## wait_for_reply

Pauses the run until the contact answers. Outputs: `reply` or `timeout`.

| Field | Type | Notes |
| --- | --- | --- |
| `timeoutMinutes` | integer | Up to 43,200 (30 days). Takes the `timeout` edge after that much silence |
| `saveAs` | string | Variable name for the reply text, for example `email` makes `{{email}}` |

A button tap also counts as a reply and sets `{{postback.payload}}` and `{{postback.title}}`; a quick reply tap sets `{{quickReply.payload}}` and `{{quickReply.title}}`. Each reply clears the previous tap.

## condition

Branches on variables. Outputs: the `id` of the first rule that matched, otherwise `default`.

| Field | Type | Notes |
| --- | --- | --- |
| `rules` | object[] | `{ id, variable, operator, value }`, checked in order |

| Operators | Compare |
| --- | --- |
| `equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `ends_with`, `matches`, `exists`, `not_exists` | Text, ignoring case. With `matches`, `value` is a regular expression |
| `greater_than`, `less_than`, `greater_or_equal`, `less_or_equal` | Numbers. If either side is not a number, the condition is false |
| `before`, `after` | Dates in ISO 8601 |
| `has_tag`, `not_has_tag` | Checks whether a list variable, for example `contact.tags`, includes `value` |
| `is_true`, `is_false` | Booleans; leave `value` out |

```json
{
  "id": "route",
  "type": "condition",
  "config": {
    "rules": [
      { "id": "vip", "variable": "contact.tags", "operator": "has_tag", "value": "vip" },
      { "id": "recent", "variable": "contact.lastInteractionHoursAgo", "operator": "less_than", "value": "48" }
    ]
  }
}
```

## delay

Waits, then continues. Outputs: one edge.

| Field | Type | Notes |
| --- | --- | --- |
| `delayMinutes` | integer | Up to 43,200 (30 days) |

The run is suspended while it waits and resumes on a timer. Remember the messaging window: a long delay on Instagram or Facebook can push the next message outside it.

## a_b_split

Sends contacts down one of two branches at random. Outputs: `a` or `b`.

| Field | Type | Notes |
| --- | --- | --- |
| `percentage` | number | 0 to 100, default 50. The share of runs that take `a` |

Tag each branch with `add_tag` so you can compare results later.

## end

Ends the run as `completed`. No config.

## set_variable

Sets run-scoped variables that exist only for this run. Outputs: one edge.

| Field | Type | Notes |
| --- | --- | --- |
| `assignments` | object[] | `{ name, value }` pairs. `value` can interpolate other variables |

## set_field

Writes a [custom field](https://pozy.ai/docs/inbox/custom-fields) on the contact, which persists after the run. Outputs: one edge.

| Field | Type | Notes |
| --- | --- | --- |
| `field` | string | The field slug. Characters outside `A-Z`, `a-z`, `0-9` and `_` are stripped |
| `value` | string | The value, usually a captured variable such as `{{email}}` |

Fires `contact.field_changed` when the value changes. Does nothing on `api_call` runs that have no contact.

## add_tag and remove_tag

Add or remove one tag on the contact. Outputs: one edge.

| Field | Type | Notes |
| --- | --- | --- |
| `tag` | string | The tag |

Fire `contact.tag_added` and `contact.tag_removed` when they change something. Do nothing on `api_call` runs that have no contact.

## enroll_sequence

Puts the contact into a [sequence](https://pozy.ai/docs/automations/sequences). Outputs: `success` or `error`.

| Field | Type | Notes |
| --- | --- | --- |
| `sequenceId` | string | The sequence to enroll in |
| `saveAs` | string | Variable for the result |

## webhook

Calls your server and saves the response. Outputs: `success` on a `2xx`, otherwise `error`.

| Field | Type | Notes |
| --- | --- | --- |
| `url` | string | Your endpoint. Private, loopback and cloud metadata addresses are refused |
| `method` | string | `GET`, `POST`, `PUT`, `PATCH` or `DELETE` |
| `headers` | object | Extra headers, for example your own auth token |
| `bodyTemplate` | string | Request body with `{{variables}}` |
| `saveAs` | string | Variable for the response, saved as `{ status, ok, body }` |

```json
{
  "id": "lookup",
  "type": "webhook",
  "config": {
    "url": "https://api.acme.example/orders/lookup",
    "method": "POST",
    "headers": { "Authorization": "Bearer acme_internal_token" },
    "bodyTemplate": "{\"phone\": \"{{contact.phone}}\", \"text\": \"{{lastMessage}}\"}",
    "saveAs": "order"
  }
}
```

Then use `{{order.body.status}}` in a later message or condition. Check the signature or token on your side, since anyone who learns the URL could call it.

## hubspot_upsert_contact

Creates or updates the contact in the profile's connected [HubSpot](https://pozy.ai/docs/platforms/hubspot) portal. Outputs: `success` or `error` (HubSpot not connected, or HubSpot refused the write).

| Field | Type | Notes |
| --- | --- | --- |
| `email`, `phone`, `firstName`, `lastName` | string | Usually variables captured earlier |
| `properties` | object | HubSpot internal property names to values, up to 50 |
| `saveAs` | string | Variable for `{ id, created }` or `{ error }`. Default `hubspotContact` |

Pozy updates the HubSpot contact already linked to this person, else one matching the email, then the phone, else it creates one. An existing HubSpot contact only gets the fields this node sets; empty values are skipped.

## ai

Asks a language model and saves the answer. Outputs: `success`, `error`, or `tool:<name>` when the model chooses one of your tools.

| Field | Type | Notes |
| --- | --- | --- |
| `provider` | string | `anthropic`, `openai`, `google`, `mistral`, `groq` or `openrouter`. With `model`, the node runs on your own key for that provider |
| `model` | string | The provider's model name |
| `preset` | string | `smart`, `tools` or `cheap` |
| `systemPrompt` | string | Instructions for the model |
| `userPromptTemplate` | string | The prompt, usually with `{{lastMessage}}` and contact variables |
| `outputType` | string | `text` or `json` |
| `saveAs` | string | Variable for the output |
| `temperature`, `maxTokens` | number | Generation settings |
| `tools` | object[] | `{ name, description, parameters }`. When the model picks one, the run follows the `tool:<name>` edge |

Use tools to let the model route the conversation: for example a `book_demo` tool whose edge leads to a booking message, and a `human` tool whose edge leads to a `handoff`.

## handoff

Ends the run as `exited` and flags the conversation for a person. No output edge.

| Field | Type | Notes |
| --- | --- | --- |
| `note` | string | Context for whoever picks it up |
| `assignTo` | string | Who should handle it |

To also pass a Meta thread to another app, call [thread control](https://pozy.ai/docs/inbox/messages#hand-a-conversation-to-another-app) when `workflow.run.completed` arrives with `execution.status: "exited"`.

## start_call

Places a WhatsApp call to the contact. WhatsApp only. Outputs: `success`, `permission_required` or `failed`.

| Field | Type | Notes |
| --- | --- | --- |
| `to` | string | The number to call, usually `{{contact.phone}}` |
| `forwardTo` | string | Where to connect the call: `tel:+E164`, `sip:user@host`, or a `wss://` URL for an AI voice agent |
| `requirePermissionFirst` | boolean | Ask for call permission before dialing |
| `recordingEnabled` | boolean | Record the call |
| `saveAs` | string | Variable for the result |

WhatsApp requires the person's permission before a business calls them. Route `permission_required` to a message explaining why you want to call. See [WhatsApp calling](https://pozy.ai/docs/whatsapp/calling).

## Validation

Saving a workflow checks that node ids are unique, that edges point at existing nodes, that WhatsApp-only nodes (`start_call`, `template` and `interactive` messages) are not used on other platforms, and that Instagram and Facebook-only fields (`buttons`, `quickReplies`, `cards`) are not used elsewhere. A problem answers `400` with a message that describes it.
