Skip to content
Get Started

Blueprint Reference

Blueprints are TOML files that declaratively define your application. This page covers the core data, page, UI, authentication, and integration sections. Domain behavior has a dedicated Domain Commands reference, while Workflows and Skills cover their respective sections in depth.

Every blueprint starts with a version and project metadata:

version = "0.6.0"
[project]
name = "My App"
version = "1.0.0"
description = "An internal tool"
[project.runtime]
min_version = "0.6.0"

Large applications can split a Blueprint into explicitly imported TOML fragments. Put imports at the top of the root file, before any table declaration:

imports = [
"./foundation/design-system.toml",
"./domain/requests/module.toml",
]
version = "0.6.0"
[project]
name = "My App"
version = "1.0.0"
[project.runtime]
min_version = "0.6.0"

Fragments can import other fragments. Each path is resolved relative to the file containing it, so a directory can own its composition without registering names globally:

domain/requests/module.toml
imports = ["./entities.toml", "./commands.toml", "./workflows.toml"]

Only the root file defines version and project. Imported files can use any .toml filename and contain any other Blueprint sections. Runtime names remain application-global: two imported files cannot both define the same entity, command, workflow, page, service, skill, or plugin.

Imports must name explicit relative .toml files. Globs, absolute paths, files outside the root Blueprint directory, cycles, and ambiguous singleton configuration are rejected. Importing the same canonical file through multiple branches incorporates it once, which allows a shared design-system.toml to be reused safely.

Entities define your data model. The runtime creates database tables automatically.

[entity.Post]
fields = [
{ name = "id", type = "ULID", primary_key = true },
{ name = "title", type = "Text", required = true },
{ name = "body", type = "LongText", required = true },
{ name = "status", type = "Enum", values = ["draft", "published"], default = "draft" },
{ name = "authorId", type = "Ref", ref = "User.id", index = true },
{ name = "createdAt", type = "DateTime", default = "now" }
]
TypeDescription
ULIDPrimary key, auto-generated unique identifier
UUIDUUID identifier
TextShort text (VARCHAR)
LongTextLong text (TEXT)
EmailEmail address with validation
IntegerWhole numbers
FloatDecimal numbers
Booleantrue/false
DateTimeDate and time (shown with its time zone)
DateDate only
JSONJSON data
EnumEnumerated values (use values = [...])
RefForeign key reference (use ref = "Entity.field")
OptionTypeDescription
primary_keybooleanMark as primary key
uniquebooleanEnforce uniqueness
indexbooleanCreate database index
requiredbooleanCannot be null
nullablebooleanExplicitly allow null
defaultanyDefault value ("now" for DateTime)
valuesstring[]Allowed values for Enum type
refstringForeign key target for Ref type
write"command-only"Reject generic writes to a lifecycle field
commandsstring[]Commands allowed to write a command-only field

Define compound or named indexes with [[entity.Post.indexes]]:

[[entity.Post.indexes]]
fields = ["authorId", "status"]
name = "post_author_status"
[[entity.Post.indexes]]
fields = ["slug"]
unique = true

Protect several fields with one command allowlist:

[entity.Post.protection]
fields = ["status", "publishedAt"]
commands = ["PublishPost", "UnpublishPost"]

See Domain Commands for command-only mutation behavior.

[entity.User.relations]
posts = { type = "hasMany", entity = "Post", foreign_key = "authorId" }
[entity.Post.relations]
author = { type = "belongsTo", entity = "User", foreign_key = "authorId" }

Relation types: hasMany, hasOne, belongsTo, manyToMany (requires through).

Define who can read, create, update, or delete records:

# Simple boolean
[entity.Post.access]
read = true
create = true
update = { authorId = "$currentUser.id" }
delete = { "$currentUser.role" = "admin" }

Use $currentUser to reference the logged-in user’s fields. Combine conditions with or/and:

[entity.Post.access]
read = { or = [{ status = "published" }, { authorId = "$currentUser.id" }] }

