Blueprint Language Syntax
A tour of Blueprint Language: the declarations a file is made of, the statements and expressions inside a function, block and storage calls, comments, the lock, positions, and the checks a compile runs. Every example is .blueprint source.
A tour: an API endpoint in sixteen lines
This endpoint reads customer_guid and amount from the request body, rejects an amount that is not positive, looks the customer up, and creates an order:
blueprint "Orders API"
route POST "orders" -> create-order
fn create-order(connection, params, query, body) {
let {customer_guid, amount} = body
guard amount > 0 else {
reply 400 {error: "INVALID_AMOUNT"}
}
customer = get@customers(customer_guid)
guard customer.accessible else {
reply 404 {error: "CUSTOMER_NOT_FOUND"}
}
order = create@orders(mutations{customer_guid: customer_guid, amount: amount})
reply 200 order.object
}- Statements are flow blocks, in execution order. Each runs after the one above it.
guard c else { ... }is abranch: the block runs whencis false, the statements after the guard when it is true. - Expressions are data blocks, written inline where they are used:
"INVALID_AMOUNT"is avalue_default,400anumber_default,amount > 0acondition_gt,{error: ...}anobject_new_fields. - Names are what a block's outputs are called.
customer.accessibleis theaccessibleoutput of thefunction_get_documentbound tocustomer;let {customer_guid, amount} = bodyis one field getter whose outputs arecustomer_guidandamount. - Everything else is a block call,
type(pin: expr) with {settings}, the plain form every block has. The shorthand above is sugar for common shapes of it.
Once stored, an export prints this source back with one difference: it names a create after what it creates, so order becomes new_order.
| Source | Blocks on the canvas |
|---|---|
route POST "orders" -> create-order |
on_startup_register_uri_post, plus a function_custom_execute_from_trigger that runs create-order |
fn create-order(connection, params, query, body) { ... } |
a trigger_custom_function named create-order; the body runs from its flow output |
let {customer_guid, amount} = body |
one object_field_getter_multiple |
guard c else { ... }, if c { ... } else { ... } |
a branch |
reply 400 {error: "INVALID_AMOUNT"} |
httpconnection_set_json with its code, body and connection blocks: five blocks |
customer = get@customers(customer_guid) |
a function_get_document on the customers storage |
create@orders(mutations{customer_guid: ..., amount: ...}) |
function_create_document_from_mutations fed by a mutations_set_bp_field_multiple |
x = call f(a: y) |
a function_custom_execute that runs f (see Functions) |
a == b, not x, a and b |
condition_equal, condition_equal_false, condition_and |
goto label |
another flow wire into the block written under label: |
A file is a header and declarations
A file starts with blueprint "Title". An export of one flow adds starter and the guid of that flow's first block to the line, as in blueprint "Orders API" starter "62e3a3b8…": the flow the file covers. Keep the header as it was exported, because staging the file only ever changes that flow. The rest of the file is declarations:
blueprint "Orders API"
let currentdate = now()
group "POST orders"
route POST "orders" -> create-order
fn create-order(connection, params, query, body) {
…
}
pub fn add-vat(arg amount: number) -> (total: number) {
function_return(total: number_times(amount, 1.21))
}
flow page = state_page with {event: "orders/:guid", scopes: ["*public"]} {
let {guid} = page.params
render_page = state_render_page(page.connection)
}| Declaration | What it is |
|---|---|
route METHOD "path" -> name |
An API route that runs the function name with connection, params, query and body. The methods are GET, POST, PUT, PATCH, DELETE, and FILE for uploads. The path is bare: every route is served under /api/v1/. Settings such as scopes go after with. |
fn name(params) -> (returns) { ... } |
A custom function. A route handler takes connection, params, query, body; pub fn marks a function public. See Functions and Custom Pins. |
statefn name(params) { ... } |
A state function, the state_trigger_custom_function block, written the same way as fn. |
flow name = starter(...) with {settings} { ... } |
A flow that begins at any other starter block, such as a page (state_page). name is the starter, so page.params and page.connection are its outputs. |
let name = expression |
At the top level: a data block that several flows or functions read, such as let currentdate = now(). |
group "Label" |
Draws the declaration that follows inside a labelled frame on the canvas. |
A route's settings go in a with block, here the scopes that may call it (see Remote access control):
route POST "orders/:guid/notes" -> add-order-note with {
scopes: ["*loggedin"],
}Statements
Calls, names and field reads
log("start")is a block that runs and needs no name.customer = get@customers(customer_guid)names the block, so later lines can read its outputs ascustomer.accessibleorcustomer.object.let {customer_guid, amount} = bodyreads several fields of an object with one block.asrenames a field and a dotted name reads a nested one:let {_meta.guid as order_guid, status} = order.object.let name = expressioninside a function names a data expression for the lines below it:let sum = number_plus(a, b).
if, guard and else: control never falls out of a block
if and guard are both a branch. What makes them different from most languages is that control never falls out of a block into the statement after the one that owns it:
if customer.accessible {
log("found")
} else {
log("not found")
}guard amount > 0 else {
reply 400 {error: "INVALID_AMOUNT"}
}
log("amount is positive")if customer.accessible {
log("found")
}
log("not found")Prefer guard for early exits: the failure case sits in the block, and the rest of the function reads straight down. A condition must be a boolean, such as a comparison, and, or, not, a condition block or an accessible or success output. A branch takes its true side only on a boolean true, so a number or a string is never true, and the compile warns about it.
reply answers the request
reply CODE BODY answers the HTTP request with a status code and a JSON body; reply 403 answers with a code only. Every path through a route handler should end in a reply: a path that ends without one answers 200 {"success": true}, so a failed guard would look like success, and the compile warns about it. A reply does not end the function: statements after it still run, but only the first reply is sent.
goto and labels: where paths meet
A block that more than one flow wire leads into, such as a funnel where two paths join, is written once under a label, and every path into it ends in a goto. goto done enters the block's default flow input; goto done.flow_0 names another input.
fn ship-order(connection, params, query, body) {
order = get@orders(params.guid)
guard order.accessible else {
reply 404 {error: "ORDER_NOT_FOUND"}
}
let {amount} = order.object
if amount > 100 {
log("free shipping")
goto done
}
log("standard shipping")
goto done.flow_0
done:
funnel()
reply 200 order.object
}on: more than one chain from one output
When one flow output is wired to more than one chain of blocks, the extra chains are written as on blocks. Here the flow output of the first log leads to two chains: the on block is the first, the statement after it the second. flow is a reserved word, so it is written in backticks. The flow runs start, A1, A2, then B1; Block Execution explains the order in which a block's outputs run.
fn log-order() {
log("start")
on `flow` {
log("A1")
log("A2")
}
log("B1")
}Expressions
- Literals are JSON:
"text",404,1.50,true,false,null. A number keeps its text, so1.50stays1.50. - Objects:
{error: "INVALID_AMOUNT", amount: amount}builds an object from its fields. - Outputs and fields:
x.pinreads an output of the block namedx, and further dots read fields of an object, as inbody.amountororder.object.status. - Operators: the comparisons
== != < <= > >=andand,orandnotare condition blocks, such ascondition_gtfor>andcondition_ltefor<=. - Field lists:
pick[username, avatar_url](object: user.object)keeps only those fields of an object;mutations{status: "paid"}is a set of field changes for a storage call;term{customer_guid: customer_guid}is a term query.
Short aliases stand for the most common block types and never shadow a real type: get, search, create, update, remove (the storage calls), mutations, now (date_currentdate), request (httpconnection_current_request), pick, term, between, contains, concat, length, log (function_console_log), translate, field, guid and branch_on.
Block calls
Every block has a plain form: its type, its inputs in parentheses and its settings after with. Any block in the block reference can be written this way:
result = block_type(input: expression, option = "a literal", tags = ["a", "b"]) with {prop: "value"}pin: expressionwires an expression into the inputpin. Inputs without a name fill the block's inputs in order:number_times(amount, 1.21).pin = literalstores a value on the input itself, the value you would type into an unconnected pin on the canvas, instead of wiring a data block into it. The literal is a string, a number,true,false,nullor a list such as["a", "b"].with {name: value}sets the block's settings, the ones its settings panel shows.result = ...names the block, soresult.pinreads its outputs.- A pin or setting whose name is not a plain identifier, or is a reserved word, is written in backticks:
`true`,`user guid`.
Storage calls
The storage blocks have a short form: the operation, @, and the storage's name.
customer = get@customers(customer_guid)
new_order = create@orders(mutations{customer_guid: customer_guid, amount: amount})
updated = update@orders(guid: order_guid, mutations: mutations{status: "paid"})
removed = remove@orders(guid: order_guid)
found = search@orders(query: query_and(query_bool_filter(term{customer_guid: customer_guid})), limit: 20)| Call | Block | Outputs you read most |
|---|---|---|
get@s(guid) |
function_get_document |
accessible, object, guid |
create@s(mutations{...}) |
function_create_document_from_mutations |
success, object, guid, error |
update@s(guid: g, mutations: mutations{...}) |
function_update_document_mutations |
success, object, has_changes, error |
remove@s(guid: g) |
function_remove_document |
success, error |
search@s(query: q, limit: l, offset: o) |
function_search |
array, hits, success, error |
Check accessible before you use a document you read, and success after a write, with a guard that replies on failure.
A longer endpoint
The same rules carry a larger flow. This endpoint adds a note to an order: it sits in labelled frames, checks the caller with a function from another blueprint, reads a nested field under a new name, validates the body, and writes a document with a selection of the author's fields:
blueprint "Orders API"
group "POST orders/:guid/notes"
route POST "orders/:guid/notes" -> add-order-note with {
scopes: ["*loggedin"],
}
group "add-order-note"
fn add-order-note(connection, params, query, body) {
is_staff_request = call is-staff-request(connection: connection)
guard is_staff_request.valid else {
reply 403
}
order = get@orders(params.guid)
guard order.accessible else {
reply 404 {error: "ORDER_NOT_FOUND"}
}
let {_meta.guid as order_guid, customer_guid} = order.object
let {author_guid, content} = body
guard between(length(content), 1, 1000) else {
reply 400 {error: "INVALID_CONTENT"}
}
author = get@users(author_guid)
guard author.accessible else {
reply 404 {error: "USER_NOT_FOUND"}
}
new_order_note = create@order_notes(
mutations{
order_guid: order_guid,
customer_guid: customer_guid,
author_guid: author.guid,
content: content,
author: pick[username, avatar_url](object: author.object),
},
)
guard new_order_note.success else {
reply 500 {error: new_order_note.error}
}
reply 200 new_order_note.object
}Comments
// starts a comment that runs to the end of the line. A comment attaches to the statement or declaration that follows it, and stays with that block through later exports as long as you keep the lock that goes with the source: the stored blueprint itself has no place for comments.
fn create-order(connection, params, query, body) {
// Amounts are in cents.
let {customer_guid, amount} = body
guard amount > 0 else {
// A zero amount is a client error.
reply 400 {error: "INVALID_AMOUNT"}
}
…A // @xy pragma looks like a comment but is not one: it is a position.
The lock keeps what the source leaves out
Source has no guids, and a statement does not say where its block sits on the canvas. An export therefore comes with a lock: a companion to the source (a .blueprint.lock file next to a .blueprint file) that records which stored block each statement and expression is, where each block sits, the exact stored form of anything the source abbreviates, and your comments.
- It keeps identity. When source is compiled, the lock is how an unchanged statement stays the same block and an edited one becomes an update of that block, not a removal and a new block. Changing
reply 404 {error: "USER_NOT_FOUND"}toreply 410 {error: "USER_GONE"}is exactly two updates. - Blocks are known by their names. Keep the names an export uses: renaming a statement can turn an update into a removal and a new block.
- New blocks need nothing. A new statement gets a new block and, if it has no position, a place on the canvas.
- It is optional over the source API. Send back the lock the export gave you, or leave it out and the blueprint's current state is used instead. You never edit a lock by hand.
Positions are // @xy pragmas
An export with positions writes each statement's and declaration's canvas position as a // @xy X,Y pragma at the end of its first line: X is the left edge and Y the top edge, in canvas pixels. Ask for it with positions=1 on the export, or the Positions toggle in the Studio code view. An unchanged pragma keeps the block exactly where it was.
blueprint "Orders API" starter "62e3a3b8…"
route POST "orders" -> create-order // @xy 50016,50016
fn create-order(connection, params, query, body) { // @xy 50592,50400
let {customer_guid, amount} = body // @xy 51168,50432
guard amount > 0 else { // @xy 52096,50432
reply 400 {error: "INVALID_AMOUNT"} // @xy 52672,50688
}
customer = get@customers(customer_guid) // @xy 52992,50400
guard customer.accessible else { // @xy 53568,50432
reply 404 {error: "CUSTOMER_NOT_FOUND"} // @xy 54144,50688
}
new_order = create@orders(mutations{customer_guid: customer_guid, amount: amount}) // @xy 54752,50400
reply 200 new_order.object // @xy 55840,50400
}- Editing a pragma moves the block exactly there, and the blocks drawn with it (the status code, the object, the string and the connection beside a reply) move by the same offset unless they carry a pragma of their own. Changing the first reply's pragma from
// @xy 52672,50688to// @xy 52672,50800moves those five blocks 112 pixels down; the dry run lists them as five updates that change only the position, and nothing else changes. - Blocks without a position are laid out automatically. A new statement without a pragma, or a block whose stored position is missing or off the canvas, is placed around everything that is positioned. Place unpositioned in the Studio code view places just those blocks, without changing anything else.
- Expressions carry no pragma. Inline literals, objects and conditions keep their places and move with their statement.
- A pragma is not a comment. It belongs to the statement that starts on its line, and a pragma anywhere else is an error with its position, never a comment that silently positions nothing.
4:1: an @xy pragma goes at the end of the first line of the statement or declaration it positions, not on a line of its own
9:13: malformed @xy pragma "// @xy 1;2": want `// @xy X,Y` with two numbers| Pragma | Error |
|---|---|
| On a line of its own | an @xy pragma goes at the end of the first line of the statement or declaration it positions, not on a line of its own |
| On a line no statement starts on, such as inside an argument list or an object | this @xy pragma positions nothing |
Not two plain numbers (// @xy 1;2, or two pragmas on one line) |
malformed @xy pragma |
| Outside the canvas | @xy ... is off the canvas |
Checks point at a line and column
Compiling source checks it and reports every finding at the line and column that caused it, as line:col: message. The Studio code view marks each one on its line, and the source API returns errors as {"pos": {"line", "col"}, "msg"}. Errors stop the compile; warnings do not. This version of the Orders API has a misspelled block type on line 7 and a misspelled output on line 15:
blueprint "Orders API"
route POST "orders" -> create-order
fn create-order(connection, params, query, body) {
let {customer_guid, amount} = body
let email = strng_lowercase(customer_guid)
guard amount > 0 else {
reply 400 {error: "INVALID_AMOUNT"}
}
guard amount <= 10000 else {
reply 400 {error: "AMOUNT_TOO_HIGH"}
}
customer = get@customers(customer_guid)
guard customer.acessible else {
reply 404 {error: "CUSTOMER_NOT_FOUND"}
}
new_order = create@orders(mutations{customer_guid: customer_guid, amount: amount})
reply 200 new_order.object
}7:15: unknown block type "strng_lowercase"; did you mean value_to_lowercase?
15:17: customer (function_get_document) has no out-pin "acessible"; did you mean accessible?Warnings catch mistakes that still compile. In this variant the last check became an if without an else, so one path ends without a reply, and a guard reads a number instead of a condition. A field name the field registry does not have is a warning too: the third warning is for a version whose line 6 reads let {customer_guid, amout} = body.
customer = get@customers(customer_guid)
guard length(customer_guid) else {
reply 400 {error: "MISSING_CUSTOMER"}
}
if customer.accessible {
new_order = create@orders(mutations{customer_guid: customer_guid, amount: amount})
reply 200 new_order.object
}
}17:3: route handler create-order can end here without a reply (when the condition is false): no httpconnection_set_* runs on this path, so the core answers the default 200 {"success": true}
14:9: the condition is typed number (value_length.number), not boolean; a branch takes its true side only on a boolean true
6:23: field "amout" is not in the field registry; did you mean amount?| Finding | Level |
|---|---|
| A block type that does not exist | error |
An output x.pin reads that the block does not have |
error |
| An input the block does not have, or a parameter the called function does not declare | error |
A misplaced or malformed // @xy pragma |
error |
| A route handler path that ends without a reply | warning |
| A branch condition that is not a boolean | warning |
| A field the field registry does not have | warning |
Suggestions are the closest real names. Where a name cannot be checked, such as a pin a block grows from its settings or a call of a function in another blueprint, nothing is reported rather than a false alarm, and the block types and field names a blueprint already uses are never reported as your mistake.
Lexical rules in brief
//starts a comment to the end of the line.- A newline ends a statement, except inside brackets and object literals and after
,,and,or, a comparison or=. - Identifiers are
[A-Za-z_][A-Za-z0-9_]*. Any other name, such as a pin calleduser guidor a reserved word used as a pin, is written in backticks:`true`. - Function names after
fn,statefn,->andcallmay contain-and.:create-order,billing.add-vat. - Strings and numbers are JSON; a number keeps its text.
- Reserved words:
blueprint route fn statefn pub flow let if else guard goto on with as group reply call arg via true false null and or not ref _ macro use props.
Frequently asked
What does guard mean in Blueprint Language?
guard condition else { ... } is a branch block: the block runs when the condition is false, and the statements after the guard run when it is true. Control never falls out of a block into the statement after it, so guard is how you write an early exit, such as a reply 400 on invalid input.
What is the .blueprint.lock file?
The lock is the companion an export gives you with the source. It records which stored block each statement is, where each block sits on the canvas, the exact stored form of anything the source abbreviates, and your comments, so an edited statement updates its block instead of replacing it. Over the source API the lock is optional: leave it out and the blueprint's current state is used.
How do I move a block in Blueprint Language?
Export with positions, and each statement carries a // @xy X,Y pragma at the end of its first line, X being the left edge and Y the top edge. Change the numbers to move the block; the blocks drawn inline with it move by the same offset. A block without a position is placed automatically.