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

# Funnel Canvas

> One picture of a store — entries, pages, wires, satellites — and the five commands that change it

# Funnel Canvas

The canvas is the map of one store: where a visitor comes in, which page follows which, what the payment flow and the club and the integrations are doing on the side, and what fires on each wire.

Two things make it different from an editor:

* **It never owns your funnel.** `funnels_v2.config` stays the truth. The canvas reads it, and changes it with commands.
* **It changes it one command at a time.** Every command is queued per funnel, merged into the config key by key, validated, saved and published. Nothing is written from the browser.

Reading it is `canvas.get`. Changing it is `canvas.apply`. Those are the names of the verbs, and this page uses them throughout. On the wire they are tRPC procedures of the CRM, grouped under `v3.canvas` — they are not REST endpoints you call with an API key, and they will never appear in the API Reference tab, which is generated from `openapi.json`. An AI assistant reaches them through the [Config MCP](/developer-tools/crm-mcp/introduction) as `canvas_get` and `canvas_apply`.

Six procedures exist, and these are the exact paths:

| Verb           | tRPC path                    | What it does                                                 |
| -------------- | ---------------------------- | ------------------------------------------------------------ |
| `canvas.get`   | `v3.canvas.get`              | The whole document, in one reply                             |
| `canvas.apply` | **`v3.canvas.applyCommand`** | One command                                                  |
| —              | `v3.canvas.recompile`        | Queue a compile again                                        |
| —              | `v3.canvas.reimport`         | Take the funnel as it now stands as the new base             |
| —              | `v3.canvas.setMode`          | Switch the store between `shadow` and `live`                 |
| —              | `v3.canvas.emissions`        | What each integration of the store sends, and on which topic |

<Warning>
  **The write procedure is named `applyCommand`, not `apply`.** tRPC 11 reserves `apply`, `call` and `then` as router keys: the client proxies a router onto a function, and those three are `Function.prototype`. A router that declares one of them throws while the module loads — `Reserved words used in router({}) call: apply` — so the procedure could not carry that name. Everywhere the name is not a tRPC path, nothing changed: the verb on this page is still `canvas.apply` and the MCP tool is still `canvas_apply`.
</Warning>

All six are gated on your CRM role: reading the canvas needs store read access, changing it needs store management. A store that does not exist and a store of another account answer the same `404` — the account filter is part of the lookup, so neither is an existence oracle.

***

## The document

`v3.canvas.get({ storeId, funnelId? })` answers one object. Everything the canvas draws is in it; there is no second call. It never writes: a store that has never been touched by a command reads as an empty canvas at sequence `0`, in `shadow` mode, and no row is created for it.

| Field        | What it holds                                                                                                                                                                  |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `store`      | `id`, `name`, and `mode` — `shadow` or `live`                                                                                                                                  |
| `funnel`     | The funnel drawn, its sequence numbers, its drift flag, its last compile error, its publication state. `null` for a store that holds no funnel — an empty canvas, not an error |
| `entries`    | Where traffic enters: the storefront integration, the domains, the API keys                                                                                                    |
| `nodes`      | The blocks of the graph, each with its typed `exits`                                                                                                                           |
| `edges`      | The wires, each one leaving an exit of a node and landing on another node                                                                                                      |
| `satellites` | What sits beside the flow: payment flow, club, e-mail templates, integrations                                                                                                  |
| `bindings`   | What fires where — an event of an integration attached to the store, a node, or a wire                                                                                         |

`funnel.publish` is the honest half of "is it live":

```json theme={null}
{ "state": "no-domain", "domain": null, "lastPromotedAt": null,
  "reason": "This funnel owns no verified production domain." }
```

`state` is `published`, `no-domain` or `failed`. A compile writes the staging config and mounts its routes in every case; it only promotes to production when the funnel already owns exactly one verified domain. The dot reads "live" when `compiledSeq === pendingSeq` **and** `publish.state === 'published'`.

### Kinds

A node carries a `kind`, and the kind decides what the block can do and which exits it has.