Field-level access:

[entity.User]
fields = [
{ name = "email", type = "Email", access = { read = true, write = { "$currentUser.role" = "admin" } } }
]

Pages bind URL paths to layouts and queries.

Set live = true on a page to keep its server-rendered main content synchronized with committed mutations on Node and Workers. See Live Mode for browser behavior and limitations.

Pages can also mount interactive widgets directly under [page."/path".widget]. For the current widget types and examples, see Widgets, Board Widget, and Lookup Widget.

LayoutDescription
listTable view of query results
detailSingle record display
formCreate/update/delete forms
dashboardWidgets and summary cards
authAuthentication pages (sign-in)
[page."/posts"]
title = "All Posts"
auth = "none"
layout = "list"
[page."/posts".query.posts]
entity = "Post"
where = { status = "published" }
orderBy = { createdAt = "desc" }
limit = 20
include = ["author"]
[page."/posts/:id"]
title = "Post Detail"
auth = "none"
layout = "detail"
[page."/posts/:id".query.post]
entity = "Post"
where = { id = "$params.id" }
include = ["author"]

Detail pages and related lists show each field according to its type. All values are HTML-escaped. Line breaks in LongText and JSON are kept on detail pages and in activity feeds; table cells show values on one line.

TypeDisplay
LongTextLine breaks are kept (detail pages) and long lines wrap
JSONPretty-printed (monospaced on detail pages). Keys that look like technical identifiers (for example id) are left out
DateTimeDate, time and time zone, such as 10/8/2026, 2:13:42 AM UTC
DateDate only
BooleanA check or cross mark
empty-

The time zone is the server’s: Cloudflare Workers always render in UTC, and the Node runtime uses the process’s zone. The zone is named so a reader in another zone is not misled.

Queries other than the page’s main record appear as related sections below it. Zebric chooses how to present each one from the entity’s name, which is worth knowing when a section does not look the way you expect:

Entity name containsPresented as
taskA checklist
milestone or timelineA timeline
activity or eventAn activity feed
anything elseA table

An activity feed shows, for each record, the first of title, summary, action, kind, type or name as its heading; the first of detail, description or message beneath it (line breaks kept); and the first of timestamp or createdAt as its time. A record with none of these fields shows the word “Event”.

[page."/"]
title = "Dashboard"
auth = "required"
layout = "dashboard"
[page."/".query.recentPosts]
entity = "Post"
orderBy = { createdAt = "desc" }
limit = 5
[page."/".query.totalUsers]
entity = "User"
OptionDescription
entityEntity to query
whereFilter conditions (use $params.id for URL params)
orderBySort order ({ field = "asc" } or "desc")
limitMaximum number of results
offsetSkip N results
includeRelated entities to include

Widgets are page-level interactive views that live alongside standard page configuration:

[page."/customers"]
title = "Customer Search"
[page."/customers".widget]
kind = "lookup"
entity = "Customer"
search = ["lastName", "firstName", "email"]

Use widgets when a page should behave more like a tool than a traditional CRUD screen. Zebric currently supports:

  • board for Kanban-style issue and workflow pages
  • lookup for standalone search-and-select pages

For full widget configuration and examples, see Widgets, Board Widget, and Lookup Widget.

Forms handle record creation and updates.

[page."/posts/new"]
title = "New Post"
auth = "required"
layout = "form"
[page."/posts/new".form]
entity = "Post"
method = "create"
[[page."/posts/new".form.fields]]
name = "title"
type = "text"
label = "Title"
required = true
[[page."/posts/new".form.fields]]
name = "category"
type = "select"
label = "Category"
options = [
{ value = "general", label = "General" },
{ value = "finance", label = "Finance" }
]
[[page."/posts/new".form.fields]]
name = "body"
type = "textarea"
label = "Content"
rows = 10
required = true
[page."/posts/new".form.onSuccess]
redirect = "/posts/{id}"
message = "Post created!"

