Canvas guide for agents
Read this first when you build or change the logic of a route, workflow or custom block. A canvas is a graph of blocks. Edit it with get_canvas and edit_canvas. Settings of the route itself are in the Route reference.
How a canvas runs
- A run starts at the entrypoint block.
- It follows the edges, one block after the other.
- Each block's output is the next block's
input. - A response block ends the run. Its input becomes the body. See Response.
The entrypoint outputs what the run started with: on a route the parsed request body (the same as the Get Request Body block), on a workflow the data it was started with. So the first block after it reads input.email, not input.body.email.
A new route starts with an entrypoint, a response block and an error handler. Call get_canvas to see them, their keys and their edges before you edit.
Blocks, handles and edges
An edge goes from one block's output handle to another block. A plain block has one handle, source. Blocks that branch or wrap other blocks have named handles.
| Block types | Handles |
|---|---|
| Most blocks | source |
if, db_exists, queue_send | success, failure (no source) |
forloop, foreachloop | source (after the loop), executor (the body, once per round) |
db_transaction, retry | success, failure, executor (the wrapped chain) |
orchestrator | source, orchestrate |
switch | case |
response, db_rollback, sticky_note | none, they end the flow |
Rules:
- A handle holds one edge. Only
caseandorchestratecan hold several. To re-point a handle,disconnectit first, in the sameedit_canvascall. - Nothing may point at the entrypoint or the error handler.
- Leave
handleout of an op and the block's default is used:source, or its only handle. A block with several handles and nosourceneedshandlenamed.
get_block_schemas with blockTypes lists the handles of each block and what each one means.
Read the block contracts
Call get_block_schemas with no input for the list of built-in block types. Call it again with blockTypes (up to 10) for each block's:
- fields and their types,
- handles,
- output, which is the next block's
input, - an example of valid data.
A custom block's inputs come from get_custom_block. Any block's data can also hold blockName, blockDescription and saveAsVariable.
Block text inputs are literal unless they start with js:. See Dynamic values and js: expressions.
Read a big canvas in parts
get_canvas returns the full data of every block. On a big canvas that is a lot to read each time. Ask for less:
compact: truegives each block askey,type,noteand a one-linesummary. Code shows ascode: 30 linesand ajs:input asjs: 4 lines. Long text is cut. Edges andrevisionare the same, so therevisionworks withedit_canvas.blocks: ["jsrunner_1", "response_1"]gives the full data of just those blocks, and the edges that touch them. An unknown key is refused and the error lists the keys on the canvas.
Read compact first, then fetch the blocks you will change.
{
"revision": 12,
"blocks": [
{ "key": "entrypoint_1", "type": "entrypoint" },
{ "key": "jsrunner_1", "type": "jsrunner", "note": "Parks the hash: KV returns true", "summary": "blockName=\"Hash it\"; value=code: 24 lines" },
{ "key": "response_1", "type": "response", "summary": "httpCode=js: 1 line" }
],
"edges": ["entrypoint_1 → jsrunner_1", "jsrunner_1 → response_1"]
}Leave notes
A workaround looks like pointless plumbing to the next reader: a variable that exists only because a block returns true, or a hash format that lives inside one script. Write down why.
- A block note. Set
blockDescriptionin the block'sdata(add_blockorupdate_block) on any block whose purpose is not obvious: a workaround, a contract, why a value is parked in a variable. One or two short sentences about why, not what the block does:"KV Set returns true, so the id is saved here for the response". - A sticky note. Add a
sticky_noteblock for a rule the whole canvas follows, for example"passwords: salt:hash, pbkdf2 sha256 100k". It never runs and has no edges. - Read before you change.
get_canvasshows each block's note asnote, and a sticky note's text as the note of the sticky note block. A block with the default placeholder text shows no note. Read the notes before you remove or rewire a block that looks pointless.
Keep the canvas honest
- Do not invent rules. One response block per happy path is fine. Do not force several end paths onto one block just to make the canvas look tidy. Never write a note that states a rule the canvas does not already follow.
- Notes must stay true. After an edit, re-read every
blockDescriptionand sticky note against the new edges, yours and earlier ones. Fix any note the edit made false in the sameedit_canvascall. - Give blocks unique names that say where they sit. Set
blockNameso no two blocks on a canvas share one:200 OK: cached usersand200 OK: users from DB, not two blocks called200 OK: users. Rename a block when you clone it. Saving warns when two blocks share a name; the default name is ignored. - Claim "no behaviour change" only after you test it. Call the rewired branch with input that takes it, with
call_routeor a test suite. Any 200 from the other branch proves nothing.
Block keys
Every block has a key: its type and a number, such as response_1, db_insert_2 or custom_send_mail_1. You name blocks by key in get_canvas, edit_canvas and test suite hooks. You never need a block id.
- The server gives a block its key when the block is created. You cannot choose or change it.
- A key is never reused on the same canvas, even after its block is deleted. If
response_2is removed, the next new response block isresponse_3. - A key tells you the block type. If you write a key that does not belong to the block it names, the op is refused.
Edit with edit_canvas
Call get_canvas first. It gives revision, blocks (key, type, data) and edges, written as text: entrypoint_1 → response_1, or if_1.success → db_insert_1 for a block with several handles (from.handle → to). Pass that revision to edit_canvas. If the canvas changed since, you get Canvas changed since you read it: read it again and redo the edit.
{
"target": { "kind": "route", "id": "<route id>" },
"revision": 4,
"ops": [
{ "op": "add_block", "ref": "find", "type": "db_exists",
"data": { "connection": "<integration id>", "tableName": "users", "conditions": [] },
"connect_from": { "from": "entrypoint_1" } },
{ "op": "add_block", "ref": "gone", "type": "response", "data": { "httpCode": "404" },
"connect_from": { "from": "find", "handle": "failure" } },
{ "op": "connect", "from": "find", "to": "response_1", "handle": "success" }
]
}| Op | Fields |
|---|---|
add_block | ref (your name for it), type, data, optional position, optional connect_from: { from, handle? } |
update_block | id, data. Only the fields that change. They are merged into the block's data. |
edit_code | id, optional field, old, new. Replaces exact text inside one field. See Change part of a script. |
remove_block | id. Its edges go with it. The entrypoint and error handler cannot be removed. |
connect, disconnect | from, to, optional handle (the handle on from) |
fromcan also carry the handle:"from": "if_1.success".- Ops run in order. A later op can use a
refadded earlier in the same call, and adisconnectfollowed by aconnecton the same handle works. - All or nothing: one bad op (unknown block type, missing id, a handle the block does not have, a handle already used) refuses the whole call and saves nothing. The error names the block and lists its valid handles.
- The answer has the new
revision;changes, one line per thing done (updated response_1 (httpCode),edited jsrunner_1.value (1 change, lines 12–14),connected if_1.success → db_insert_1,removed log_2 (+2 edges)), so you can see each op hit the block you meant. A block you add also says what it outputs, which is the next block'sinput; andrefs, which maps each of your refs to the key the block got. Use those keys in later calls. - Leave
positionout and the server places new blocks. Passauto_layout: trueto lay out the whole canvas again. ops: []checks the canvas and saves nothing.
Change part of a script
A script is one long string. To change a line, do not send the whole script again with update_block: it costs tokens every time and a slip in the escaping can break the code without an error. Use edit_code instead:
{ "op": "edit_code", "id": "jsrunner_1", "old": "subtotal * 0.2", "new": "subtotal * 0.1" }fieldis the text field to edit. Leave it out for the block's main code field:valueon a JS Runner,json a Transformer, KV raw and DB native,transformScripton a response block. Name the field to edit ajs:text input, for example"field": "httpCode".oldmust be found exactly once, spaces and line breaks included. Copy it fromget_canvas. If it is found no times or several times, the call is refused and nothing is saved. The error says how many matches there are and on which lines, or shows the closest lines. Add more of the surrounding text tooldto make it unique.- The answer says where the change landed:
edited jsrunner_1.value (1 change, lines 12–14). - A custom block's code is the JS Runner on its own canvas, so
edit_codeworks there too:target: { "kind": "custom_block", "id": "<custom block id>" }.
Validation
Validation runs by default and returns issues: { severity, message, block }, where block is the key of the block it is about. Pass validate: false only to skip them.
- An
erroron a block you wrote in this call refuses the save. Fix it and call again. - A
warningstill saves. Read it: it catches things like atemplate or a missingjs:. - Reachability warnings name a block that is not connected to the flow, a route with no path from
entrypointto a response, or an openifbranch (if_1.failure is not connected). Fix them before testing: an unconnected route answers the default response (NO RESULT). Retry and transactionfailure, loop bodies and workflows or custom blocks may end open, so they stay quiet. - Issues on blocks you did not touch are reported but never block the save.
The response block
The body of a response is the output of the block before it. The status is its httpCode: a fixed code like "404", or js: code that returns one when it depends on the flow. To reshape the body in place, set transformEnabled: true and transformScript (plain code, it gets the body as input and returns what to send). Recipes: Composed flows.
A workflow has no caller, so its end block only finishes the run and its httpCode is not read.
Errors
If a block throws, the run jumps to the error handler block. Wire it with an edge from the handler's source handle to the first block of your error flow:
{ "op": "connect", "from": "error_handler_1", "to": "<first error block key>" }- The first block of that chain gets the error as text for its
input, likeError: <message>. - End the chain with a response block, such as
httpCode"500", to answer with JSON. - With nothing on its
sourcehandle, the error fails the route. - The handler's
nextfield is not what the runtime follows. Use the edge. retryanddb_transactioncan catch a failure themselves on theirfailurehandle. See their contracts inget_block_schemas.call_routereturns the real cause aserroreven when callers only see a generic message. See Debug and fix.
Save output to variable
Any block, built in or custom, can keep its output for later blocks. Set saveAsVariable in the block's data:
{ "saveAsVariable": { "enabled": true, "name": "user" } }Later blocks read it as outputs.user:
- in a text input:
js: return outputs.user.email - in a code field (JS Runner, Transformer, a response
transformScript):outputs.user.email
It is stored for that request only. The next request starts empty.
| Rule | Detail |
|---|---|
| Name | A valid identifier: starts with a letter, _ or $, then letters, digits, _ or $. An enabled setting with a bad name is refused when you save. |
| Saved when | The block finishes and moves on through its source handle. For retry and db_transaction that is success. db_exists saves the matching row on success and null on failure. queue_send saves on either branch. |
| Read before it ran | outputs is undefined until some block has saved, so outputs.user throws on a path where the block did not run. |
| Same name twice | The later block overwrites the earlier one. |
Use it when a later block needs data that input no longer holds:
- the body is needed after a database block replaced
inputwith a row, - one loaded row feeds an update and the response,
- a value is used in several branches.
If the next block needs only the previous output, just read input. Do not save everything.
The build loop
get_block_schemaswithblockTypesfor the blocks you will use.get_canvasfor the keys andrevision.edit_canvaswith small ops. Fix everyerrorinissues.get_system_logsfor compile errors.call_routealso gives the real error and a trace of the blocks that ran.call_routewith real input; it runs in development. When the route is ready, commit it withcommit_version, switch production on withsave_routeandactive: true, and ask the user to approvepromote_version: production serves only a promoted version.- Stop when the route works and its tests pass. Do not rewrite a working canvas.
Common problems
| What you see | What to do |
|---|---|
Canvas changed since you read it | get_canvas again, redo the edit with the new revision. |
... has no "x" handle. Use ... | The error lists the handles the block has. Pick one. |
... handle already goes to ... | Add a disconnect before the connect, in the same call. |
no block "x" | Use a key from get_canvas (the error lists similar ones), or a ref added earlier in the same call. |
x is a ... block, not a ... block | The key does not belong to that block. Read the canvas again and use the key it shows. |
a canvas already has its one error_handler block | A canvas has exactly one entrypoint and one error handler. Edit the existing one. |
| A block shows its text in the response | The value is a literal. Start it with js:. |
Related pages
- Dynamic values and
js:expressions: names code can read. - Composed flows: check then update, retry, error handler, dynamic status.
- Validate a request: route schemas before any block.
- Debug and fix a route or workflow.
