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

For coding agents

Give a coding agent what it needs to change an Alvo backend safely, from llms.txt and the JSON Schema to a checked, previewed and idempotent apply.

Alvo is built to be configured by an agent. The whole backend is one JSON file with a published schema, every refusal says what to fix, and every write to the configuration can be previewed and retried safely.

  • A stack serving your descriptor: Run your own descriptor. curl and jq for the shell recipe.
  • An API key whose roles reach a management level in the descriptor’s access block. This page uses admin (roles admin, authenticated; secret ALVO_ADMIN_KEY_SECRET) and grants that role the developer level.

Two plain-text files describe this site for a model:

  • https://alvo.burgyn.online/llms.txt: an index of every page, one line each.
  • https://alvo.burgyn.online/llms-full.txt: the pages themselves as Markdown, with every descriptor, request and captured response inline.

Give your agent the first one in its instructions, and let it fetch the second when it needs the detail.

The descriptor format is a JSON Schema with a description on every key, served at https://alvo.burgyn.online/schema/v1/project.json. A descriptor’s own $schema value, https://alvo.dev/schema/v1/project.json, does not resolve yet, so an editor does not find the schema from it. Map the file pattern to the served URL instead. In VS Code, add this to .vscode/settings.json:

.vscode/settings.json
{
"json.schemas": [
{ "fileMatch": ["*.alvo.json"], "url": "https://alvo.burgyn.online/schema/v1/project.json" }
]
}

Every *.alvo.json file then gets completion, hover text and errors while you or your agent type. The same URL works in any editor that maps JSON files to a schema. The schema is also published key by key in the descriptor reference.

The dashboard’s schema assistant learns the descriptor from nine skills, and they are plain Markdown your agent can load too. Each one teaches one part of the format and the mistakes Alvo refuses.

In Claude Code, install them as a plugin. This repository is a plugin marketplace, and its alvo plugin carries the nine skills. In a session:

Claude Code
/plugin marketplace add Burgyn/MMLib.Alvo
/plugin install alvo@mmlib-alvo

Or from your shell:

Terminal
claude plugin marketplace add Burgyn/MMLib.Alvo && claude plugin install alvo@mmlib-alvo

Claude Code then loads a skill by itself when a task matches its description, such as adding a rule or a hook, and you can call one directly as /alvo:alvo-descriptor-rules-and-cel. /plugin updates the plugin like any other.

For another agent, or a folder of your choice, a script downloads the same nine files, no clone needed:

Terminal
curl -fsSL https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/scripts/install-agent-skills | sh

It writes them to .claude/skills/alvo-descriptor-*/SKILL.md under the current directory and lists what it installed; any failed download stops it with an error. To put them elsewhere, such as your agent’s own skills folder, pass the folder: curl -fsSL https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/scripts/install-agent-skills | sh -s -- ~/.claude/skills. Run it again to update them.

To load them by hand instead, each skill is one file:

In the repository they live in plugins/alvo/skills/.

Every refusal, from the Data API and the Management API alike, is an RFC 9457 problem document. Its type is https://alvo.dev/errors/<slug>; branch on the slug, never on detail, which is prose. Those URIs do not resolve yet: the slug is the anchor on the Problem types page, so https://alvo.dev/errors/forbidden is documented at /reference/problem-types/#forbidden. A violation adds a JSON pointer, a stable code and a fixSuggestion an agent can act on.

The Management API changes what the project is. Every route is closed to every caller except the bootstrap administrator until the descriptor’s access block grants a level, so this descriptor, the help desk from the Tutorial, adds one:

help-desk.alvo.json
"access": {
"developer": "'admin' in @user.roles"
}

Add it to your own descriptor and recreate the container before you call the routes below; without a level, every management route answers 403 forbidden. On the Tutorial’s stack, in its alvo-help-desk directory, this downloads the descriptor with the block in place and applies it:

Terminal
curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/coding-agents/01-base.alvo.json
docker compose up -d --wait --force-recreate alvo

The agent’s change adds one field, due_on:

