> ## Documentation Index
> Fetch the complete documentation index at: https://www.bolna.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Workflow Nodes

> Every workflow node type — start, agent, extraction, api, time, retry, WhatsApp and end — with configuration, defaults, bounds and examples.

Every node in a definition is an envelope with the same shape:

```json theme={"system"}
{
  "id": "n_call",              // ^[a-z0-9_]{1,64}$, unique within the workflow
  "type": "agent",             // one of the types below
  "name": "Qualify",           // optional display name
  "config": { ... },           // type-specific, documented below
  "cases": [ ... ],            // ordered routing conditions (most types)
  "on_no_match": "n_end_x"     // optional per-node fallback
}
```

Most nodes route onward through `cases` — ordered conditions where the first match wins (see [Conditions and variables](/docs/guides/workflows/conditions-and-variables)). `time` nodes use a single `to` instead, and `end` nodes have no exit. When no case matches, the execution moves to the node's `on_no_match`, or the definition-wide one if the node doesn't declare its own.

The [Node Types API](/docs/api-reference/workflows/node-types) returns this catalog programmatically — every parameter with defaults and bounds, generated from the same models that publish enforces.

<Info>
  Definition limits: at most 200 nodes, 25 cases per node, 10 effects per case, 50 declared start fields, and 512 KB of definition JSON.
</Info>

***

## start

The entry point. Exactly one per workflow, referenced by `entry_node_id`, and it must have at least one case. It declares the contact fields the workflow accepts — the schema that single runs and campaign uploads are validated against.

| Config              | Type    | Default  | Notes                                                                       |
| ------------------- | ------- | -------- | --------------------------------------------------------------------------- |
| `trigger.kind`      | string  | required | `manual` — contacts enter via the run endpoint or campaigns                 |
| `fields`            | array   | `[]`     | Up to 50 declared fields                                                    |
| `fields[].name`     | string  | required | `^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$`                                            |
| `fields[].type`     | string  | `string` | `string`, `number`, `boolean`, `timestamp`, `phone`, `email`, `url`, `json` |
| `fields[].required` | boolean | `false`  | Rows missing a required field fail validation                               |

Four **system fields** are always accepted without declaring them: `reference_id`, `mobile_number`, `name`, `email`. Declaring one (for example to make `mobile_number` required) refines the built-in rather than duplicating it.

```json theme={"system"}
{
  "id": "n_start",
  "type": "start",
  "config": {
    "trigger": { "kind": "manual" },
    "fields": [
      { "name": "mobile_number", "type": "phone", "required": true },
      { "name": "loan_amount", "type": "number" },
      { "name": "city", "type": "string" }
    ]
  },
  "cases": [
    { "when": { "always": true }, "then": { "to": "n_call" } }
  ]
}
```

Declared fields become `entry.*` variables — `entry.loan_amount` in the example — usable in any downstream condition. Typed fields are coerced at upload, so `"42000"` in a CSV compares numerically.

***

## agent

Places a call with one of your Bolna voice agents and waits for it to finish.

| Config        | Type    | Default                          | Notes                                                                                                      |
| ------------- | ------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `agent_id`    | string  | required                         | The agent to dial with                                                                                     |
| `recipient`   | operand | `{"var": "entry.mobile_number"}` | Who to call                                                                                                |
| `from_number` | string  | `null`                           | Originating number override                                                                                |
| `timeout_s`   | integer | `3600`                           | 60–86400. The case branch on `call.status` `"timeout"` fires if the call hasn't concluded in time          |
| `variables`   | object  | `{}`                             | Values passed to the agent as prompt variables, e.g. `{"customer_name": {"value": {"var": "entry.name"}}}` |

After the call, the node writes `call.*` variables and its cases branch on them:

* `call.status` — `completed`, `busy`, `no_answer`, `failed`, `voicemail`, `cancelled`, `rejected`, `timeout`, or `error`
* `call.duration_s` — call duration when known
* `call.error` — set when `status` is `error` (for example `missing_recipient` when the recipient variable is blank)