text, textarea, email, password, number, select, checkbox, radio, file, date, datetime, hidden

Use hidden for technical values that must be submitted with a form but should not be presented to the user, such as a parent record identifier.

OptionDescription
labelDisplay label
placeholderPlaceholder text
requiredField is required
defaultDefault value
optionsChoices for select/radio (string array or { value, label } objects)
rowsNumber of rows for textarea
patternRegex validation pattern
error_messageCustom error message
min / maxNumber range constraints
acceptAccepted file types for file input

Detail pages can include an action bar with workflow triggers and a status badge. They render domain commands for the primary entity when the command’s policy and availableWhen checks pass, even when no explicit action-bar block is present:

[page."/issues/:id"]
title = "Issue Detail"
layout = "detail"
[page."/issues/:id".actionBar]
statusField = "status"
[[page."/issues/:id".actionBar.actions]]
label = "Set Status"
workflow = "SetIssueStatus"
style = "primary"
[[page."/issues/:id".actionBar.actions]]
label = "Request Approval"
workflow = "RequestApprovalIfNeeded"
style = "secondary"
visibleWhen = { status = ["draft", "changes_requested"] }

Command actions do not need to be repeated in actionBar.actions; define their label, input, confirmation prompt, and style on the command. See Generated UI.

Workflow and link actions support visibleWhen and enabledWhen record conditions, along with confirm, payload, redirect, successMessage, and errorMessage. Put authoritative state checks in the workflow or command as well; UI visibility is not an authorization boundary.

Customize layout rendering with named slots:

LayoutAvailable Slots
listlist.header, list.body, list.empty
detaildetail.main, detail.related
formform.form
dashboarddashboard.widgets
[ui]
render_mode = "server"
theme = "default"
progressive_enhancement = "none" # "none", "alpine", or "htmx"
view_transitions = true
[ui.tailwind]
primary_color = "#3B82F6"

Zazzle selects visual styles through [design_system]. The default is modern; classic, friendly, and minimal are also built in, each with a dark palette.

[design_system]
name = "acme"
extends = "friendly"
css = ["/styles/acme.css"]
[design_system.tokens]
color-primary = "#5636cc"
color-error = "#a12622"
color-info = "#24548a"
font-family-heading = "Georgia, serif"

Token names become --zb-* CSS custom properties. Generated details, related feeds, action bars, status badges, feedback, and form controls use semantic classes so their colors follow the selected system. Success uses color-success, warning uses color-warning, and errors and destructive actions use color-error. Informational feedback uses color-info. Status badges and feedback use surface-card as their background.

The navigation color-mode control cycles auto/light/dark and saves the visitor’s choice locally. Custom token overrides apply to the light palette; inherited dark colors come from the base system. Use CSS scoped to html[data-zebric-design-system="acme"][data-zebric-resolved-color-mode="dark"] to customize dark colors.

The renderer ships two Theme objects — defaultTheme (light) and darkTheme — exported from @zebric/runtime-core. The theme field above is accepted by the schema but not yet wired up to select between them automatically; applying a different theme currently requires passing a Theme object to the renderer programmatically rather than by name in the blueprint.

Global UX defaults can be overridden on individual pages. Fields are merged within interaction, data, and form; changing one page setting retains the other global defaults. Matching entries in ux.patterns supply pattern defaults between global and page configuration.

[ux.navigation]
model = "sidebar"
primary = ["Tasks", "Activity"]
[ux.responsive]
collapse_sidebar = true
[page."/tasks".ux.interaction]
selection = "multi"
row_click = "select"
[page."/tasks".ux.data]
filters = "top-bar"
column_config = true
pagination = "client"

Sidebar navigation occupies a left column on desktop and collapses behind a Menu button below 48rem. Set collapse_sidebar = false to keep its links visible on small screens.

