shop-backend-builder
Build a merchant's own BACKEND on this multi-tenant platform via the shop MCP — declared data tables, node-graph workflows bound to API paths, Go code inside nodes, built-in auth/files, and the API domain they answer on. Use when the user wants an API, an app backend, a webhook receiver, scheduled logic, or data their website does not render. NOT for website content — a thing visitors read is a content type or a blog post, and putting it here gives it no page.
Declare tables, draw workflows, bind them to API paths. Tools:
list_backend_tables, create_backend_table, update_backend_table, delete_backend_table,
query_backend_rows, insert_backend_row, update_backend_row, delete_backend_row,
get_backend_node_catalog, list_backend_workflows, save_backend_workflow,
publish_backend_workflow, list_backend_workflow_versions, revert_backend_workflow,
list_backend_routes, upsert_backend_route, list_backend_runs,
validate_backend_workflow, test_backend_workflow,
list_backend_secrets, set_backend_secret,
list_backend_schedules.
Read
shop-core-rulesfirst — §A (propose + confirm before creating), §D (verify). Sold on the growth plan; a website on a lower plan gets a clear refusal, not a missing tool.
Is this the right surface?
| The merchant wants | Use |
|---|---|
| Something visitors READ on the site | a content type (shop-create-content-type) or the blog |
| An API their app/webhook calls; data with no page | this |
| A contact form | a content type with is_form — not this |
Putting a page's content in a backend table gives it no route, no template and no SEO. Putting an app's data in a content type gives it a public URL nobody asked for.
1. The declaration is the contract
A table's columns decide what the API accepts, what the admin grid shows, and what the generated MCP tools advertise. There is one declaration and everything reads it.
Types: text long_text integer number boolean date datetime json email url uuid enum reference file password.
An unknown word is refused — it never falls back to text.
Rules the platform enforces, so do not work around them:
id,created_at,updated_atare the record's envelope — a column may not take those names.enumwithoutoptions,referencewithoutref_table→ refused.uniqueis unavailable onjson,password,long_text(no canonical value to compare).passwordis write-only: it stores an argon2id hash and never comes back.
A write naming an undeclared column is REFUSED with that column named. It is not dropped. If you
get column-not-declared, you invented a field — fix the call or declare the column; never retry
with the field removed and pretend the data was stored.
2. Draw the workflow, then publish it
Call get_backend_node_catalog FIRST. It is generated from the publish gate's own table, so a node it
lists is a node the gate knows, and it tells you which settings are required.
The website's own data is a table too
A workflow reaches the merchant's ORDERS, PRODUCTS, BLOG POSTS, CONTACTS, MEDIA
and every content type this website declares by naming them in the shop:
namespace from the ordinary db nodes — table: "shop:order", exactly where you
would write one of their own tables. There is no separate node, no API key, no
HTTP call to the platform.
A content type is one entity per type: shop:content:<type_code>. So a site
with a dich_vu type reads it as table: "shop:content:dich_vu", and the
type's declared FIELDS are that entity's columns — misspell one and the publish
gate refuses you while you draw, exactly as it does for an order. This is how a
content site's catalogue is reached; there is no single shop:content_record
table, because one opaque values blob would give up the column checking that
is the whole point of the namespace.
Its columns are the record's own — id, title, slug, status
(draft/published/submitted), created_at, updated_at — plus one per
declared field. A field whose code collides with one of those six is NOT
reachable (the record column keeps the name); the catalogue's why names it.
Form types (is_form) are content types too: their submissions are records, so
shop:content:<form_code> is how a workflow reads what people sent.
Call get_backend_shop_catalog FIRST. It says which entities THIS website has,
each column, which are read-only, what can be filtered and sorted, and the row
ceiling per read. It is generated from the publish gate's own registry, so a
column it lists is a column the gate accepts.
Four things it will not let you do, and each is refused while you draw:
- an order's status, total or customer is read-only. The only writable field
is
internal_note, which appends to the order's own timeline — the history the admin shows.noteis what the CUSTOMER typed at checkout and is theirs. There is no way to create an order at all: checkout recomputes every price from the database, and a workflow that could setpayment_statuswould be able to mark an order paid with no money behind it; - filter and sort only by what the catalogue lists. The services underneath take narrow queries; a filter that could not be applied would return the wrong rows while looking like it worked, so it is refused instead of ignored;
shop:productis the real catalogue, and only on a store that sells. A content site's products are a content type, reached asshop:content:<type_code>— do not expect ashop:productthat does not exist, and do not invent one;- a content type cannot be filtered or sorted from a workflow. The service
underneath narrows by nothing but published state, and a filter that could not
be applied would return the wrong rows while looking like it worked. Read a
page and narrow in a
codenode, or start from thecontent.form.submittedevent, which hands you the record; - media is read-only. Uploading goes through checks that live outside any service a workflow can call.
Every shop: update is a PATCH: a column you do not name is left alone.
The trigger is a NODE
Every workflow starts at exactly one trigger node, and it is DRAWN:
| node | starts the workflow when |
|---|---|
http_in | a request arrives on a bound route |
schedule_trigger | the clock says so — {expression}, 5-field cron, UTC |
event_trigger | the website does something — {event} from get_backend_shop_events |
⚠️ A one-time token NEVER comes back in a response. /auth/forgot-password
answers {"ok": true} whether or not the address has an account — the same
bytes either way, or the answer becomes a list of who has one. The reset token
is delivered to the MAILBOX: it fires password.reset_requested, and if a
workflow answers, that workflow sends the email in the merchant's own words. The
same holds for user.registered and its verification token. If nothing answers,
the platform sends a plain message itself, because a reset that silently goes
nowhere is worse than a plain one.
⚠️ The binding is DERIVED from the graph at publish. Draw a schedule_trigger
and publishing writes the timetable; delete the node and publishing removes it.
There is no separate mutation to subscribe or to schedule — a second way to say
it would be a second thing to keep in step, and the next publish would silently
overrule it.
Running a workflow when the website does something
A workflow can start on an event instead of a request: an order is placed, a
form is submitted. Call get_backend_shop_events for the list.
Draw an event_trigger as the workflow's entry and publish it. That is the whole
binding — the same way a schedule works.
The event arrives in the ordinary input envelope, so one graph shape reads every trigger:
{{ input.method }} "EVENT"
{{ input.event }} "order.placed" ← which one, for a workflow bound to two
{{ input.body }} the order, in the SAME shape a shop:order read returns
So {{ input.body.total }} on order.placed means what {{ find.rows.0.total }}
means in a query. One vocabulary.
Two things to know before building on it:
- it only fires once PUBLISHED. The binding is written from the published graph, so a half-finished edit cannot fire on the next real order;
- an event is best-effort, and the platform says so rather than pretending. It is published after the order's transaction commits, so a process that dies in between loses it. A sweep re-offers recent orders every few minutes and an idempotency key stops anything running twice — a miss is corrected within half an hour, not never made. If a step must not be missed at all, make it something the workflow can re-derive, not something it only does once.
A graph is {nodes:[{id,type,config}], edges:[{from,to,port}]}:
- ⚠️ the settings key is
config, and every key inside it is REFUSED unless the catalogue lists it for that node type. A node also takes onlyid, type, label, config, an edge onlyid, from, to, port, the graph onlynodes, edges. Nothing here is dropped quietly: writingsettingsinstead ofconfig, orbodyyinstead ofbody, comes back naming the node and the key. It used to save clean, draw clean, publish clean and then answer every request with an empty body — which is why the save refuses it now; - ⚠️ do not place the nodes.
x/yexist on a node, but they belong to the canvas — the merchant's drawing, and the only thing that knows a card is 220×62. You have never seen it, so any spacing you pick is a guess: one graph saved at 180px apart put every card 40px under the next, eight overlapping pairs out of ten nodes, titles cut off mid-word. Omitx/yand the canvas lays the graph out from its own shape. Positions that already exist and overlap are laid out too, so nothing you saved before is stuck; - a publish can be undone. Publishing overwrites the live graph in place, so
the platform keeps the outgoing one:
list_backend_workflow_versionsshows what this workflow has served before, andrevert_backend_workflowputs one back. It PUBLISHES rather than restores — the old graph goes through the same gate, because the world around it has moved and a table it wrote to may be gone, so you can get violations back instead of a revert. The graph you replace is kept too, so a revert is itself revertible, and the draft is left alone; http_intakes no settings at all. The method and path live on the ROUTE (upsert_backend_route); a copy on the node would be a second place holding the same fact, and the two would drift;- exactly one
http_in, and every path must reach arespond; - a
branchhas two edges, ported"true"and"false"— both must go somewhere; - no loops: a request that loops never answers, and the gate refuses one.
Reference earlier steps with {{ }}. A path, nothing more — no arithmetic, no calls:
{{ input.body.email }} the request
{{ find.rows.0.id }} an earlier node's output
"order #{{ save.id }}" printed into a sentence
A lone reference keeps its type ({{ save.id }} stays a number); one inside text becomes text.
That distinction matters: an integer column refuses "77".
What else is readable:
| reference | is |
|---|---|
{{ input.body.x }} {{ input.query.x }} {{ input.params.id }} | the request |
{{ input.ip }} | the caller's address, as the platform resolved it |
{{ input.user.id }} | the signed-in end user (route auth end_user) |
{{ secret.stripe_key }} | a stored credential |
A per-IP limit goes on {{ input.ip }}, never on a header — the forwarding
headers are removed from input.headers precisely because they are whatever the
caller typed.
⚠️ A LIMIT IS ONLY A LIMIT IF ITS KEY IS NOT THE CALLER'S TO CHOOSE. This is
the general law, not a note about one field. input.ip is resolved by the
platform from the hop it can account for, so a caller cannot mint a fresh
identity per request. Anything a caller sends — a header, a body field, a
cookie, a query string, an X-Client-Id you invented — can be rotated, and a
cap keyed on it counts nobody while looking like it works. Key a cap on
input.ip, on input.user.id (route auth end_user), or on a row you wrote
yourself; never on something that arrived in the request.
You may only reference a node that certainly runs before this one. Reading across a branch is reading a value that may never have been produced, and the gate refuses it.
2b. Check it and RUN it before you publish
Two tools, and using them is the difference between building and guessing:
validate_backend_workflow— the PUBLISH GATE, run on the draft. Same rules, same violations, each naming the node. Call it after every edit. Nothing new can appear at publish time, because it is the same check.test_backend_workflow— EXECUTE the draft once with a request you make up, and read every step: what each node returned, what it printed, how long it took, which one failed.
Debug by reading the STEPS, not the final answer. A 500 tells you the
workflow failed; the steps tell you find returned zero rows because the filter
compared a number to a string.
wm.Log("here", value) in a code node lands in that step's logs. It is the
only way to see inside a node, and it survives a node that FAILED — which is
exactly the run whose prints you need.
If the gate refuses the draft you get violations INSTEAD of a run. That is not the tool being unhelpful: a graph the gate refuses cannot be usefully executed, and its failure would be a confusing consequence of the real problem rather than the problem.
3. Go inside a code node
import (
"strings"
"wm"
)
func Run() map[string]any {
in := wm.Input()
name, _ := in["name"].(string)
return map[string]any{"greeting": "hello " + strings.ToUpper(name)}
}
func Run() map[string]anyexactly. No parameters. The gate refuses anything else.import "wm"yourself — it is not injected. What the gate reads is what the interpreter runs.wmgives youInput Log Query Insert Update Delete Fetch Secret Now. There is no other way out: a node cannot open a socket, a file or a process, and the allowed import list is closed.- A disallowed import is refused at publish, in front of you — not when a customer calls.
- A node has a few seconds and a memory cap. Longer work belongs behind a
waitnode.
4. Publishing refuses, and it tells you where
publish_backend_workflow runs the gate. It measures both directions and names the node:
| rule | means |
|---|---|
bb-node-input-unbound | a required setting is empty |
bb-route-no-respond | no path answers the request |
bb-ref-not-declared | {{ }} names a node that does not exist or does not run first |
bb-table-field-not-declared | a db node touches a column nobody declared |
bb-import-not-allowed | a code node reached outside the allowed set |
bb-code-no-run | no func Run() map[string]any |
bb-branch-port | a branch side goes nowhere |
bb-cycle | the graph loops |
Fix the node it names and publish again. Nothing is published when it refuses — do not report success on a refusal, and do not route around a rule by deleting the node that triggered it.
5. Bind a path
upsert_backend_route — method, path, workflow_code, auth_mode, is_published.
auth_mode: public · api_key (default) · end_user · signed (a webhook, see below).
A route goes live only once its workflow is published; the server refuses otherwise, because an endpoint answering 500 is the merchant finding out from a customer.
A signed route also declares WHOSE signature. signature_scheme is
webmati (default, HMAC-SHA256 of the raw body in X-Signature), stripe or
github. This is the only vendor-shaped thing in the product, and it earned its
place: a signature is an HMAC over the raw body, and by the time any node
runs the body is already parsed — no workflow can verify one.
Stripe's is the case worth knowing: it signs "<timestamp>.<body>", not the
body, and rejects a delivery older than five minutes. Hand-rolling that is how
every genuine delivery ends up 401.
Store the secret with set_backend_secret under the scheme's code
(stripe_webhook_secret, github_webhook_secret), or name your own with
signature_secret_code when one website takes deliveries from several endpoints
— Stripe issues a separate secret per endpoint.
5a. Let the platform own the dangerous parts
Ownership. Give a table a column of type owner and stop wiring it by hand.
The platform stamps the signed-in user on write and filters by them on every
read, update and delete — from the run TOKEN, never from the request, so a
workflow cannot hand a record to someone else even if its author writes the
field. A request with no end user cannot touch such a table at all, and an update
of a record you do not own answers "no such record" rather than "not yours".
Without it you are writing that check on every route, and it is the one place a mistake shows nothing: one person simply sees another's records.
Calling an AI model. http_request to the router, with the key as
{{ secret.<code> }} in the Authorization header. Most routers answer
text/event-stream by default, and the node handles it: the result carries
{{ ask.text }} the assembled answer — bind THIS
{{ ask.events }} every event, data parsed when it is JSON
{{ ask.stream }} true when the response was a stream
{{ ask.body }} the raw stream, for a shape nobody named
text is assembled from three named shapes only — OpenAI-compatible
choices[].delta.content, the Responses API's delta, and Anthropic's
delta.text. Anything else gives text: "" on purpose: an empty string the
merchant can see beats a plausible one nobody can check. Read events then.
⚠️ This is a request/response node. The workflow answers its caller once, when it finishes — so a chat endpoint built this way returns the whole message, not tokens as they arrive. Do not tell a merchant they will get token-by-token streaming out of a workflow; they will not.
Secrets. set_backend_secret once, then {{ secret.code }} in an
http_request header/URL/body, or wm.Secret("code") in Go. Filled on the
platform at the moment of the call, scrubbed out of the run log, and refused in a
respond node.
A hostname of their own. get_backend_api_domain first — every route lives
under it. provision_backend_api_domain mints the built-in api-<slug> one;
add_backend_api_domain attaches a hostname the merchant owns, which comes back
pending.
⚠️ Read the records off the reply. Never compose them. The verifier does a
byte comparison, so a value that merely looks right fails forever — and it did:
this screen once printed its own CNAME target, its own TXT name and its own TXT
value, and all three were wrong. The reply carries cname_target,
txt_record_name and txt_record_value; tell the merchant either:
CNAME <the hostname> → <cname_target>
TXT <txt_record_name> → <txt_record_value>
One is enough. The TXT is the only one that works behind Cloudflare's orange cloud, which rewrites the CNAME — which is most merchants, so lead with it if they mention Cloudflare.
Then verify_backend_api_domain. DNS takes minutes to hours, so a failure right
after the records are added is normal — wait and call again rather than saying
something is wrong. HTTPS is issued automatically on the first request.
When it answers error code: 525 the origin is fine and Cloudflare is not:
the record is proxied (orange cloud), Cloudflare wants a certificate the origin
cannot obtain while Cloudflare is the thing answering the ACME challenge. Tell
the merchant to pick one — Cloudflare SSL/TLS → Flexible, or a Cloudflare
Origin Certificate uploaded to the platform with Full (strict), or turn the
orange cloud off. Nothing on this side can change their Cloudflare setting.
Retiring the built-in hostname. Once a custom one is verified the merchant
may switch api-<slug> off — as a 308 to the custom hostname, or as a 410.
308 and not 301 on purpose: a 301 lets a client turn a POST into a GET and
several well-known ones do. It is refused while no custom hostname is verified,
because that would take every published route offline.
The API never shares a hostname with the website: /orders must not be
ambiguous between a page and an endpoint. A hostname already serving their
website is refused rather than re-pointed.
Social sign-in. set_backend_auth_provider for google, facebook or
github. Do not draw this: the provider redirects to a fixed address that
has to exist before any workflow does, and only the platform can mint the
session.
⚠️ THE FRONT END HAS HALF OF THIS, AND IT IS NOT OPTIONAL. The platform can sign someone in; it cannot store the session in their browser. Build both halves or the flow ends with the person looking at an API URL.
Set redirect_allowlist when you configure the provider — at least one address.
It is where the browser is sent afterwards, not merely a filter: when the app
does not name one, the FIRST entry is used.
<!-- 1. Starting it is a LINK, not fetch(). The provider's consent screen is a
page the person has to see, and XHR cannot navigate there. -->
<a href="https://api-<slug>.<apex>/auth/oauth/google/start?return_to=https://yourapp.com/dashboard">
Đăng nhập với Google
</a>
// 2. On the page you named, the session is waiting in the URL FRAGMENT.
// A fragment is never sent to a server: it stays out of your access log and
// out of the Referer your next page sends. That is why it is not a query
// parameter, and it is why the front end must read it — nothing else can.
const hash = new URLSearchParams(location.hash.slice(1));
const token = hash.get("token");
if (token) {
localStorage.setItem("session", token);
// Take it out of the address bar: a token left in the URL goes into history,
// into a screenshot, and into whatever the person pastes next.
history.replaceState(null, "", location.pathname + location.search);
}
// 3. Every later call carries it.
fetch("https://api-<slug>.<apex>/_bb/todos", {
headers: { Authorization: "Bearer " + localStorage.getItem("session") },
});
A route the signed-in person calls should be auth_mode: "end_user" — then
{{ input.user.id }} is them, and an owner-scoped column is filled in by the
platform rather than trusted from the body.
Read list_backend_auth_providers first — it gives you the exact callback_url
to register with the provider (compared byte for byte; one stray / is
redirect_uri_mismatch), and it tells you the one thing that is not cosmetic:
asserts_email_verification. Google states whether an address was verified, so a Google sign-in may adopt an existing account with that email. Facebook and GitHub never state it, so a colliding email is REFUSED with an instruction — sign in with the password, then link the provider — rather than silently becoming the same person. Adopting an account on an unverified address means whoever can register that address at the provider inherits the merchant's user. Do not ask for a way around this; there isn't one, and the refusal is the feature.
Enabling a provider requires a redirect_allowlist. The callback hands out a
session token, so a callback that redirects anywhere is a way to have that token
delivered somewhere else.
An account created this way has no password. /auth/login refuses it — that
is correct, not a bug to work around.
Schedules. For work with no caller — a nightly digest, an hourly sync — draw
a schedule_trigger node with a 5-field cron expression in UTC, then publish.
⚠️ There is no tool that sets a timetable, and there never will be. The
schedule is DERIVED from the node the graph draws, at publish. A mutation that
also set one returned success and was then erased by the very next publish
without a word (measured 2026-09-08) — two places deciding when a workflow runs,
one of which quietly loses. list_backend_schedules shows what the platform
derived, which is how you check the cron you drew was read the way you meant.
Reserved — these are the platform's and cannot be taken over:
/auth/*— register, login, logout, me, verify, forgot-password, reset-password, andoauth/<provider>/{start,callback,link}. Built in. Do NOT draw a workflow that re-implements sign-in; you would be re-inventing password hashing and single-use tokens, and getting one of them wrong./files/*— the backend's own file storage. See §5a-iii./_bb/*— the auto-generated data API (/_bb/schema,/_bb/tables/<code>/query, …), which is also what the merchant's generated MCP server wraps. Key-only — it reads and writes the merchant's TABLES, which are nobody's personal property to scope by.
5a-iii. Files: bytes go in as bytes and come out as bytes
Upload — PUT or POST <api>/files/<key>, three ways in, all real:
| you have | send |
|---|---|
a browser <input type=file> | multipart/form-data, part named file |
| a file in a script | a raw PUT body (any content-type) |
| bytes inside a JSON call | {"content_base64": "<base64>", "mime": "image/png"} |
| a line of TEXT (a log, a CSV) | {"content": "…"} |
{"visibility": "public"} mints a /storage/... URL anyone can fetch; the default is private.
Download — GET <api>/files/<key> returns the file itself, with its stored mime.
Add ?meta=1 for the record instead: {key, size, mime, visibility, url, owner_id, content_base64}.
⚠️ NEVER put file bytes in a JSON string. GET /files/<key> used to answer
{"content": "<the bytes>"}, and Go's JSON encoder replaces every byte that is not valid UTF-8
with U+FFFD. Measured: an 84-byte PNG came back as 104 bytes and not a PNG file — every image,
PDF and zip read through it was destroyed, with a 200 and no error. content is for TEXT and
comes back empty when the file is not text; content_base64 is the file.
Who may call it. A bbk_ API key reaches the whole store's files. An app user's session
token also reaches /files/*, scoped to their own: they read, overwrite and delete files they
uploaded, and nothing else. So the ordinary app — a person uploads their avatar or a receipt and
reads it back — needs no server of the merchant's in the middle.
Three properties come with that and you should not fight them:
- a file uploaded with a session is forced private.
visibility:"public"on a person's document would mint a URL for the whole internet; - another user's file answers
no such file, never "not yours" — the second turns the endpoint into a directory of everyone's filenames; owner_idis stamped from the session, never from the body. A caller cannot name someone else.
From a workflow, the storage_put / storage_get nodes do the same thing, and a run started
by an end_user route stamps that user as the owner automatically. storage_put takes
content (text) or content_base64 (a file) — exactly one; a node with neither, or with
both, is refused at publish. storage_get returns content_base64 always and content only
when the file really is text.
5b. Variables and secrets are different things — most values are VARIABLES
Two stores, one rule for choosing:
variable — {{ var.code }} | secret — {{ secret.code }} | |
|---|---|---|
| what it is for | a SETTING | a CREDENTIAL |
| readable back | yes, with its value | never, by anything |
| in a step log | yes | scrubbed out |
| in the expression editor | shown with its value | shown as the marker |
in a respond node | allowed | refused at publish |
| a mistyped code | refused at PUBLISH, naming what IS declared | fails at run time by name |
| tools | list/set/delete_backend_variable | list_backend_secrets, set_backend_secret |
Put it in a variable — an API base URL, a callback/return URL, a sender name or address, a page size or limit, a currency, a plan code, a feature flag, a timezone, an account/tenant/project id that is not itself a credential, a model name, a webhook target, anything you would otherwise hard-code twice.
Put it in a secret — an API key, a signing key, a token, a password, a private key, a webhook signing secret, a database URL that carries a password.
⚠️ The failure mode this exists to stop. A secret is deliberately invisible: it never enters the runner, never appears in a step log, is scrubbed from the ones it might, cannot be read back and cannot be sent in a response. Put a base URL in one and the single value you need while debugging "why did it call the wrong host" is the single value nobody can see. And because a secret's code is only checked when it runs, a typo ships.
⚠️ A variable is not a hiding place either. Anyone who can open the admin reads it. If leaking it would matter, it is a secret.
Rule of thumb: if you are about to write the same literal into two nodes, or into a node and a page, it is a variable. If you would be uncomfortable seeing it in a screenshot, it is a secret.
5a-0. input.user on an end_user route already carries the email
A route with auth_mode: end_user resolves the bearer session BEFORE the graph
runs and puts the whole person in the request:
{ "id": 31, "email": "someone@example.com", "verified": false, "meta": {} }
So {{ input.user.email }} is there for the taking — for a Stripe checkout's
customer_email, for a receipt, for a lookup. Measured on prod 2026-09-08 with
a real signed-in call.
⚠️ Do NOT carry the email from the browser and re-check it server-side. That
is a longer path to a value the platform already proved: the session was
verified against bb_end_user_sessions before your first node ran, and a body
field is whatever the caller typed. There is deliberately no way for a workflow
to read the account LIST — a workflow acts for ONE person, and the one it acts
for arrives in input.user.
meta is the merchant's own JSON on the account; verified says whether the
email was confirmed. input.user is absent entirely when the route is public,
api_key or signed — those callers are not a person.
5a-i. NEVER throw away a wm error — the write is REJECTED if you do
wm.Query wm.Insert wm.Update wm.Fetch wm.Secret return (value, error);
wm.Delete returns error. The interpreter accepts every short form the Go compiler
would refuse, and each one loses the message:
_, _ = wm.Insert("usage", …) // ✗ both thrown away
rows, _ := wm.Query("usage", nil) // ✗ the error position is blank
rows := wm.Query("usage", nil) // ✗ fewer variables than returns
wm.Delete("usage", id) // ✗ the error goes nowhere at all
rows, err := wm.Query("usage", nil) // ✓
if err != nil { return map[string]any{"error": err.Error()} }
if err := wm.Delete("usage", id); err != nil { … } // ✓
⚠️ Why it matters more than it looks. Measured on prod 2026-09-08: a column was declared
enum with options device|ip|user, the workflow wrote "ipu", the platform refused the row
correctly and by name — and the node had written _, _ = wm.Insert(…). The IP counter for
signed-in users had never once run. The only symptom was a number that stayed at zero. A refusal
you discard arrives as empty data you cannot tell from real empty data.
Every one of those spellings is now refused at PUBLISH (bb-code-error-dropped), naming the call
and what to write instead. When the same gate was run over the 65 code nodes live on the platform,
23 of them were dropping an error.
5a-ii. A branch rule, and the wm signatures
Both of these existed only inside the implementation until someone worked them out by trial against a live workflow.
A branch rule is {left, op, right} — nothing else:
{"match":"all","rules":[{"left":"{{ check.count }}","op":"gt","right":0}]}
match is all (default) or any. op is one of: eq ne gt gte lt lte
contains starts in is_null not_null. A rule spelled any other way — column,
value, field, expr — is REFUSED at publish now, by name. It used to
publish and compare "" against "", so every request took the same edge in
silence. (A filter on a TABLE still says {column, op, value}, because there it
really is a column.)
wm, with signatures. Every data call returns (value, error) and you
must take both — the interpreter accepts rows := wm.Query(...), which Go
itself would refuse, and throws the error away. Publish refuses that now, because
two separate bug reports ("Insert returns an empty map", "Query returns no rows")
were the same swallowed refusal:
wm.Input() map[string]any
wm.Log(args ...any)
wm.Now() string // RFC3339 UTC
wm.Query(table string, filters []map[string]any) ([]map[string]any, error)
wm.Insert(table string, values map[string]any) (map[string]any, error)
wm.Update(table string, id any, values map[string]any) (map[string]any, error)
wm.Delete(table string, id any) error
wm.Fetch(method, url string, body map[string]any) (map[string]any, error)
wm.Secret(code string) (string, error)
id takes any numeric shape — a JSON id arrives as float64, and passing one
used to panic inside reflect.
A code node may read the tables it NAMES AS LITERALS: wm.Query("orders", …)
puts orders in the run's scope. A table name built at run time cannot be read
from the drawing, stays out of scope, and is refused.
http_request returns {status, body, …}, not the body alone — read
{{ ask.body.choices }}, not {{ ask.choices }}.
Every setting accepts {{ }} except a code node's source. The catalogue's
expr flag says so now; it used to be wrong in both directions.
5b-ii. When a node fails
Give any node an error edge — {"from":"ask","to":"sorry","port":"error"} —
and the run continues down it instead of returning the gateway's generic 500.
The failing node's id then holds {error, failed}, so the recovery path can say
something true:
{"id":"sorry","type":"respond","config":{"status":503,
"body":{"error":"{{ ask.error }}"}}}
Without one there is exactly ONE answer to every failure, and a merchant whose
provider blips cannot reply with a cached answer, a reason, or anything at all.
The error path still has to reach a respond node — catching a failure and then
answering nothing is worse than the failure. The failure stays in the run log
either way.
5c. What the platform CANNOT do
Read this before designing anything that calls a model. These three together decide the architecture, and finding them afterwards means starting over:
- No background jobs. Cron is the only detached execution, minimum one minute.
responddoes not stream. The gateway answers once, when the run finishes — a chat endpoint returns the whole message, not tokens.- The whole request must finish inside the edge's budget. The ladder is
gateway 60s › runner 50s › outbound 40s, with Varnish at 90s in front of a
custom hostname. Aim to answer in under ~25 seconds: past that you are
relying on every rung, and a run that finishes after its caller gave up is
recorded as
abandoned, notsucceeded.
5c-ii. What does NOT get a node — and why saying so is the job
Merchants ask for "a Stripe node", "a Slack node", "a Sheets node". The answer is
that they already have one: http_request with {{ secret.X }} in the header.
An integration only belongs in the platform when the merchant genuinely cannot
draw it — a route the platform must own (an OAuth callback), or a check that has
to happen before the body is parsed (a webhook signature). That is the whole
list, and it is short on purpose.
The website's own data is the one exception, and it proves the rule. A
merchant could draw it: call the platform's own API from http_request with a
key. But that key is scoped to the whole ORGANIZATION and takes its website from
a header, so drawing it means handing one workflow the keys to every sibling
site. When the only version a merchant can draw is the unsafe one, the safe
version is the platform's to own — and it is the run token, which already pins
exactly one website. That is the test, not "would a node be convenient".
Never build payments out of nodes. Charging money means idempotency, refunds, disputes, SCA and reconciliation; a workflow implementing half of that loses the merchant real money to the half that is missing. If the website is selling products, the platform's own commerce module already does checkout — use that. Backend Builder's part of a payment is receiving the webhook, verified.
6. Verify
Never report done on a publish alone — publishing proves the graph is well formed, not that it does the right thing.
-
test_backend_workflowwith a REALISTIC request —{"method":"POST", "path":"/todos","body":{"title":"mua sua"}}— and READ THE STEPS. An empty input tests nothing: a workflow that reads{{ input.body.title }}will "succeed" on{}and fail on the first real caller.set_backend_workflow_test_inputpins that request so the merchant sees the case you reproduced on the trigger node, instead of it vanishing when you stop.⚠️ If the workflow touches a table with an
ownercolumn, add"as_user_id": <id from list_backend_end_users>. A trial run is nobody's session by default, so the write fails onowner_id: required column is null— which reads like a bug in the workflow and is not one. -
Bind the route, then call it for real:
get_backend_api_domainfor the hostname (provision_backend_api_domainif there is none yet — the API deliberately does not share a hostname with the website);create_backend_api_keyfor a key, if the route isapi_key. It is shown once, intoken.
-
list_backend_runsto find the run, thenget_backend_run_stepson its id to see which node did what — includingwm.Logoutput from a code node.
A route that has never been called is a route nobody has tested, and "it published" is not a test.
7. Clearing up
Everything that creates has a matching delete: delete_backend_route (unbind a
path, keep the workflow), delete_backend_workflow, delete_backend_table,
delete_backend_row, delete_backend_secret,
delete_backend_file, delete_backend_auth_provider, revoke_backend_api_key.
Use them on anything you created to try something out. A half-built table with a live route on it is debris the merchant then has to work out how to remove.
