Tutorial: your first backend
Build a help desk's backend in four small steps: an entity, access rules, a before-hook and a computed field, then look at it in the dashboard.
Before you start
Section titled “Before you start”- Docker with Compose v2,
curlandopenssl, and port 8080 free (stop the Quick start’s stack if it still runs). You run the published image the way Run your own descriptor explains; this page gives you every command. - Two API keys, declared by the override file the first step downloads:
agent(rolesagent,authenticated) andadmin(rolesadmin,authenticated). Their secrets are the variablesALVO_AGENT_KEY_SECRETandALVO_ADMIN_KEY_SECRET. Run every command on this page in the same shell and directory, so they stay exported.
Each step changes one file, help-desk.alvo.json in the alvo-help-desk directory the first step creates; the stack
mounts it read-only. Edit it by hand to match the descriptor shown, or download the finished step with the command
beside it. The responses on this page were captured from a real run when the site was built, so the ids, timestamps
and ETags you get will differ.
1. Describe an entity
Section titled “1. Describe an entity”A descriptor names the project, the roles its callers can hold, and its entities. This one has a single entity,
tickets, with five fields and audit: true, which records who created and changed each row, and when.
-
Start the stack over the first version of the descriptor. The commands create the directory, download the quick start’s compose file, the key override and the descriptor, generate the secrets, and wait until the API answers:
Terminal mkdir -p alvo-help-desk && cd alvo-help-deskcurl -fsSLO https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/docker-compose.quickstart.ymlcurl -fsSL -o docker-compose.override.yml https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/shell/help-desk.compose.override.ymlcurl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/tutorial/01-entity.alvo.jsonexport COMPOSE_FILE=docker-compose.quickstart.yml:docker-compose.override.ymlexport ALVO_DEMO_KEY_SECRET="$(openssl rand -hex 16)" ALVO_ADMIN_PASSWORD="$(openssl rand -hex 12)"export ALVO_AGENT_KEY_SECRET="$(openssl rand -hex 16)" ALVO_ADMIN_KEY_SECRET="$(openssl rand -hex 16)"docker compose up --wait --wait-timeout 90 -
This is the descriptor the stack now serves:
help-desk.alvo.json {"$schema": "https://alvo.dev/schema/v1/project.json","apiVersion": "alvo.dev/v1","name": "help-desk","auth": {"providers": ["local"],"roles": ["admin", "agent"]},"entities": {"tickets": {"audit": true,"fields": {"title": { "type": "string", "required": true, "maxLength": 120 },"body": { "type": "text" },"priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" },"status": { "type": "enum", "values": ["open", "closed"], "default": "open" },"estimate_hours": { "type": "decimal", "precision": 5, "scale": 1 }},"rules": {"list": "'authenticated' in @user.roles","get": "'authenticated' in @user.roles","create": "'authenticated' in @user.roles"}}}}Each entity has
rules, one CEL condition per operation. Alvo is default-deny: an operation with no rule is refused for everyone, so an entity withoutrulesanswers nothing at all. Here any authenticated caller may list, read and create tickets, and nobody may update or delete them yet. -
File a ticket as the
agentkey, then list the tickets:Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"title":"Printer on fire","estimate_hours":1.5}'201 Created
Response HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8ETag: "639273155287100400"Location: /api/tickets/b467d3c2-f39f-4f5f-a762-81fbddb85f12{"id": "b467d3c2-f39f-4f5f-a762-81fbddb85f12","body": null,"created_at": "2026-10-11T11:38:48.71004+00:00","created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01","estimate_hours": 1.5,"priority": "normal","status": "open","title": "Printer on fire","updated_at": "2026-10-11T11:38:48.71004+00:00","updated_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01"}Request curl -sS -X GET http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET"200 OK
Response HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8{"items": [{"id": "b467d3c2-f39f-4f5f-a762-81fbddb85f12","body": null,"created_at": "2026-10-11T11:38:48.71004+00:00","created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01","estimate_hours": 1.5,"priority": "normal","status": "open","title": "Printer on fire","updated_at": "2026-10-11T11:38:48.71004+00:00","updated_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01"}],"next": null,"count": null}The response carries the defaults (
priority,status) and the audit columns Alvo maintains. -
Leave out the required
title, and the write is refused before anything is stored:Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"priority":"high"}'422 Unprocessable Entity
Response HTTP/1.1 422 Unprocessable EntityContent-Type: application/problem+json{"type": "https://alvo.dev/errors/validation","title": "Unprocessable Entity","status": 422,"detail": "A field the entity declares required is missing or null.","violations": [{"pointer": "/title","code": "required","message": "A field the entity declares required is missing or null.","fixSuggestion": "Supply a value for it. A create must carry every required field; a partial update may omit any field it is not changing, but may not null a required one."}]}Every refusal is a problem document like this one:
typesays what kind of refusal it is, and each violation names the field, a stablecodeand a fix.
2. Decide who may do what
Section titled “2. Decide who may do what”Rules are CEL expressions over the caller (@user) and the row. Alvo compiles them to SQL predicates, so they hold for
every request, including a list that matches thousands of rows.
-
Let agents and administrators create and update tickets, and let only an administrator delete one:
help-desk.alvo.json {"$schema": "https://alvo.dev/schema/v1/project.json","apiVersion": "alvo.dev/v1","name": "help-desk","auth": {"providers": ["local"],"roles": ["admin", "agent"]},"entities": {"tickets": {"audit": true,"fields": {"title": { "type": "string", "required": true, "maxLength": 120 },"body": { "type": "text" },"priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" },"status": { "type": "enum", "values": ["open", "closed"], "default": "open" },"estimate_hours": { "type": "decimal", "precision": 5, "scale": 1 }},"rules": {"list": "'authenticated' in @user.roles","get": "'authenticated' in @user.roles","create": "'agent' in @user.roles || 'admin' in @user.roles","update": "'agent' in @user.roles || 'admin' in @user.roles","delete": "'admin' in @user.roles"}}}} -
Apply it by recreating the
alvocontainer. On start, Alvo applies a descriptor change that discards nothing, and refuses one that would. (Apply and evolve your descriptor covers the other ways to apply a change, including the Management API without a restart.)Terminal curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/tutorial/02-rules.alvo.jsondocker compose up -d --wait --force-recreate alvo -
An agent files a ticket, then tries to delete it:
Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"title":"Reset my password"}'201 Created
Response HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8ETag: "639273155292266340"Location: /api/tickets/cd419cac-760c-435b-bf7c-3384fd059e5f{"id": "cd419cac-760c-435b-bf7c-3384fd059e5f","title": "Reset my password","status": "open"}Request curl -sS -X DELETE http://localhost:8080/api/tickets/cd419cac-760c-435b-bf7c-3384fd059e5f \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET"404 Not Found
Response HTTP/1.1 404 Not FoundContent-Type: application/problem+json{"type": "https://alvo.dev/errors/not-found","title": "Not Found","status": 404,"detail": "The requested record was not found."}A 404, not a 403. A
deleterule works like a row filter: a caller it excludes cannot see the row, and Alvo answers as if the row were not there, so nobody learns which rows exist that they may not touch. Access rules lists exactly when Alvo answers 403 instead. -
The
adminkey deletes the same ticket:Request curl -sS -X DELETE http://localhost:8080/api/tickets/cd419cac-760c-435b-bf7c-3384fd059e5f \-H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET"204 No Content
Response HTTP/1.1 204 No Content
3. Clean up and check every write
Section titled “3. Clean up and check every write”Before-hooks run inside the write’s transaction, before the row is stored. A mutate action rewrites a field from a
CEL expression over new, the row being written. A reject action refuses the write when its condition is true.
-
Trim every new ticket’s title with the built-in
trimfunction, and refuse a high-priority ticket that has no body:help-desk.alvo.json {"$schema": "https://alvo.dev/schema/v1/project.json","apiVersion": "alvo.dev/v1","name": "help-desk","auth": {"providers": ["local"],"roles": ["admin", "agent"]},"entities": {"tickets": {"audit": true,"fields": {"title": { "type": "string", "required": true, "maxLength": 120 },"body": { "type": "text" },"priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" },"status": { "type": "enum", "values": ["open", "closed"], "default": "open" },"estimate_hours": { "type": "decimal", "precision": 5, "scale": 1 }},"rules": {"list": "'authenticated' in @user.roles","get": "'authenticated' in @user.roles","create": "'agent' in @user.roles || 'admin' in @user.roles","update": "'agent' in @user.roles || 'admin' in @user.roles","delete": "'admin' in @user.roles"},"hooks": {"beforeCreate": [{ "action": { "mutate": { "title": { "$cel": "trim(new.title)" } } } },{"condition": "new.priority == 'high' && !has(new.body)","action": { "reject": "A high-priority ticket needs a body." }}]}}}} -
Apply it:
Terminal curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/tutorial/03-hook.alvo.jsondocker compose up -d --wait --force-recreate alvo -
A padded title comes back trimmed:
Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"title":" VPN drops every hour "}'201 Created
Response HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8ETag: "639273155297543840"Location: /api/tickets/d091b978-63ee-4d53-860e-e1fbace77ac8{"id": "d091b978-63ee-4d53-860e-e1fbace77ac8","title": "VPN drops every hour","priority": "normal"} -
A high-priority ticket without a body is refused, with the hook’s own text in
detail:Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"title":"Server room is flooding","priority":"high"}'403 Forbidden
Response HTTP/1.1 403 ForbiddenContent-Type: application/problem+json{"type": "https://alvo.dev/errors/forbidden","title": "Forbidden","status": 403,"detail": "A high-priority ticket needs a body. (refused by the before-hook at '/entities/tickets/hooks/beforeCreate/1')"}A
rejectis a policy refusal, so its status is 403forbidden, the same as a rule’s. The detail also names the hook that refused, so you can find it in the descriptor.
4. Compute a value
Section titled “4. Compute a value”A computed field is derived from the row’s own fields, stored as a generated column, and never written by a caller.
-
Add an hourly rate and a computed cost. This is the finished descriptor, the same file as
examples/help-desk:help-desk.alvo.json {"$schema": "https://alvo.dev/schema/v1/project.json","apiVersion": "alvo.dev/v1","name": "help-desk","auth": {"providers": ["local"],"roles": ["admin", "agent"]},"entities": {"tickets": {"audit": true,"fields": {"title": { "type": "string", "required": true, "maxLength": 120 },"body": { "type": "text" },"priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" },"status": { "type": "enum", "values": ["open", "closed"], "default": "open" },"estimate_hours": { "type": "decimal", "precision": 5, "scale": 1 },"hourly_rate": { "type": "decimal", "precision": 6, "scale": 2 },"estimate_cost": { "type": "decimal", "precision": 12, "scale": 2, "computed": "estimate_hours * hourly_rate" }},"rules": {"list": "'authenticated' in @user.roles","get": "'authenticated' in @user.roles","create": "'agent' in @user.roles || 'admin' in @user.roles","update": "'agent' in @user.roles || 'admin' in @user.roles","delete": "'admin' in @user.roles"},"hooks": {"beforeCreate": [{ "action": { "mutate": { "title": { "$cel": "trim(new.title)" } } } },{"condition": "new.priority == 'high' && !has(new.body)","action": { "reject": "A high-priority ticket needs a body." }}]}}}}A computed expression combines the row’s own fields. A number or other non-text constant, as in
estimate_hours * 60, is refused when the descriptor is applied, because a generated column cannot take a parameter (a text constant joined to a field is allowed). Keep such a value in a field of its own, ashourly_ratedoes here. -
Apply it:
Terminal curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/examples/help-desk/help-desk.alvo.jsondocker compose up -d --wait --force-recreate alvo -
File a ticket with an estimate and a rate. The response carries
estimate_cost:Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"title":"Replace the office router","estimate_hours":2.5,"hourly_rate":40}'201 Created
Response HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8ETag: "639273155302695050"Location: /api/tickets/22a7f8c9-b6e5-4a54-8d5d-3c1efe2e9564{"id": "22a7f8c9-b6e5-4a54-8d5d-3c1efe2e9564","title": "Replace the office router","estimate_hours": 2.5,"hourly_rate": 40,"estimate_cost": 100}
5. See it in the dashboard
Section titled “5. See it in the dashboard”The stack also serves the admin dashboard at http://localhost:8080/admin. The compose file created a bootstrap
administrator, admin@alvo.local, whose password is the ALVO_ADMIN_PASSWORD the first step generated:
echo "$ALVO_ADMIN_PASSWORD"Sign in and open Schema, then tickets. The Fields tab lists the seven declared fields with their facets and
marks estimate_cost as computed; Rules holds the five rules and On write the two before-hooks. Data browses
the tickets you created through the API.
When you are done, stop the stack and delete its database. The files stay in alvo-help-desk for next time; the
unset makes a plain docker compose in this shell stop reaching for the override:
docker compose down --volumesunset COMPOSE_FILEWhat can go wrong
Section titled “What can go wrong”| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 401 | unauthenticated | A key was sent and cannot be used: a wrong secret, or a role the descriptor does not declare. | Check that the secret variables are still exported in this shell, and that every role on the key is in auth.roles or built in. | every host |
| 403 | forbidden | The operation has no rule, a create rule refused the row, or a before-hook’s reject fired. | Add or widen the rule, or send a row the hook’s condition does not match. | every host |
| 404 | not-found | The row does not exist, or the get, update or delete rule excludes it for this caller. | Check the rule for that operation. | every host |
| 422 | validation | The body breaks the entity’s declared shape, such as a missing required title. | Follow the violation’s pointer and fixSuggestion. | every host |
If docker compose up fails after you change the descriptor, the new version did not apply. The reason is at the end
of docker compose logs alvo; see Run your own descriptor.
What you built
Section titled “What you built”- An entity,
tickets, with a required, length-limited title, two enums with defaults, two decimals and audit columns. - Access rules per operation: reads for any authenticated caller, writes for agents and administrators, deletes for administrators only.
- Two before-hooks: one normalises the title with a built-in function, one refuses an incomplete urgent ticket.
- A computed field derived from two others.
Reference
Section titled “Reference”- Descriptor keys:
entities, fields, rules, hooks, computed fields,auth. - CEL functions:
trim, in the CEL function catalog. - Problem types:
unauthenticated,forbidden,not-found,validation.
Run your own descriptor: point the same stack at a file you wrote, with keys for the roles it declares.