Table selection supports single or multi, including keyboard row selection when row_click = "select". Multi-selection includes a select-visible-rows checkbox; selection is retained while filtering or changing client pages. The table emits a bubbling zebric:selection-change event with detail.ids. Selection alone does not run bulk mutations. row_click = "edit" navigates to the entity’s update page when one exists.

filters adds a text filter over the loaded rows, positioned in a top bar or sidebar; it does not query the database. column_config = true adds column visibility controls. Client pagination shows 25 loaded rows at a time. Controls are scoped to each table and initialize again after Live Mode replaces content.

For pagination = "server", the main query on a list page uses its limit as the page size (default 25, maximum 250). Previous/Next links use zb_page and retain other query parameters. The runtime fetches one lookahead row to decide whether Next should be available, then removes it from the returned data. Related queries keep their configured limits and offsets. Set orderBy on the main query for stable ordering across pages. Filtering in this mode searches the current page’s rows. JSON page responses include pagination metadata.

Any value other than "none" enables client-side form enhancement: forms render with a data-enhance attribute, and a small inline script intercepts submits, does an AJAX fetch with CSRF-token forwarding, shows a loading state on the submit button, and redirects or displays inline field errors based on the JSON response — no full page reload. The "alpine" and "htmx" values are accepted by the schema but don’t currently load those libraries; they behave the same as any non-"none" value.

view_transitions = true adds a <meta name="view-transition"> tag and @view-transition CSS so supporting browsers animate page navigations.

Every page responds with HTML by default, or JSON if the request’s Accept header includes application/json without also including text/html:

Terminal window
curl http://localhost:3000/posts # HTML
curl -H "Accept: application/json" http://localhost:3000/posts # JSON

Rendered pages include WCAG-oriented features: a skip-to-content link, aria-label/aria-current on navigation, table <caption> and scope="col" headers, aria-describedby/aria-invalid/role="alert" on form validation errors, and an sr-only utility class for screen-reader-only content.

[auth]
providers = ["email"]
trustedOrigins = ["http://localhost:3000"]
[[auth.apiKeys]]
name = "my-agent"
keyEnv = "MY_API_KEY"
agentId = "content-agent"
credentialId = "content-agent-production"
displayName = "Content Agent"
roles = ["editor"]
scopes = ["entity.post.list", "posts.publish"]

API keys can authenticate entity, skill, workflow, command, job, and audit routes. See Security & Authentication for identity and scope behavior.

Commands define guarded business transitions shared by UI, HTTP, agents, and workflows:

[command.PublishPost]
entity = "Post"
label = "Publish"
policy = "actor.roles contains 'editor'"
availableWhen = "record.status == 'draft'"
mutations = { status = "published", publishedAt = "now" }
scopes = ["posts.publish"]

See Domain Commands for typed input, protected fields, handlers, generated UI, workflow steps, and HTTP invocation.

Services declare typed operations supplied by a loaded plugin:

[service.crm]
description = "Customer system"
plugin = "crm-plugin"
[service.crm.operations.lookupCustomer]
description = "Find a customer by email."
[service.crm.operations.lookupCustomer.input.email]
type = "Email"
required = true
[service.crm.operations.lookupCustomer.result]
type = "Object"
fields = { id = { type = "Text", required = true }, name = { type = "Text" } }
allowUnknown = false

Each service requires at least one operation. Input and result schemas accept entity scalar types plus nested Object and Array values. Object requires fields; Array requires items. The optional transform map projects a validated external result before returning it to the caller.

[notifications]
default = "console"
[[notifications.adapters]]
name = "console"
type = "console"
[[notifications.adapters]]
name = "slack_ops"
type = "slack"
[notifications.adapters.config]
botTokenEnv = "SLACK_BOT_TOKEN"
defaultChannel = "#ops"

Adapter types: console, slack, email.

Use the framework stories package to test your blueprints:

Terminal window
pnpm --filter @zebric/framework-stories test

See the examples/ directory for complete blueprint files.