Skip to content
Alvo is pre-v0.1: the image runs from its edge tag, and no NuGet package or release is published yet.Pre-v0.1: no release yet.Roadmap and status

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.

  • Docker with Compose v2, curl and openssl, 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 (roles agent, authenticated) and admin (roles admin, authenticated). Their secrets are the variables ALVO_AGENT_KEY_SECRET and ALVO_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.

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.

  1. 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-desk
    curl -fsSLO https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/docker-compose.quickstart.yml
    curl -fsSL -o docker-compose.override.yml https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/shell/help-desk.compose.override.yml
    curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/tutorial/01-entity.alvo.json
    export COMPOSE_FILE=docker-compose.quickstart.yml:docker-compose.override.yml
    export 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
  2. 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 without rules answers nothing at all. Here any authenticated caller may list, read and create tickets, and nobody may update or delete them yet.

  3. File a ticket as the agent key, 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 Created
    Content-Type: application/json; charset=utf-8
    ETag: "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 OK
    Content-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.

  4. 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 Entity
    Content-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: type says what kind of refusal it is, and each violation names the field, a stable code and a fix.

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.

  1. 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"
    }
    }
    }
    }
  2. Apply it by recreating the alvo container. 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.json
    docker compose up -d --wait --force-recreate alvo
  3. 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 Created
    Content-Type: application/json; charset=utf-8
    ETag: "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 Found
    Content-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 delete rule 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.

  4. The admin key 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

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.

  1. Trim every new ticket’s title with the built-in trim function, 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." }
    }
    ]
    }
    }
    }
    }
  2. 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.json
    docker compose up -d --wait --force-recreate alvo
  3. 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 Created
    Content-Type: application/json; charset=utf-8
    ETag: "639273155297543840"
    Location: /api/tickets/d091b978-63ee-4d53-860e-e1fbace77ac8
    {
    "id": "d091b978-63ee-4d53-860e-e1fbace77ac8",
    "title": "VPN drops every hour",
    "priority": "normal"
    }
  4. 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 Forbidden
    Content-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 reject is a policy refusal, so its status is 403 forbidden, the same as a rule’s. The detail also names the hook that refused, so you can find it in the descriptor.

A computed field is derived from the row’s own fields, stored as a generated column, and never written by a caller.

  1. 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, as hourly_rate does here.

  2. Apply it:

    Terminal
    curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/examples/help-desk/help-desk.alvo.json
    docker compose up -d --wait --force-recreate alvo
  3. 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 Created
    Content-Type: application/json; charset=utf-8
    ETag: "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
    }

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:

Terminal
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:

Terminal
docker compose down --volumes
unset COMPOSE_FILE
StatusProblem typeWhenFixReturned by
401unauthenticatedA 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
403forbiddenThe 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
404not-foundThe row does not exist, or the get, update or delete rule excludes it for this caller.Check the rule for that operation.every host
422validationThe 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.

  • 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.

Run your own descriptor: point the same stack at a file you wrote, with keys for the roles it declares.