| Kind                                   | What it is                                                                                                                                                                                                                                              |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `entry.storefront`                     | A Shopify or WooCommerce storefront that sends traffic in                                                                                                                                                                                               |
| `entry.address`                        | A domain of the store                                                                                                                                                                                                                                   |
| `entry.api`                            | An API key that opens a session                                                                                                                                                                                                                         |
| `page.checkout`                        | The payment page                                                                                                                                                                                                                                        |
| `page.offer`                           | An upsell, a downsell, a cross-sell                                                                                                                                                                                                                     |
| `page.landing`                         | A landing page                                                                                                                                                                                                                                          |
| `page.thankyou`                        | The order confirmation, where conversion fires                                                                                                                                                                                                          |
| `page.custom`                          | Any other hosted page                                                                                                                                                                                                                                   |
| `step.external`                        | A step of the funnel you host yourself — see [External steps](/developer-tools/node-sdk/external-steps)                                                                                                                                                 |
| `rule`                                 | A branch: geo, device, UTM source, cart content                                                                                                                                                                                                         |
| `flow`, `club`, `email`, `integration` | Satellites. They are read from their own tables and never written into the funnel config                                                                                                                                                                |
| `api`                                  | A satellite kind the document declares but the read never fills: an API key arrives as an `entry.api`, because for a headless store a key is *how traffic comes in*, and counting it twice would make the canvas lie about how many objects a store has |

`entry.*` and the satellites are **not addable**: they follow the store. Connect the storefront, add the domain, create the API key, and the block draws itself on the next read.

### Exits

An exit is the port a wire leaves from. Its `id` is `nodeId:kind`, or `nodeId:custom:name` for a named exit.

| Node kind                                                                  | Exits                      | Condition written on the wire                                            |
| -------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------ |
| `page.checkout`                                                            | `paid`, `failed`           | `payment.success` / `payment.failed`                                     |
| `page.offer`                                                               | `accepted`, `declined`     | `offer.accepted` / `offer.declined`                                      |
| `page.landing`, `page.thankyou`, `page.custom`, `step.external`, `entry.*` | `next`                     | none — the default wire                                                  |
| `rule`                                                                     | `match`, `otherwise`       | the condition of the rule; `otherwise` is the default wire, checked last |
| `step.external` declared with `exits`                                      | one `custom` exit per name | none — see the warning below                                             |

The four rules compile to conditions the runtime already evaluates:

| Rule     | Parameters                         | Condition                                                    |
| -------- | ---------------------------------- | ------------------------------------------------------------ |
| `geo`    | `countries: ["FR", "BE"]`          | `customer.fromCountry`, one per country, combined with `or`  |
| `device` | none                               | `customer.onMobile` — "not mobile" is the `otherwise` exit   |
| `utm`    | `sources: ["newsletter"]`          | `customer.fromUtmSource`, one per source, combined with `or` |
| `cart`   | `selection` (product and variants) | `mainOrder.hasProduct`                                       |

<Warning>
  A **named exit** of an external step is kept in the canvas, drawn, and never lost — but the runtime cannot branch on it yet. Its wire is written as a default wire carrying the name in `metadata.canvasExit`, so the path actually taken is the highest-priority one. `canvas.apply` returns a `warnings[]` entry saying so on the command that creates it.
</Warning>

A condition read back from a funnel the canvas did not write is never dropped. If it names something outside the runtime's 31 conditions, it comes back as a `custom` exit with the raw condition as its label and `unroutable: true`.

***

## The five commands

`canvas.apply` accepts exactly five verbs. There is no sixth, and there is no free-form patch of the config.

### add

Creates a node and, when `after` is given, the wire that reaches it.

```json theme={null}
{
  "type": "add",
  "kind": "page.offer",
  "after": { "node": "step_checkout", "exit": "step_checkout:paid" },
  "props": { "name": "Post-purchase upsell", "offerId": "offer_2f1c", "quantity": 1 }
}
```

