---
title: "Campaign graph schema"
description: "The shape of a saved campaign graph — the node and edge fields, the configuration object for each node type, and the constraints a valid graph satisfies."
canonical: https://docs.afp.monster/reference/campaign-graph-schema
updated: 2026-08-19
pageType: reference
---

A saved campaign graph is two arrays: `nodes`, each carrying a type and a configuration object, and `edges`, each joining one node's numbered output to another node's input. Nothing else is stored on the graph — the campaign name, its page, and its active state live on the campaign, not in the graph. Saving writes a new version of both arrays.

## Node fields

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | Identifies the node inside this graph, and is what edges point at. Replaced with a fresh identifier when the graph is saved, so it is stable only within one version. |
| `type` | string | yes | One of the types in [node types](/reference/node-types), or one of the runtime-only types listed below. |
| `config` | object | yes | The node's settings. An empty object is valid, and is what a freshly added node starts with. |
| `canvas_x` | number | yes | Horizontal position of the node on the canvas. Restored when the graph is opened. |
| `canvas_y` | number | yes | Vertical position of the node on the canvas. Restored when the graph is opened. |

## Edge fields

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | Identifies the connection inside this graph. |
| `from_node_id` | string | yes | The `id` of the node the connection leaves. Must appear in the same `nodes` array, or the connection is dropped on save. |
| `to_node_id` | string | yes | The `id` of the node the connection enters. Must appear in the same `nodes` array, or the connection is dropped on save. |
| `output_index` | number | yes | Which output of the source node the connection leaves from. `0` is the default output; on a `condition`, `0` is Match and `1` is No Match; on a `random_split`, `0` to N-1 are the arms in configured order. |
| `label` | string | no | Display text shown on the connection, taken from the name of the source output — for example `Match` or `No Match`. Executions are routed by `output_index`, never by `label`. |

## Worked example

A comment triggers the campaign, a keyword list decides the branch, matching people get a direct message and a tag an hour later, and every other execution ends immediately.

```d2 title="A saved graph: trigger to condition, then send DM, delay and add tag on match, and stop on no match"
direction: down
trigger: trigger_comment
cond: "condition\nkeyword_list" {shape: diamond}
dm: send_dm
wait: delay 3600s
tag: add_tag
halt: stop
trigger -> cond
cond -> dm: "Match (output 0)"
dm -> wait -> tag
cond -> halt: "No Match (output 1)"
```

```json
{
  "nodes": [
    {
      "id": "3f6c1a80-5d2b-4c11-9e64-7a0b8d43f215",
      "type": "trigger_comment",
      "config": { "post_scope": "all", "unique_per_user": false },
      "canvas_x": 240,
      "canvas_y": 40
    },
    {
      "id": "9b1d47e2-08af-4d5c-bb37-2c6e5f90a4d1",
      "type": "condition",
      "config": {
        "match_type": "keyword_list",
        "keyword_list_id": 41,
        "reverse_keywords": false,
        "wait_for_reply": false
      },
      "canvas_x": 240,
      "canvas_y": 200
    },
    {
      "id": "c74a2f19-6e30-4b8d-8f52-19d7c3b6e084",
      "type": "send_dm",
      "config": {
        "message_type": "text",
        "text": "Thanks for commenting. The price list is on its way."
      },
      "canvas_x": 80,
      "canvas_y": 380
    },
    {
      "id": "1a58e63d-4c97-42f0-9b18-6d2f7ae5c390",
      "type": "delay",
      "config": { "delay_seconds": 3600, "only_if_no_reply": true },
      "canvas_x": 80,
      "canvas_y": 540
    },
    {
      "id": "e2079b45-13cd-4a6e-8c71-5f83d0b29a6c",
      "type": "add_tag",
      "config": { "tag_ids": [12] },
      "canvas_x": 80,
      "canvas_y": 700
    },
    {
      "id": "7d3fb0c6-92e1-4f57-a840-3b6c1d5e2087",
      "type": "stop",
      "config": {},
      "canvas_x": 440,
      "canvas_y": 380
    }
  ],
  "edges": [
    {
      "id": "5c8e1b73-2f40-4d19-9a6b-8e7d0c34f512",
      "from_node_id": "3f6c1a80-5d2b-4c11-9e64-7a0b8d43f215",
      "to_node_id": "9b1d47e2-08af-4d5c-bb37-2c6e5f90a4d1",
      "output_index": 0,
      "label": ""
    },
    {
      "id": "b06d4a92-7e15-48c3-bf20-6a9c2d813e47",
      "from_node_id": "9b1d47e2-08af-4d5c-bb37-2c6e5f90a4d1",
      "to_node_id": "c74a2f19-6e30-4b8d-8f52-19d7c3b6e084",
      "output_index": 0,
      "label": "Match"
    },
    {
      "id": "48f1c25e-3b7d-4096-8ea4-1c5b7f02d9a3",
      "from_node_id": "9b1d47e2-08af-4d5c-bb37-2c6e5f90a4d1",
      "to_node_id": "7d3fb0c6-92e1-4f57-a840-3b6c1d5e2087",
      "output_index": 1,
      "label": "No Match"
    },
    {
      "id": "a91b7e04-6c58-4d2f-93a1-7e04b8c5f261",
      "from_node_id": "c74a2f19-6e30-4b8d-8f52-19d7c3b6e084",
      "to_node_id": "1a58e63d-4c97-42f0-9b18-6d2f7ae5c390",
      "output_index": 0,
      "label": ""
    },
    {
      "id": "d5720af8-9134-4be6-8c07-2f6a5d18b3e9",
      "from_node_id": "1a58e63d-4c97-42f0-9b18-6d2f7ae5c390",
      "to_node_id": "e2079b45-13cd-4a6e-8c71-5f83d0b29a6c",
      "output_index": 0,
      "label": ""
    }
  ]
}
```