```json theme={"system"}
{
  "id": "n_call",
  "type": "agent",
  "name": "Qualify",
  "config": {
    "agent_id": "<agent_id>",
    "timeout_s": 3600,
    "variables": {
      "customer_name": { "value": { "var": "entry.name" } }
    }
  },
  "cases": [
    { "when": { "cmp": "==", "left": { "var": "call.status" }, "right": { "const": "completed" } },
      "then": { "to": "n_extract" } },
    { "when": { "cmp": "in", "left": { "var": "call.status" }, "right": { "const": ["busy", "no_answer"] } },
      "then": { "to": "n_retry" } }
  ]
}
```

Each agent node produces a regular [call execution](/docs/api-reference/executions/overview) with its own transcript and recording.

***

## extraction

Exposes fields your agent extracted from the call — its [dispositions](/docs/api-reference/dispositions/overview) — as `extraction.*` variables for branching.

| Config          | Type             | Default  | Notes                                                                           |
| --------------- | ---------------- | -------- | ------------------------------------------------------------------------------- |
| `agent_node_id` | string           | required | The agent node whose call to read from                                          |
| `selected`      | array of strings | required | The extraction field names this node exposes; publish requires a non-empty list |

An extraction node must directly follow its linked agent node. At publish, the field types are filled in server-side from the agent's dispositions — numeric dispositions compare numerically.

```json theme={"system"}
{
  "id": "n_extract",
  "type": "extraction",
  "config": {
    "agent_node_id": "n_call",
    "selected": ["call_score", "engagement_intent"]
  },
  "on_no_match": "n_end_cold",
  "cases": [
    { "when": { "cmp": ">", "left": { "op": "+", "args": [ { "var": "extraction.call_score" }, { "var": "extraction.engagement_intent" } ] },
                "right": { "const": 80 } },
      "then": { "to": "n_end_hot", "effects": [ { "set_value": { "state.lead": { "const": "hot" } } } ] } },
    { "when": { "cmp": ">", "left": { "var": "extraction.call_score" }, "right": { "const": 40 } },
      "then": { "to": "n_end_warm" } }
  ]
}
```

***

## api

Calls an external HTTP endpoint, maps values out of the response, and branches on the result.

| Config             | Type    | Default  | Notes                                                                             |
| ------------------ | ------- | -------- | --------------------------------------------------------------------------------- |
| `method`           | string  | `POST`   | `GET`, `POST`, `PUT`, `PATCH`, `DELETE`                                           |
| `url`              | string  | required | The host must be literal (no variables in the host)                               |
| `headers`          | object  | `{}`     | `x-internal*` header names are reserved                                           |
| `body`             | any     | `null`   | JSON body; string values may embed `{placeholder}` references                     |
| `timeout_s`        | integer | `30`     | 1–300                                                                             |
| `follow_redirects` | boolean | `false`  |                                                                                   |
| `response_map`     | object  | `{}`     | Maps names to dotted paths into the response, e.g. `{"score": "json.data.score"}` |
| `variables`        | object  | `{}`     | Resolves `{placeholder}` references in the URL, headers and body                  |

After the request, cases branch on `response.*`:

* `response.status` — the HTTP status code as a number. A 4xx/5xx is **not** retried away — it reaches your cases so you decide what happens.
* `response.<name>` — every `response_map` entry.
* `response.error` — `"transport"` on DNS failure, connection refusal or timeout (no `response.status` in that case).

```json theme={"system"}
{
  "id": "n_record",
  "type": "api",
  "name": "Record the promise",
  "config": {
    "method": "POST",
    "url": "https://api.example.com/promises",
    "body": { "customer": "{customer}", "date": "{promised_date}" },
    "variables": {
      "customer": { "value": { "var": "entry.reference_id" } },
      "promised_date": { "value": { "var": "extraction.promised_date" } }
    },
    "response_map": { "promise_id": "json.id" }
  },
  "cases": [
    { "when": { "cmp": "==", "left": { "var": "response.status" }, "right": { "const": 200 } },
      "then": { "to": "n_end_recorded" } },
    { "when": { "cmp": "==", "left": { "var": "response.error" }, "right": { "const": "transport" } },
      "then": { "to": "n_end_unreachable" } },
    { "when": { "always": true }, "then": { "to": "n_end_failed" } }
  ]
}
```

***

## time

Waits, then moves on. The only node with a single exit — it takes `to` instead of `cases`.

