Docs

Writing dbb1 apps

Models, components and routes in one small Rhai file.

dbb1 apps

A dbb1 app is one Rhai file (app.rhai). It declares models (each becomes a database table, an admin view and a JSON API) and routes (pages built from components, or JSON). Deploying it puts it live at https://<name>.apps.dbb1.dev.

app(#{ title: "Todo", nav: [["Tasks", "/"]] });

model Task #{
  title: "string! max=200",
  done:  false,
};
expose(Task);

get("/", || [
  hero("Things to do", "A tiny task list."),
  form(Task, #{ fields: ["title"] }),
  table(Task, #{ actions: [["Done", "/tasks/:id/done"], "delete"] }),
]);

post("/tasks/:id/done", |id| {
  Task.update(id, #{ done: true });
  redirect("/")
});

Deploy: dbb1 apps deploy (the first deploy creates the app and writes app.yaml; later deploys update it), or paste the file into the app's Code page on apps.dbb1.dev, or describe the app on New app and let the builder write it. dbb1 apps deploy --dry-run checks a file without deploying. Errors come back with a line number.

Never write HTML by hand. Pages are built from components (below); every string you pass to them is escaped. Return an array of components from a route and it becomes a full page in the app's layout (navigation bar, styling, footer).

App settings

app(#{
  title: "Reading list",                     // the app's name in the layout and page titles
  description: "Books and reviews.",         // meta description
  layout: "sidebar",                         // "sidebar" (default), "top" or "site"
  nav: [["Books", "/", "book"], ["Reviews", "/reviews", "chat-quote"]],   // [label, path, optional icon]
});

Layouts. sidebar (the default) is a back-office app: a left rail with the nav links and compact pages. top puts the nav in a slim bar across the top — good for one to three pages. site is a public-facing look with a dark navigation bar, where hero() becomes a big banner. Icons are Bootstrap Icons names ("list-check", "people").

Pages update without full reloads (htmx), and confirmations show as toasts — nothing to set up.

Models

model Name #{ field: spec, ... }; — Name is PascalCase. Each model gets id, created_at and updated_at automatically (don't declare them). A field's value says what it is:

WriteMeaning
"string" "text" "int" "float" "bool" "datetime" "json"a field of that type (text is long text; datetime holds 2026-10-11 or 2026-10-11T09:30)
"string!"required
"string! max=200 unique"options after the type: min= max= default= unique index required
"int min=0 default=1"numbers: min/max bound the value; strings: they bound the length
false · 0 · 1.5bool / int / float field, defaulting to that value
["draft", "live"]one of these strings; defaults to the first
Booka reference to another model, stored as book_id
"Book!"a required reference
string(#{ label: "Title", max: 200, required: true })long form, for a custom label

Field names are lower_snake_case. Models may reference models declared later in the file.

JSON API. Every model has /api/<plural> (Task → /api/tasks, Category → /api/categories): GET lists (?field=value&order=-id&limit=20&offset=0), POST creates, GET/PATCH/DELETE /api/tasks/:id. It's private (needs an app API key from the app's Settings) unless you expose it:

  • expose(Task); — public reads
  • expose(Task, "write"); — public reads and writes

Changing models migrates the database when you deploy: new models and fields are added. Removing a field or model keeps its data (it's just no longer read); changing a field's type is refused — add a field with a new name instead.

Data

Models are global names inside routes, hooks and functions.

CallReturns
Task.all() · Task.all(#{ done: false, order: "-id", limit: 20, offset: 0 })array of records
Task.first(#{ ... }) · Task.find_by(#{ title: "x" })a record or ()
Task.find(id)a record or () (id may be 5 or "5")
Task.count() · Task.count(#{ done: true })int
Task.create(#{ title: "x" })the new record
Task.update(id, #{ done: true })the updated record, or () if missing
Task.delete(id)true / false

A record is a map: t.id, t.title, t.created_at, and a reference as t.list_id.

Filters — plain keys mean "equals"; order, limit (max 1000, default 100) and offset are options. Operators: #{ pages: #{ gt: 300 } }, gte, lt, lte, ne, #{ status: #{ in: ["a", "b"] } }, #{ title: #{ ilike: "%rust%" } } (like is case-sensitive), #{ due: #{ null: true } }. order: "-created_at" sorts descending; an array sorts by several fields.

Validation runs on every write (required, min/max, choices, references must exist, unique). A failed write in a route answers 422 with the field errors automatically, so post("/tasks", |body| Task.create(body)) needs no error handling. To handle it yourself — Rhai's try is a statement, so return from both branches:

model Task #{ title: "string!" };

post("/tasks", |body| {
  try {
    return Task.create(body);
  } catch (e) {
    return json(#{ problems: e.errors }, 400);   // e.message, e.errors: #{ title: "is required" }
  }
});

Deleting a record that others still reference is refused.

Hooks

model Book #{ title: "string!", slug: "string unique" };

before_save(Book, |b| {
  b.title = b.title.trim();                 // change the record in place
  if b.slug == () { b.slug = b.title.to_lower().replace(" ", "-"); }
});

before_delete(Book, |b| {
  if b.title == "keep" { throw "this one stays"; }   // throw refuses the write
});

after_save(Book, |b| print(`saved ${b.id}`));

before_save runs on create and update (on update the record has an id), after_save, before_delete, after_delete.

Routes

get, post, put, patch, delete(path, handler). Paths may have :params and a trailing *rest. Handler arguments are matched by name:

  • a route param — get("/books/:id", |id| ...) (numeric params arrive as integers)
  • body (parsed form or JSON), query (map of the query string), params, headers
  • req — everything: req.method, req.path, req.params, req.query, req.headers, req.body, req.raw_body

|| ... takes nothing. An unknown argument name is a deploy error.

Return

  • components (or an array containing any) → a full page in the app layout; page("Title", [...]) sets the page title
  • a map, an array of plain values, or a record → JSON
  • a string → sent as HTML as-is (prefer components)
  • json(v) · json(v, 201) · html(s) · text(s) · redirect("/") · status(404) · status(404, "message"), and .header("x-name", "value") on any of them
  • nothing → 204

/api/<plural> of declared models and /_dbb1/* are reserved.

Components

Components return markup and compose freely; arrays of them concatenate. Strings, numbers and maps passed as content are escaped (a map renders as a label/value list).

ComponentRenders
page(title, content)a page with a document title
hero(title, subtitle) · hero(title, subtitle, actions)the page header: title, subtitle, buttons on the right (a dark banner in the site layout)
section(title, content)a titled section
card(title, content) · card(content)a bordered panel
grid([a, b, c]) · grid(items, cols)side-by-side columns (stacks on phones)
stat(label, value) · stat(label, value, hint)a big number tile
stats([stat(...), ...])a row of stat tiles
heading(text) · heading(text, level)a heading (level 1–4)
p(content) · muted(content)a paragraph · a grey paragraph
markdown(text)formatted text (raw HTML in it is shown as text)
link(label, href)a link
button(label, href) · button(label, href, variant)a link styled as a button
action(label, path) · action(label, path, variant) · action(label, path, variant, question)a button that POSTs to one of your routes; with a question (or a danger variant) it asks first in a dialog
alert(content) · alert(content, kind)a notice box
badge(content) · badge(content, kind)a small label
empty(message)an empty-state placeholder
detail(map)a label/value list, e.g. detail(book)
list(items) · list(items, |item| ...)a list; the closure renders each item
table(Model, opts) · table(rows, opts)a table (below)
form(Model, opts)a create/edit form (below)
raw(html)trusted HTML — avoid unless you must

Variants: primary secondary outline-primary outline-secondary success danger outline-danger link. Kinds: primary secondary success danger warning info. text() is a response, not a component — use p() for text.

table(Task, opts) queries the model itself: columns: ["title", "list", "created_at"] (default: the first six fields), plus any filters and order/limit (default newest first, 50 rows), actions, link: "/tasks/:id" (the first column links to each row's page), and empty: "No tasks yet.". Reference columns show the other record's name; choice fields show as badges. actions per row: ["Label", "/tasks/:id/done"] posts to your route (:id and other :fields are filled from the row; optional third and fourth items are the variant and a confirmation question — danger variants always ask), and "delete" deletes the row after a confirmation dialog. Actions always POST — for navigation use link. table(rows, opts) renders an array of maps you already have (columns, actions, link, empty).

form(Task, opts) renders a form from the field definitions and saves it — no route needed. Options: fields: ["title", "list"] (default: all), submit: "Add task", title: "New task", redirect: "/thanks" (default: back to this page), record: task (edit that record instead of creating), values: #{ book: id } (prefill shown fields; for fields not shown, the value is fixed and signed — use this to attach a review to its book). Validation errors appear next to the fields; success shows a toast. Putting a form on a page is what allows the public to create or edit through it — only the listed fields.

Helpers

escape(s) · url_encode(s) · to_json(v) · from_json(s) · now() (UTC 2026-10-11T09:30:00Z) · today() (2026-10-11) · print(...) (goes to the app's logs).

Rhai in five minutes

  • Maps are #{ key: value }, arrays [1, 2], strings "..."; backticks interpolate: `Hi ${name}`.
  • let x = 1; · if a { } else { } (an expression: let s = if done { "yes" } else { "no" };) · for t in tasks { } · while · loop · switch.
  • Missing map keys read as (); a ?? b picks b when a is ().
  • fn helper(x) { ... } defines a function. Functions can't see top-level let variables — pass them in, or use a closure (|x| ...), which captures them.
  • default is a reserved word — that's why field defaults are written "int default=1" or as a literal.
  • Useful methods: s.trim(), s.to_upper(), s.to_lower(), s.replace(a, b), s.contains(x), s.len(), s.split(","), parse_int(s), parse_float(s), arr.len(), arr.push(x), arr.filter(|x| ...), arr.map(|x| ...), arr.sort(), map.keys().
  • Strings, not symbols: status: "done".

Limits

Sandboxed: no files, network or imports. Each request gets about 2 million operations and 5 seconds; scripts up to 256 KB; request bodies up to 1 MB; up to 1000 rows per query, 50 models, 100 fields per model and 200 routes.

Recipes

A detail page with related records

model Book #{ title: "string!", author: "string" };
model Review #{ book: "Book!", rating: "int! min=1 max=5", notes: "text" };

get("/books/:id", |id| {
  let book = Book.find(id);
  if book == () { return status(404, "No such book"); }
  page(book.title, [
    heading(book.title, 1),
    muted(`by ${book.author ?? "unknown"}`),
    section("Reviews", table(Review, #{ book: id, columns: ["rating", "notes"] })),
    form(Review, #{ fields: ["rating", "notes"], values: #{ book: id }, title: "Add a review" }),
  ])
});

A dashboard from computed numbers

model Order #{ total: 0.0, status: ["open", "paid", "refunded"] };

get("/", || {
  let paid = Order.all(#{ status: "paid", limit: 1000 });
  let revenue = 0.0;
  for o in paid { revenue += o.total; }
  [
    hero("Sales", "Today at a glance"),
    stats([
      stat("Paid orders", paid.len()),
      stat("Revenue", `$${revenue}`),
      stat("Open", Order.count(#{ status: "open" })),
    ]),
    table(Order, #{ columns: ["total", "status", "created_at"], limit: 10 }),
  ]
});

Search with a query parameter

model Contact #{ name: "string!", email: "string" };

get("/", |query| {
  let term = query.q ?? "";
  let rows = if term == "" { Contact.all() } else { Contact.all(#{ name: #{ ilike: `%${term}%` } }) };
  [heading("Contacts"), table(rows, #{ columns: ["name", "email"], empty: "No matches." })]
});

A JSON endpoint

model Task #{ title: "string!", done: false };

get("/stats.json", || #{ total: Task.count(), open: Task.count(#{ done: false }) });
post("/tasks", |body| Task.create(body));   // 200 with the record, or 422 with field errors

A complete app

app(#{
  title: "Reading list",
  nav: [["Books", "/"], ["Reviews", "/reviews"]],
});

model Book #{
  title:  "string! max=200",
  author: "string! max=120",
  status: ["to read", "reading", "done"],
  pages:  "int min=1",
};

model Review #{
  book:   "Book!",
  rating: "int! min=1 max=5",
  notes:  "text",
};

expose(Book);

before_save(Book, |b| { b.title = b.title.trim(); });

get("/", || [
  hero("Reading list", "What I'm reading, what's next, and what I thought."),
  stats([
    stat("Reading", Book.count(#{ status: "reading" })),
    stat("To read", Book.count(#{ status: "to read" })),
    stat("Finished", Book.count(#{ status: "done" })),
  ]),
  section("Currently reading", table(Book, #{
    status: "reading",
    columns: ["title", "author", "pages"],
    link: "/books/:id",
    actions: [["Finished", "/books/:id/done"]],
    empty: "Nothing on the nightstand.",
  })),
  grid([
    form(Book, #{ fields: ["title", "author", "status", "pages"], title: "Add a book" }),
    card("Up next", list(Book.all(#{ status: "to read", order: "title" }), |b| `${b.title} — ${b.author}`)),
  ]),
]);

post("/books/:id/done", |id| {
  Book.update(id, #{ status: "done" });
  redirect("/")
});

get("/reviews", || page("Reviews", [
  table(Review, #{ columns: ["book", "rating", "notes", "created_at"], actions: ["delete"] }),
]));

Working on an app

  1. Write or edit app.rhai.
  2. dbb1 apps deploy --dry-run — fix anything it reports (each error has a line number; unknown functions come with a "did you mean").
  3. dbb1 apps deploy -m "what changed" — prints the URL and the database changes.
  4. Open the pages (or curl them) to check they render; dbb1 apps logs shows print output and errors.

Other commands: dbb1 apps init (starter app), dbb1 apps pull <name> (download an app made on the web), dbb1 apps list, dbb1 apps open [--admin], dbb1 apps destroy. On the web, each app has Data, Schema, Versions (with what changed in each), Code, Build with AI and Logs pages.

Teach Claude to write dbb1 apps

This page is also a Claude skill. Install it and ask Claude Code to build you an app.

dbb1 skill install          # → ~/.claude/skills/dbb1-apps/SKILL.md
# or: mkdir -p ~/.claude/skills/dbb1-apps && curl -fsSL https://apps.dbb1.dev/skill.md -o ~/.claude/skills/dbb1-apps/SKILL.md