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:

orders.blueprint
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 a branch: the block runs when c is false, the statements after the guard when it is true.
  • Expressions are data blocks, written inline where they are used: "INVALID_AMOUNT" is a value_default, 400 a number_default, amount > 0 a condition_gt, {error: ...} an object_new_fields.
  • Names are what a block's outputs are called. customer.accessible is the accessible output of the function_get_document bound to customer; let {customer_guid, amount} = body is one field getter whose outputs are customer_guid and amount.
  • 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:

The outline of a file
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):

A route with scopes
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 as customer.accessible or customer.object.
  • let {customer_guid, amount} = body reads several fields of an object with one block. as renames a field and a dotted name reads a nested one: let {_meta.guid as order_guid, status} = order.object.
  • let name = expression inside 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 with else: one of the two blocks runs
if customer.accessible {
  log("found")
} else {
  log("not found")
}
guard: the block runs on false, the lines after it on true
guard amount > 0 else {
  reply 400 {error: "INVALID_AMOUNT"}
}
log("amount is positive")
if without else: the lines after it run only on false
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.

Two paths joining
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.

One output, two chains
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, so 1.50 stays 1.50.
  • Objects: {error: "INVALID_AMOUNT", amount: amount} builds an object from its fields.
  • Outputs and fields: x.pin reads an output of the block named x, and further dots read fields of an object, as in body.amount or order.object.status.
  • Operators: the comparisons == != < <= > >= and and, or and not are condition blocks, such as condition_gt for > and condition_lte for <=.
  • 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:

The general form
result = block_type(input: expression, option = "a literal", tags = ["a", "b"]) with {prop: "value"}
  • pin: expression wires an expression into the input pin. Inputs without a name fill the block's inputs in order: number_times(amount, 1.21).
  • pin = literal stores 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, null or a list such as ["a", "b"].
  • with {name: value} sets the block's settings, the ones its settings panel shows.
  • result = ... names the block, so result.pin reads 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.

Storage calls
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:

orders.blueprint (a second endpoint)
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.

A comment
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"} to reply 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.

The Orders API, exported for its flow with positions
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,50688 to // @xy 52672,50800 moves 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.
Two misplaced pragmas
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:

orders.blueprint with two mistakes
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
}
Errors
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.

Lines 13 to 21 of the variant
  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
  }
}
Warnings
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 called user guid or a reserved word used as a pin, is written in backticks: `true`.
  • Function names after fn, statefn, -> and call may 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.
Functions and Custom Pins Parameters, returns and calls, also of a function in another blueprint. Editing Blueprints with an AI Agent The same compile over HTTP: export, dry run and stage, step by step with the request and response of each step. The Studio Code View The same source in a code editor next to the canvas.

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.