| Config     | Type              | Default | Notes                                                                                            |
| ---------- | ----------------- | ------- | ------------------------------------------------------------------------------------------------ |
| `delay`    | object            | —       | `{"days": 0, "hours": 0, "minutes": 30}` — relative wait, up to 365 days                         |
| `at`       | string or operand | —       | Absolute timestamp: an ISO datetime with offset, or `{"var": "..."}` reading a `timestamp` field |
| `timezone` | string            | `null`  | IANA zone, only valid with a variable `at`                                                       |

Exactly one of `delay` or `at` must be set.

```json theme={"system"}
{
  "id": "n_wait",
  "type": "time",
  "config": { "delay": { "hours": 24 } },
  "to": "n_followup_call"
}
```

***

## retry

Re-runs an earlier node on a schedule — the only legal way to close a cycle in the graph. Any other loop back upstream is rejected at publish with `unbounded_cycle`.

| Config           | Type   | Default  | Notes                                                                                 |
| ---------------- | ------ | -------- | ------------------------------------------------------------------------------------- |
| `target_node_id` | string | required | The node to re-run                                                                    |
| `attempts`       | array  | required | 1–10 schedule entries, each `{"delay": {...}}` or `{"at": ...}` — one entry per retry |

Each time the execution reaches the retry node it consumes the next attempt: waits out that entry's schedule, then jumps back to the target. Once all attempts are consumed, the node's `cases` are evaluated instead — that's your "gave up" path. The target therefore runs at most `attempts + 1` times.

```json theme={"system"}
{
  "id": "n_retry",
  "type": "retry",
  "config": {
    "target_node_id": "n_call",
    "attempts": [
      { "delay": { "minutes": 2 } },
      { "delay": { "minutes": 2 } },
      { "delay": { "minutes": 2 } }
    ]
  },
  "cases": [
    { "when": { "always": true }, "then": { "to": "n_end_unreachable" } }
  ]
}
```

***

## aisensy\_whatsapp

Sends a WhatsApp template message through your connected AiSensy account.

| Config          | Type    | Default  | Notes                                                                                        |
| --------------- | ------- | -------- | -------------------------------------------------------------------------------------------- |
| `template_name` | string  | required | The approved template's name                                                                 |
| `language`      | string  | required | The template's ISO language code, e.g. `en` or `en_US` — never a display name like `English` |
| `recipient`     | operand | required | Who to message, e.g. `{"var": "entry.mobile_number"}` — no default                           |
| `variables`     | object  | `{}`     | Template placeholder values keyed by index — `"1"`, `"2"`, ... contiguous from 1             |
| `campaign_name` | string  | `null`   | Records which AiSensy campaign the template came from; display-only                          |

```json theme={"system"}
{
  "id": "n_wa",
  "type": "aisensy_whatsapp",
  "config": {
    "campaign_name": "lead_warmup",
    "template_name": "followup_v2",
    "language": "en",
    "recipient": { "var": "entry.mobile_number" },
    "variables": {
      "1": { "value": "{entry.name}", "required": true }
    }
  },
  "cases": [
    { "when": { "cmp": "==", "left": { "var": "whatsapp.status" }, "right": { "const": "sent" } },
      "then": { "to": "n_end_messaged" } },
    { "when": { "always": true }, "then": { "to": "n_end_failed" } }
  ]
}
```

Cases branch on `whatsapp.status` (`sent` or `failed`), with `whatsapp.message_id` and `whatsapp.error` alongside.

***

## end

Terminates the execution.

| Config    | Type   | Default  | Notes                                                                           |
| --------- | ------ | -------- | ------------------------------------------------------------------------------- |
| `label`   | string | required | Becomes the execution's `termination_reason` and a key in campaign reports      |
| `outcome` | string | required | `success`, `failure` or `neutral` — rolls up into campaign and version counters |

```json theme={"system"}
{
  "id": "n_end_reached",
  "type": "end",
  "config": { "label": "reached", "outcome": "success" }
}
```

Design your endings deliberately: every distinct `label` becomes a row in the [campaign report](/docs/api-reference/workflow-campaigns/report), and the outcomes drive the `success_count` / `failure_count` / `neutral_count` rollups everywhere.

***

## Next Steps

<CardGroup cols={2}>
  <Card title="Conditions and variables" icon="code-branch" href="/docs/guides/workflows/conditions-and-variables">
    The expression language cases are written in
  </Card>

  <Card title="Node Types API" icon="code" href="/docs/api-reference/workflows/node-types">
    This catalog, programmatically
  </Card>
</CardGroup>
