Describe your app in one file
Your whole app lives in one JSON document called the contract: the kinds of users it has, the data it stores, who can see and change which rows, the rules the server enforces, background workflows, screens, and sign-in. Your agent writes it. Appbricx builds the database, access policies, sign-in, workflows and UI from it.
Why it matters to you
- Your app is easy to change. Adding a field or a role is a small edit to one file, not a hunt through code.
- Your work carries over.The contract stays in the project, so the next session (yours, a teammate's, or the built-in builder) reads it back with
get_contractand continues. - Mistakes are caught before they ship. Appbricx checks the contract, fixes what it safely can and tells you exactly what it changed.
You rarely write the contract by hand. Read this page when you want to understand what your agent wrote, or to steer it precisely.
get_guide (MCP), appbricx docs (CLI) and GET /dev/v1/docsis generated from the platform's own source and is always current. This page is the overview.Top-level keys
| Key | Required | What it holds |
|---|---|---|
app | Yes | name, tagline; optional brand_icon, theme, nav_style (sidebar | topbar | rail), density (comfortable | compact), direction, brand_color (a hex colour) |
personas | Recommended | The roles: id, label, description, kind (staff | consumer) |
entities | Yes (at least one) | Tables, their fields, access, owner refs and rules |
seeds | No | Rows per entity |
pages | No | Your own pages. Omit it and every table gets a list + form page for its staff roles |
workflows | No | Up to 10 server-side workflows (on data change, schedule or webhook) |
components | No | Custom React components with their source. See Custom components |
auth | No | Sign-in screen presentation and providers, e.g. { "screen": "MyLogin" } for your own sign-in component, or { "google": true } to turn on Sign in with Google. See Sign-in methods |
demo_logins | No | Demo sign-ins: [{ "persona", "row", "email"?, "password"?, "name"? }], up to 8 |
Personas (roles)
staffpersonas manage shared records.consumerpersonas act through pages (forms, funnels, composers).- The first consumer persona is the public sign-up. With no consumer persona the app is invite-only, and the first staff role gets a Team & access page to add people.
- A person's own row lives in the entity named exactly like their persona id (the
technicianpersona → thetechniciantable).
Entities
Names are snake_case and singular. Every table already has id, created_at, updated_at, user_id, org_id and created_by. Never declare them.
| Key | Meaning |
|---|---|
name | Table name |
label_field | The field that names a row in the UI |
fields | { name, type, enum_values?, ref?, optional? }. Types: text, number, boolean, date, timestamp, enum, ref, email, url, image, attachment. A ref names another entity |
unique | Field combinations that must be unique, e.g. [["slot", "date"]] |
access | Per-persona overrides. read / update / delete: "own" | "all" | "none"; create: true | false |
owner_refs | Ref fields that assign a row to a person. A row is "own" when any listed ref points at the viewer's row (transitively). [] = creator only |
actor_ref | A ref that always names the writer's own persona row (e.g. sender_id). The server stamps it on every write by a non-staff role |
rules | Before-write checks, up to 6. See Before-write rules |
Access is derived from personas and pages and enforced in Postgres with row-level security. Only add access where the default is wrong. verify then proves it with a role × table probe.
Seeds, pages and workflows
- Seeds. Seed
ids like"t1"are labels for refs between seed rows; the platform turns them into stable UUIDs. After the first apply, omitseedsto keep every row. - Pages. Each page has a
name,route,title,personasand alayouttree of blocks and custom component nodes."chrome": "none"makes it full screen. Seeget_guidesectionspagesandblocks. - Workflows.
{ id, title, on, when?, do[] }. Triggers: an entity event, a schedule or a webhook. Actions includenotify,email,integrationand (last resort)customcode. Omittingworkflowskeeps the current ones; a present list replaces them. Workflows run after a write; use rules to stop one. - Demo sign-ins. Every persona gets one login, linked to the first seed row of its table and named after that person:
ava@tablefor.testwith a word password likemaple-otter-river-27. Adddemo_loginswhen a demo needs two people (chat, dating, a marketplace). To choose the credentials yourself, give an entryemail,password(8+ characters) and/orname. They are used exactly as written; on the first seed row they replace that persona's default login. Top-levelaccounts({ email, password, role }) work too.
Example
A field-service app: dispatchers create and assign jobs, technicians see and update only their own. This is the example contract that ships with the Claude Code skill.
{
"app": { "name": "Field Desk", "tagline": "Jobs, sites and the people who fix them" },
"personas": [
{ "id": "dispatcher", "label": "Dispatcher", "description": "creates and assigns jobs", "kind": "staff" },
{ "id": "technician", "label": "Technician", "description": "works the jobs assigned to them", "kind": "staff" }
],
"entities": [
{
"name": "technician",
"label_field": "name",
"fields": [
{ "name": "name", "type": "text" },
{ "name": "email", "type": "email" },
{ "name": "phone", "type": "text", "optional": true }
]
},
{
"name": "site",
"label_field": "name",
"fields": [
{ "name": "name", "type": "text" },
{ "name": "address", "type": "text" }
]
},
{
"name": "job",
"label_field": "title",
"fields": [
{ "name": "title", "type": "text" },
{ "name": "site", "type": "ref", "ref": "site" },
{ "name": "technician", "type": "ref", "ref": "technician", "optional": true },
{ "name": "status", "type": "enum", "enum_values": ["open", "assigned", "done"] },
{ "name": "due", "type": "date", "optional": true }
],
"owner_refs": ["technician"],
"access": { "technician": { "read": "own", "update": "own", "delete": "none" } }
}
],
"seeds": {
"technician": [
{ "id": "t1", "name": "Asha Rao", "email": "asha@example.com" },
{ "id": "t2", "name": "Ben Ortiz", "email": "ben@example.com" }
],
"site": [
{ "id": "s1", "name": "Harbor Mall", "address": "1 Quay St" },
{ "id": "s2", "name": "Northside Clinic", "address": "40 Elm Ave" }
],
"job": [
{ "id": "j1", "title": "Boiler pressure drop", "site": "s1", "technician": "t1", "status": "assigned", "due": "2026-10-10" },
{ "id": "j2", "title": "Replace door closer", "site": "s2", "status": "open" }
]
},
"workflows": [
{
"id": "job-assigned",
"title": "Tell the technician about a new assignment",
"on": { "entity": "job", "event": "updated" },
"when": { "field": "status", "equals": "assigned" },
"do": [ { "action": "notify", "to": "technician", "role": "technician", "title": "New job: {{title}}", "body": "Due {{due}}" } ]
},
{
"id": "overdue-digest",
"title": "Morning digest of new jobs",
"on": { "schedule": "daily 08:00", "entity": "job" },
"do": [ { "action": "notify", "role": "dispatcher", "title": "{{count}} new job(s) since yesterday", "body": "{{items}}" } ]
}
]
}No pages: each table gets a management page for its staff roles. "to": "technician"sends the notification to the person the job's technician ref points at, not to the whole role.
The check report
check_contract (and every apply and patch) returns a report:
| Field | Meaning |
|---|---|
ok | True when there are no errors |
errors | Why the contract can't be built. Must be empty to apply |
fixes | What the normalizer matched, filled or dropped. Make each one explicit in the contract so your intent is clear |
warnings | Built fine but worth a look (e.g. pages nobody can reach) |
summary | app, roles, entities (fields, seed row counts), pages (name, route, personas), workflows(id, trigger, actions). Null when the contract doesn't parse |
An apply with errors is refused with 422, code: "CONTRACT_INVALID" and the report. Nothing is half-built.
Patch ops
After the first apply, send small typed edits with patch_contract instead of resending the whole contract. Each op is validated, then the whole contract is re-normalized and rebuilt. dry_run reports without building. Up to 50 ops per call.
| Op | Fields | Notes |
|---|---|---|
add_entity | name, fields[], title?, personas? | Also adds its management page |
add_field | entity, field | Added fields are optional |
update_field | entity, name, enum_values?, optional? | Enum options can be added, not removed while in use |
remove_field | entity, name | Leaves the app; the column and its data stay |
set_workflow | workflow | Add or replace by id (kebab-case) |
remove_workflow | id | |
add_page | page (name, route, title, …) | The route must be new |
set_page | page, title?, icon?, personas?, layout?, form?, chrome? | Route and name stay fixed |
remove_page | page | |
set_component | component | Add or replace by name; new source is gated on apply |
remove_component | name | Pages that placed it drop the node |
edit_component | name, edits[{ find, replace }] | Each find must occur exactly once |
set_entity | entity, access?, owner_refs?, actor_ref?, label_field?, rules? | A present key replaces; null removes |
set_app | theme?, direction?, nav_style?, density?, brand_color?, auth? | brand_color: "" removes it; auth: null removes the auth section |
set_demo_logins | logins[{ persona, row, email?, password?, name? }] | Replaces the demo sign-ins (max 8) |
Example
[
{ "op": "add_field", "entity": "job", "field": { "name": "priority", "type": "enum", "enum_values": ["low", "normal", "urgent"] } },
{ "op": "update_field", "entity": "job", "name": "status", "enum_values": ["open", "assigned", "done", "cancelled"] },
{ "op": "set_app", "brand_color": "#0F766E" }
]Send it as edits to patch_contract, as the body { "edits": [...] } to POST /dev/v1/projects/:id/patch, or save it as a file and run appbricx patch ops.json.