## Configuration keys by node type

| Node type | Keys in `config` |
|---|---|
| `trigger_comment` | `post_scope` (`all` or `specific`), `post_id` (required when `post_scope` is `specific`), `unique_per_user` |
| `trigger_dm` | `unique_per_user` |
| `condition` | `match_type` (`any`, `contains`, `exact`, `keyword_list`), `text`, `keyword_list_id`, `reverse_keywords`, `wait_for_reply`, `filter_images`, `filter_gifs`, `filter_links` |
| `send_dm` | `message_type` (`text`, `image`, `card`, `text_with_buttons`), `text`, `image_url`, `title`, `subtitle`, `buttons`, `buttons_text` |
| `reply_comment` | `text` |
| `send_comment` | `text`, `post_id` |
| `hide_comment`, `delete_comment` | `also_block_user` (Facebook only) |
| `delay` | `delay_seconds` (1 to 31536000), `only_if_no_reply` |
| `random_split` | `paths`, an array of `{ "percent": number }` with each percent 0 to 100 and the total no more than 100 |
| `add_tag`, `remove_tag` | `tag_ids`, an array of numeric tag identifiers |
| `stop` | none |

[Node types](/reference/node-types) describes what each key does and what each node does when an execution reaches it.

## Connection constraints

The canvas enforces these while a connection is being dragged, so an invalid connection never reaches a saved graph.

1. A trigger is never the target of a connection.
2. A node never connects to itself.
3. A connection that would create a cycle is refused.
4. A node accepts one incoming connection.
5. Each output carries one outgoing connection.
6. A trigger connects only to a `condition`.
7. A `condition` never connects to another `condition`.
8. Comment actions are unavailable when the trigger is `trigger_dm`.
9. Downstream of a `delete_comment`, `reply_comment` and `hide_comment` are refused.

A graph holds one trigger in practice, though the shape of the data allows more than one. A graph whose nodes have configuration errors cannot be saved at all — the canvas reports the errors instead. [Build a campaign](/guides/build-a-campaign) walks through assembling a graph that satisfies all of this.

## Node types outside the palette

The runtime recognises eight further types that have no palette tile and no settings panel. A graph built on the canvas never contains them; they appear only through a path that bypasses the editor, so treat them as runtime-only rather than as features.

| Node type | What the execution does |
|---|---|
| `switch` | Evaluates each arm's rules and follows the first arm that matches, or a default arm when one is configured. With no match and no default arm the execution ends. |
| `wait_until` | Waits until a configured time and then continues, scheduled the same way a `delay` is. |
| `block_user` | Blocks the person and continues. Facebook only; on Instagram it does nothing and warns. |
| `unhide_comment`, `typing_indicator`, `mark_seen`, `set_field`, `note` | Executable, but they carry no settings panel and the sources behind this page record no configuration for them. |

## What saving does to a graph

- Each save writes a new version of the graph. The campaign points at the newest version, and only that version's trigger starts new executions.
- Every node identifier is regenerated on save and the connections are remapped to the new identifiers. Identifiers are therefore stable within a version, not across saves.
- A connection whose `from_node_id` or `to_node_id` is not in the same `nodes` array is dropped, and the rest of the graph still saves.
- An empty graph — both arrays empty — saves successfully. With no trigger in it, nothing starts an execution.
- An execution already in flight stays on the version it started on, so editing and saving a campaign never changes executions that are mid-flight.
- Older versions are removed once no execution refers to them.

## Notes

- A `postback` button on a `send_dm` node gets its own output. On save the canvas expands each button branch into extra `condition` nodes and labels the connections with the button's identifier; opening the graph collapses them back into button outputs.
- An execution follows the connection whose `output_index` matches the output the node selected. When no connection carries that index, the execution completes at that node.
- An execution that reaches a node it has already visited ends with a cycle error, which is why the editor refuses cycles in the first place.
- The configuration keys in this reference are the ones to write. Some node types also answer to older key spellings, kept so that graphs saved earlier still run.

## Related

- [Node types](/reference/node-types) — the full per-node reference for every key in `config`
- [Build a campaign](/guides/build-a-campaign) — building a graph that satisfies the constraints above
- [Test and debug campaigns](/guides/test-and-debug-campaigns) — running a saved graph and reading the path it took
- [Execution states](/reference/execution-states) — the states an execution moves through while walking a graph