| Kind                                           | `props`                                                                |
| ---------------------------------------------- | ---------------------------------------------------------------------- |
| `page.checkout`                                | `name`, `template?`                                                    |
| `page.offer`                                   | `name`, `template?`, `offerId?`, `productId?`, `priceId?`, `quantity?` |
| `page.landing`, `page.thankyou`, `page.custom` | `name`, `path?`                                                        |
| `step.external`                                | `name`, `url`, `exits?` (up to 10 names)                               |
| `rule`                                         | `name`, `rule` (`geo`, `device`, `utm` or `cart` with its parameters)  |

A name is at most 120 characters. A `url` must be an absolute `https://` address. `entry.*` is refused — see [entry-not-addable](#entry-not-addable).

### remove

```json theme={null}
{ "type": "remove", "id": "step_upsell_1" }
```

Removes the node and every wire that touched it.

### connect

```json theme={null}
{ "type": "connect", "from": { "node": "step_checkout", "exit": "step_checkout:failed" }, "to": "step_retry" }
```

An exit carries exactly one wire. Connecting an exit that already has one is refused ([exit-already-connected](#exit-already-connected)), and so is a wire that closes a loop ([cycle](#cycle)).

### bind

Attaches an event of an integration to the store, a node, or a wire.

```json theme={null}
{
  "type": "bind",
  "target": { "kind": "edge", "nodeId": "step_checkout", "exit": "step_checkout:paid" },
  "integration": "integration_9a2b",
  "event": "order/paid",
  "enabled": true
}
```

`target` is `{ "kind": "store" }`, `{ "kind": "node", "nodeId": "…" }` or `{ "kind": "edge", "nodeId": "…", "exit": "…" }`.

<Warning>
  **`event` is the platform topic, `namespace/name`, not the name the destination shows.** `order/paid`, `checkout/initiated`, `subscription/started` — never `Purchase`, never `Order Paid`. The subscriber matches this string against the topic of the event as it leaves the bus, so a destination-side label matches nothing and the marker never fires. The name the destination will see for that topic is in `v3.canvas.emissions`, keyed by the same provider × topic pair.

  This holds for `store` and `edge` targets, which the server dispatches. A `node` target is a **browser** marker — the pixel of a page — so its `event` is one of the browser event names of that destination, listed as `browserEvents` in `v3.canvas.emissions`: `PageView`, `InitiateCheckout`, `Purchase`, and `AddToCart` / `ViewContent` / `Search` and friends depending on the provider.
</Warning>

`bind` always writes a row of a canvas-owned table at accept time, in the same transaction as the journal, so the marker the drawer shows is always one the queue actually wrote. Binding the same event twice on the same place toggles that row instead of adding a second one — two rows would fire the pixel twice.

A `store` or `edge` target stops there and changes nothing in `funnels_v2.config`. A **`node` target also writes the page pixel**: the next compile merges the event into `node.config.stepConfig.pixels[provider]`, an owned sub-key, taking the pixel id from the integration's own settings. It is a merge, never a rewrite — a pixel you set up outside the canvas keeps its id, its other events, its enabled flag and its Google Ads settings, and a second pixel of the same provider is left alone. `enabled: false` clears that one event, and drops the entry when it was its last one. Four providers carry a page pixel: `facebook`, `tiktok`, `snapchat` and `gtm`. Any other destination, an event it does not send from the browser, or an integration with no pixel id yet, comes back as a **warning** on the reply — the marker row is written, the page is not.

### set

Changes one node in place. Every field is optional, at least one must be present, and a field that does not apply to the node it lands on is refused.

```json theme={null}
{ "type": "set", "id": "step_upsell_1", "patch": { "name": "Upsell — 2 bottles", "quantity": 2 } }
```

`name` · `index` · `path` · `url` · `template` · `offerId` · `productId` · `priceId` · `quantity` · `exits` · `rule` · `variants`.

***

## The envelope

```json theme={null}
{
  "storeId": "store_8d5f84244751",
  "funnelId": "funnelv2_84de654a9f56",
  "commandId": "01J9Z2X4V7A8B9C0D1E2F3G4H5",
  "expectedSeq": 12,
  "command": { "type": "remove", "id": "step_upsell_1" }
}
```

**`commandId` is yours to generate, and replaying it is safe.** A `commandId` already seen returns the original reply — the same document, the same sequence number — and never a conflict and never a second command. Retry on a timeout with the same id; generate a new one only for a genuinely new command.

**`expectedSeq` is the `funnel.pendingSeq` you read.** If someone else moved the canvas in between, the command comes back [stale](#stale) with the fresh document, so you can re-read and re-send in one round trip.

### The reply is a union of three, and all three are HTTP 200

Branch on `status`. A refusal is **data, not an exception**: it travels inside the reply, with the reason and the remedy, because the tRPC error formatter of the CRM only lets `zodError` through and would drop `code`, `cause`, `fix` and `docs` on the way out.

```json theme={null}
{
  "status": "applied",
  "document": { "…": "the canvas as it now stands" },
  "seq": 13,
  "compile": "pending",
  "warnings": []
}
```

```json theme={null}
{
  "status": "refused",
  "refusal": {
    "code": "unknown-node",
    "message": "This command names a node the canvas does not hold.",
    "cause": "No node `step_upsell_9` in this funnel.",
    "fix": "Call `canvas.get` again and take the id from `document.nodes[].id`.",
    "docs": "https://docs.tagada.io/developer-tools/funnels/canvas#unknown-node"
  }
}
```

```json theme={null}
{
  "status": "stale",
  "document": { "…": "the canvas as it now stands" },
  "seq": 12
}
```

`compile` is `pending` when the funnel is queued for a compile, `skipped` in shadow mode.

<Tip>
  For an agent this is the useful shape: `fix` is an instruction it can act on without a human, and `docs` is the page that explains it. Branch on `code` — it is versioned and never changes meaning. `message`, `cause` and `fix` are prose and may be sharpened with the actual value.
</Tip>

<Warning>
  **A malformed command is not a `refused` reply — it is an HTTP 400 with a zod error.** The input schema runs before any of this, so a name over 120 characters, an unknown key, a missing `expectedSeq` or an `http://` address never reach the refusal union. What comes back is the standard tRPC `zodError`, and the offending field is named in it:

  ```json theme={null}
  { "fieldErrors": { "command.props.url": ["Invalid input: must start with \"https://\""] } }
  ```

  Read both shapes: the 400 tells you which *field* is wrong, the `refused` reply tells you which *rule* you broke. The catalogue codes below that a well-formed command can never reach — [name-too-long](#name-too-long) and [url-not-https](#url-not-https) — exist for callers that build a command somewhere else and want the same wording.
</Warning>

Three things are still thrown errors, so handle them as errors and not as a `status`:

* `404` — an unknown or foreign `storeId`, on every procedure of the group.
* `412` — `v3.canvas.setMode` going `live` while the canvas holds commands that never compiled.
* `429` — `v3.canvas.applyCommand` when an agent actor passes [rate-limited](#rate-limited).

***

## What happens after a command is accepted

```text theme={null}
canvas.apply
  → zod, and every id checked against your account
  → dry-run on a copy of the config
  → the command is journalled (seq = pendingSeq + 1)
  → the funnel is queued for a compile (one at a time, per funnel)
  → merge into funnels_v2.config, by node id and by owned sub-key
  → validate
  → save (staging routes are mounted here)
  → publish, if the funnel owns one verified production domain
  → compiledSeq catches up with pendingSeq
```

The merge only writes the keys the canvas owns — a name, a type, an entry flag, an index, order bumps, upsell offers, payment and resource settings, pixels, variants, the address of an external step or a storefront (the one URL you typed yourself), and the wires. Positions, instance ids, mount points, plugin versions, screenshots and routing are read and put back untouched, which is what lets the V2 editor, a Studio deploy and the screenshot job keep working on the same funnel.

### Shadow mode

A store starts in `shadow`. Commands are accepted and journalled, the merge and the validation run, the diff is logged — and nothing is written to the funnel. `compile` comes back as `skipped`.

`v3.canvas.setMode` switches it. Going `live` while `pendingSeq !== compiledSeq` is refused with an HTTP **412**, not a typed refusal: the procedure answers with the mode and nothing else, so there is no channel for a body. The message says so — *"This canvas cannot go live while it holds commands that were never compiled."* Either run `canvas.reimport` to drop them, or read the shadow diff in the logs and accept it, then switch again.

### Drift

The canvas remembers what the funnel looked like at the last compile — an `updated_at` token plus a hash of the config with every key the canvas does not own projected out. If someone changed the funnel outside the canvas, `funnel.drift` is `true`.

**A command is still accepted while a funnel is in drift.** It takes its sequence number and is journalled as usual; it is the *compile* that stops, writes nothing, and logs why. Nothing anyone wrote outside the canvas is ever overwritten. `canvas.reimport` is the way out: it takes the funnel as it now stands as the new base and marks every command that never compiled as failed, with [drift](#drift) as the reason — that reason is where you read the code, not the reply to the command itself.

***

## Error catalogue

Fifteen codes, versioned. A code never changes meaning; a new one is additive. Every refusal carries a `message` (what happened), a `cause` (why), a `fix` (what to do) and a `docs` link to the section below.

Most of them reach you as a `refused` reply on an HTTP 200. Four do not, and knowing which saves an hour: `stale` is the third branch of the union, also a 200; `graph-not-found` is an HTTP 404; `rate-limited` is an HTTP 429; `drift` is the reason stamped on commands that `canvas.reimport` discards, and the HTTP 412 that `v3.canvas.setMode` throws. `name-too-long` and `url-not-https` describe rules the input schema already enforces, so a command sent to tRPC or to the MCP tool hits the [HTTP 400 with a zod error](#the-envelope) first.

| Code                                              | What it means                                                  |
| ------------------------------------------------- | -------------------------------------------------------------- |
| [stale](#stale)                                   | The canvas moved before this command could be applied          |
| [unknown-node](#unknown-node)                     | This command names a node the canvas does not hold             |
| [unknown-exit](#unknown-exit)                     | This node has no such exit                                     |
| [unknown-integration](#unknown-integration)       | No integration of this store carries that id                   |
| [foreign-id](#foreign-id)                         | This id belongs to another account                             |
| [node-limit](#node-limit)                         | This funnel already holds the maximum number of nodes          |
| [name-too-long](#name-too-long)                   | This name is longer than 120 characters                        |
| [url-not-https](#url-not-https)                   | An external step address must start with `https://`            |
| [entry-not-addable](#entry-not-addable)           | An entry cannot be added from the canvas                       |
| [exit-already-connected](#exit-already-connected) | This exit already carries a wire                               |
| [cycle](#cycle)                                   | This wire would send the funnel back into itself               |
| [validate-funnel-failed](#validate-funnel-failed) | The funnel would not pass validation with this command applied |
| [drift](#drift)                                   | This funnel was changed outside the canvas                     |
| [rate-limited](#rate-limited)                     | Too many commands on this canvas                               |
| [graph-not-found](#graph-not-found)               | No canvas for this store                                       |

### stale

`expectedSeq` does not match the graph `pendingSeq`: another command was accepted between the read and this write. You also get it when you ask for a second funnel of the same store while the queue of the first is not empty.

It arrives as `{ "status": "stale", "document", "seq" }` on an HTTP 200, not as an error.

**Fix.** Take the `document` returned with it and send the command again with its `funnel.pendingSeq` as `expectedSeq`. Re-read and retry — it is not a failure to show the merchant.

### unknown-node

No node of the current document carries that id: it was removed, or it belongs to another funnel.

**Fix.** Call `canvas.get` again and take the id from `document.nodes[].id`.

### unknown-exit

The exit id is not in the `exits` of the node it is attached to: a checkout has `paid` and `failed`, an offer `accepted` and `declined`.

**Fix.** Read `document.nodes[].exits[].id` for that node and use one of the values it lists.

### unknown-integration

The integration was deleted, or it belongs to another store of the account.

**Fix.** Take an id from `document.satellites[]` where `kind` is `integration`, or connect the integration on the store first.

### foreign-id

Every id a command names — offer, product, price, integration, club, API key, template, funnel — is checked against the caller account before anything is written.

**Fix.** Replace it with an id returned by the matching read for this account.

### node-limit

A canvas graph is capped at 200 nodes and this command would push it past that.

**Fix.** Remove a node the funnel no longer serves, or move the tail of the flow into a second funnel, then send the command again.

### name-too-long

Names are written into the funnel config and drawn on the card; 120 characters is the ceiling.

Over tRPC and over the MCP tool the input schema catches it first, so what you actually receive is an HTTP 400 naming `command.props.name` or `command.patch.name`. The code exists for a caller that builds the command elsewhere and wants the same wording.

**Fix.** Shorten the name to 120 characters or fewer, then send the command again.

### url-not-https

The value is not an absolute `https://` URL: `http://`, a bare host and a relative path are all refused.

<Warning>
  **An `http://` address does not come back as a `refused` reply.** It is caught by the input schema, so what you get is an HTTP 400 with the field named:

  ```json theme={null}
  { "fieldErrors": { "command.props.url": ["Invalid input: must start with \"https://\""] } }
  ```

  Both shapes are readable, but they are not the same shape. Handle the 400 on the field, not in the refusal branch.
</Warning>

**Fix.** Give the full address, scheme included, for example `https://shop.example.com/thanks`.

### entry-not-addable

`entry.storefront`, `entry.address` and `entry.api` are read from the store — its storefront integration, its domains, its API keys — and never written into the funnel.

**Fix.** Connect the storefront, add the domain or create the API key on the store, then reload the canvas: the entry draws itself.

### exit-already-connected

An exit routes to exactly one node; a second wire would leave the runtime with two candidates on the same condition.

**Fix.** Remove the wire that leaves this exit first, or connect a different exit of the same node.

### cycle

The node in `from` is already reachable from the node in `to`, so the runtime would walk between the two forever.

**Fix.** Point the wire at a node that comes after `from`, or remove the wire that closes the loop first.

### validate-funnel-failed

Validation reported at least one error on the resulting config: a missing page configuration, a duplicate path, an orphan step or a missing entry step.

**Fix.** Read the issues logged with this command, fix the node it names, and send the command again.

### drift

The funnel no longer matches the snapshot the canvas last compiled from: the V2 editor, a Studio deploy or a script wrote to it.

You meet this code in two places, and never as the answer to a command: stamped on the commands `canvas.reimport` discards, and as the HTTP 412 `v3.canvas.setMode` throws when the canvas still holds commands that never compiled.

**Fix.** Run `canvas.reimport` to take the funnel as it stands as the new base, then send the command again.

### rate-limited

An agent actor is capped at 60 commands per minute per graph, so one script cannot starve the merchant editing the same funnel. A command sent from the canvas screen by a signed-in user is not an agent and is not counted.

It arrives as an HTTP 429, thrown, not as a `refused` reply.

**Fix.** Wait for the current minute to pass, and group what you can into fewer commands before sending again.

### graph-not-found

The `storeId` names no store of the caller's account: it does not exist, or it belongs to someone else. The account filter sits in the same `where` as the id, so the two answer identically and neither confirms that a store exists.

It arrives as an HTTP 404, thrown, not as a `refused` reply.

**Fix.** Send a `storeId` this account owns. The name of the code is a leftover: the graph row is not what is missing, and the read never creates one either — the row appears the first time a command is accepted for the store, and `v3.canvas.get` on a store without one answers an empty canvas at sequence `0`, not this error.

***

## Next

<CardGroup cols={2}>
  <Card title="External Steps" icon="signal" href="/developer-tools/node-sdk/external-steps">
    Put a page you host on the canvas, and report what happens on it.
  </Card>

  <Card title="Config MCP" icon="robot" href="/developer-tools/crm-mcp/tools-reference">
    `canvas_get` and `canvas_apply` from an AI assistant.
  </Card>
</CardGroup>