help-desk.alvo.json (the change)
{
"$schema": "https://alvo.dev/schema/v1/project.json",
"apiVersion": "alvo.dev/v1",
"name": "help-desk",
"auth": {
"providers": ["local"],
"roles": ["admin", "agent"]
},
"access": {
"developer": "'admin' in @user.roles"
},
"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" },
"due_on": { "type": "date" }
},
"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." }
}
]
}
}
}
}
  1. Check an expression. While it edits, the agent can ask what the apply would say about one expression, without applying anything. The body carries the working copy as descriptorJson, the JSON pointer of the slot (path, here /entities/tickets/rules/update) and the candidate (source). A typo in a role:

    Request
    POST /management/projects/help-desk/cel/check HTTP/1.1
    X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET
    Content-Type: application/json

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    {
    "findings": [
    {
    "path": "/entities/tickets/rules/update",
    "message": "'agnet' is not a declared role, so this membership test can never match.",
    "fixSuggestion": "Did you mean 'agent'? Declared roles: admin, agent, anon, authenticated.",
    "severity": 0
    }
    ],
    "isValid": false
    }

    An expression with a problem is a 200 with findings, not an error status. The corrected expression comes back clean:

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    {
    "findings": [],
    "isValid": true
    }
  2. Preview the apply. Read the current revision, then send the whole descriptor with ?dryRun=true. The answer is the migration plan, and nothing is applied:

    Request
    GET /management/projects/help-desk/descriptor HTTP/1.1
    X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    {
    "project": "help-desk",
    "revision": 1
    }
    Request
    PUT /management/projects/help-desk/descriptor?dryRun=true HTTP/1.1
    X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET
    If-Match: "1"
    Content-Type: application/json

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    {
    "applied": false,
    "revision": 1,
    "plan": {
    "isEmpty": false,
    "hasDestructiveChanges": false,
    "steps": [
    "AddField tickets.due_on"
    ]
    },
    "replayed": false
    }

    The body is {"descriptorJson": "<the whole file as one string>"}. From a shell, jq builds it from the file, and the current revision goes into If-Match:

    Terminal
    curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/coding-agents/02-add-field.alvo.json
    REVISION="$(curl -sS localhost:8080/management/projects/help-desk/descriptor \
    -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" | jq -r .revision)"
    jq -n --rawfile d help-desk.alvo.json '{descriptorJson: $d}' \
    | curl -sS -X PUT "localhost:8080/management/projects/help-desk/descriptor?dryRun=true" \
    -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \
    -H "If-Match: \"$REVISION\"" \
    -H "Content-Type: application/json" \
    -d @-

    Drop ?dryRun=true and add an Idempotency-Key header to apply it for real, as the next step does.

  3. Apply it. A write must say which revision it replaces, in If-Match. Without it, the API refuses rather than risk a lost update:

    Request
    PUT /management/projects/help-desk/descriptor HTTP/1.1
    X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET
    Content-Type: application/json

    428 Precondition Required

    Response
    HTTP/1.1 428 Precondition Required
    Content-Type: application/problem+json
    {
    "type": "https://alvo.dev/errors/precondition-required",
    "title": "Precondition Required",
    "status": 428,
    "detail": "This write requires 'If-Match' carrying the descriptor's current revision, e.g. If-Match: \"3\". Read that revision from GET the same path. Applying without one is a lost update nothing would detect."
    }

    With If-Match and an Idempotency-Key, the change is applied and the revision advances:

    Request
    PUT /management/projects/help-desk/descriptor HTTP/1.1
    X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET
    If-Match: "1"
    Idempotency-Key: help-desk-add-due-on
    Content-Type: application/json

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    {
    "applied": true,
    "revision": 2,
    "plan": {
    "isEmpty": false,
    "hasDestructiveChanges": false,
    "steps": [
    "AddField tickets.due_on"
    ]
    },
    "replayed": false
    }
  4. Retry safely. If the response is lost and the agent sends the same request again, with the same key, the API does not apply it twice. It answers with the revision the first request appended, and replayed: true says this is that earlier result:

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    {
    "applied": true,
    "revision": 2,
    "plan": {
    "isEmpty": true,
    "hasDestructiveChanges": false,
    "steps": []
    },
    "replayed": true
    }

    On a replay the plan is empty: it describes what this request did, which is nothing. Read what a revision applied from GET …/revisions/{n}.

Rollback, destructive changes and revision history are in Apply and evolve your descriptor.

The access block maps the caller’s roles to three levels; the highest one that matches wins. A descriptor without access admits nobody but the deployment’s bootstrap administrator.

LevelMay
viewerread the descriptor, its revisions, the resolved schema, the capabilities and the CEL function catalog, and simulate a policy
developereverything a viewer may, plus check an expression, apply a descriptor and roll back, as long as the access block itself stays the same
admineverything, including a change to the access block, the AI connection and the people who may sign in

An API key’s scopes limit only the Data API; on the Management API, a key reaches whatever its roles reach. The per-route table is in docs/architecture/management-api.md.

StatusProblem typeWhenFixReturned by
403forbiddenThe key’s roles reach no level the route requires, or a developer changed the access block.Grant the role a level in access; have an admin make access changes.every host
409idempotency-conflictThe Idempotency-Key was already used by this caller for a different request.Use a fresh key for a new request.every host
412precondition-failedIf-Match names a revision that is no longer current.Read the descriptor again, reapply your change to it, and send the new revision.every host
422validationThe descriptor is refused, for example an expression that does not compile; the violation’s pointer names the place.Fix what the violation’s fixSuggestion says, and check the slot with cel/check first.every host
428precondition-requiredThe write carried no If-Match.Read the current revision and send it as If-Match: "<revision>".every host

Apply and evolve your descriptor: the full lifecycle of a change, from a dry run to a rollback.