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.configstays 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.
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 as canvas_get and canvas_apply.
Six procedures exist, and these are the exact paths:
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.
funnel.publish is the honest half of “is it live”:
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 akind, and the kind decides what the block can do and which exits it 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. Itsid is nodeId:kind, or nodeId:custom:name for a named exit.
The four rules compile to conditions the runtime already evaluates:
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, whenafter is given, the wire that reaches it.
A name is at most 120 characters. A
url must be an absolute https:// address. entry.* is refused — see entry-not-addable.
remove
connect
bind
Attaches an event of an integration to the store, a node, or a wire.target is { "kind": "store" }, { "kind": "node", "nodeId": "…" } or { "kind": "edge", "nodeId": "…", "exit": "…" }.
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.name · index · path · url · template · offerId · productId · priceId · quantity · exits · rule · variants.
The envelope
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 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 onstatus. 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.
compile is pending when the funnel is queued for a compile, skipped in shadow mode.
Three things are still thrown errors, so handle them as errors and not as a status:
404— an unknown or foreignstoreId, on every procedure of the group.412—v3.canvas.setModegoinglivewhile the canvas holds commands that never compiled.429—v3.canvas.applyCommandwhen an agent actor passes rate-limited.
What happens after a command is accepted
Shadow mode
A store starts inshadow. 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 — anupdated_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 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 amessage (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 first.
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. Callcanvas.get again and take the id from document.nodes[].id.
unknown-exit
The exit id is not in theexits 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 fromdocument.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 namingcommand.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 absolutehttps:// URL: http://, a bare host and a relative path are all refused.
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 infrom 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 commandscanvas.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 arefused reply.
Fix. Wait for the current minute to pass, and group what you can into fewer commands before sending again.
graph-not-found
ThestoreId 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
External Steps
Put a page you host on the canvas, and report what happens on it.
Config MCP
canvas_get and canvas_apply from an AI assistant.