App contract
One JSON document describes the whole app. Appbricx normalizes it, reports what it changed, and builds the database, access policies, sign-in, workflows and UI from it. The contract stays in the project, so the next session reads it back with get_contract.
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, e.g. { "screen": "MyLogin" } for your own sign-in component |
demo_logins | No | Extra demo sign-ins: [{ "persona", "row" }], 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. Add
demo_loginswhen a demo needs two people (chat, dating, a marketplace).
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 }] | Replaces the extra 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.