Provision a Server from config-as-code

A Server's setup - organizations, agents, the API keys they authenticate with, webhook subscriptions, and custom contract types - can be declared in a directory of YAML files and reconciled on every boot. This is the repeatable, reviewable, GitOps-friendly alternative to clicking through admin API calls by hand, and it is what makes AGLedger installable by an agent or an automation pipeline rather than a human at a terminal: point the Server at a directory, and the identities and types it needs come up with it.

Provisioning is additive to the admin API, not a replacement. Agents, API keys, contract types, and webhook subscriptions can also be created with POST /v1/admin/agents, POST /v1/admin/api-keys, and the schema and webhook endpoints - provisioning just makes the whole set declarative and idempotent. Organizations are the one exception: in production, provisioning is how additional orgs are created (the POST /v1/admin/orgs route is dev/test-only and is not registered in production; GET /v1/admin/orgs remains available for listing). The two coexist: provisioned resources are tagged managed_by = provisioning; resources you create through the API directly are not touched by reconciliation.

Validated against API v1.2.0 on 2026-07-06 (Developer Edition, Docker Compose).

The directory

Set PROVISIONING_CONFIG_PATH to a directory and the Server reads it at startup. The layout groups resources by kind:

provisioning/
  orgs/       # orgs, with inline agents and API keys
  agents/     # standalone agents (reference their org by name)
  webhooks/   # webhook subscriptions (reference orgs/agents by name)
  schemas/    # custom contract types (inline, or referencing a JSON file)

Those four are the only directories read, alongside trusted-issuers.yaml at the root. YAML anywhere else is logged with a WARN at load naming the files it holds, whether it sits in a directory nothing reads or loose at the root (which is where a hand-rolled Kubernetes ConfigMap puts it, since a ConfigMap key cannot contain a slash). The alternative is an install that comes up with none of those resources, no error, and no mention of the directory anywhere, which is how a retired name costs an afternoon.

A starter set ships in the agledger-ai/install repository under examples/provisioning/. Copy it, edit it, and point the Server at it:

$ cp -r examples/provisioning /etc/agledger/provisioning
$ export PROVISIONING_CONFIG_PATH=/etc/agledger/provisioning

On Docker Compose, set PROVISIONING_CONFIG_PATH in compose/.env and mount the directory into the agledger-api and agledger-worker services. On Kubernetes, declare the content under provisioning.* in the Helm chart's values and it renders and mounts the ConfigMap for you - do not hand-roll the ConfigMap.

Declaring resources

An org can carry its agents and their keys inline, so one file stands up a complete working identity. The natural key is the resource name - keep it stable across reconciles, because renaming creates a new resource.

orgs/example.yaml:

orgs:
  - name: Acme Corp
    # Admin-role key for external integrations. The generated key is returned
    # once in the reconciliation result (and logged at startup) — capture it then.
    apiKeys:
      - role: admin
        label: primary-api-key
        scopeProfile: admin-standard
    # Agents owned by this org (they inherit org_id from the parent).
    agents:
      - displayName: Acme Task Processor
        agentClass: system
        apiKeys:
          - role: agent
            label: processor-key
            scopeProfile: agent-full

Custom contract types are declared the same way - inline, or referencing a JSON Schema file relative to the provisioning root (schemas/). A schema entry uses the same top-level placement as POST /v1/schemas for the keys it supports: type, recordSchema, completionSchema, plus optional displayName, description, category, fieldMappings, commissionSourceField, and defaultGateMode. Gate rules go in fieldMappings at the top level, exactly as in a register body. Register fields outside that list are not provisioning-configurable; declaring them, or any other unknown key, fails the entry at load time with a per-key error. Rule wiring gets the same validation as the register API (malformed mapping elements, duplicate ruleIds, unknown verbs, and non-resolving criteria/evidence paths are all load errors), so misplaced or broken gate config can never silently provision a Type that enforces nothing.

Webhook subscriptions reference their owner by name (ownerType: org matches an org name; ownerType: agent matches an agent's displayName, with ownerOrgName to disambiguate) and take the same fields as POST /v1/webhooks - url, eventTypes, signingAlg, and, from API v1.2.0, recordTypes to scope the subscription to specific contract types (fail-closed, server-side; see the webhooks guide). In provisioning YAML, omit recordTypes to receive every type; from API v1.3.2 an explicit recordTypes: ['*'] is normalized to the same all-types subscription rather than a literal type named *. See the example files for the full shape of each kind.

Reconciliation on boot

The Server reconciles the directory every time it starts. Each resource is created if absent and updated to match the file if present; nothing is deleted unless you opt into pruning (below). The startup log reports exactly what was applied:

{
  "orgs":    { "created": 1, "updated": 0, "createdNames": ["Acme Corp"] },
  "agents":  { "created": 2, "updated": 0, "createdNames": ["Acme Task Processor","External Monitor Agent"] },
  "webhooks":{ "created": 4, "updated": 0 },
  "schemas": { "created": 2, "updated": 0, "createdNames": ["CUSTOM-INVOICE-v1","CUSTOM-REPORT-v1"] },
  "apiKeys": {
    "created": 3,
    "generated": [
      { "ownerName": "Acme Task Processor", "ownerType": "agent", "label": "processor-key", "apiKey": "agl_agt_…" },
      { "ownerName": "Acme Corp",           "ownerType": "org",   "label": "primary-api-key", "apiKey": "agl_adm_…" }
    ]
  }
}

The generated API keys appear in plaintext exactly once - in apiKeys.generated[] of this result, and in the startup log line that records each key. They cannot be retrieved afterward. Capture them at boot (an init container that reads the log, a Helm --wait hook, or the reload response below) and hand them to whatever consumes them.

Schema registrations are notarized

Creating a contract type from this directory appends a signed SCHEMA_REGISTERED entry to the platform schema chain, the same entry a POST /v1/schemas registration produces. It carries the type, version, publisher and manifest digest, so the registration is tamper-evident and an offline verifier can attest it alongside everything else in the vault.

The entry also carries source: "provisioning" in its signed payload. Config-as-code has no authenticated caller, so the chain says the file was the authority rather than letting the entry read as an API registration. Both are equally authentic; they are not equally attributable. The attribution sits inside the signature, not in a mutable column beside it, so it is worth the same as the rest of the entry.

One entry per schema, appended when the type is first created. A reload that edits a schema updates the row in place and appends nothing, because the type was not registered again.

Status and hot reload

Confirm what is under management with the status endpoint (platform key):

$ curl -s -H "Authorization: Bearer $PLATFORM_KEY" "$U/v1/admin/provisioning/status"
{"configured":true,"configPath":"/etc/agledger/provisioning","dryRun":false,"prune":false,
 "lastReloadAt":"2026-05-27T21:40:07.141Z","managed":{"orgs":1,"agents":2,"webhooks":4,"schemas":2},
 "loadErrors":[]}

Edit the YAML and apply the change without a restart - send SIGHUP, or call the reload endpoint:

$ curl -s -X POST -H "Authorization: Bearer $PLATFORM_KEY" "$U/v1/admin/provisioning/reload"
{
  "orgs":    { "created": 0, "updated": 1, "pruned": 0 },
  "agents":  { "created": 0, "updated": 2, "pruned": 0 },
  "webhooks":{ "created": 0, "updated": 4, "pruned": 0 },
  "schemas": { "created": 0, "updated": 2, "pruned": 0 },
  "apiKeys": { "created": 0, "skipped": 3, "generated": [] },
  "errors": [],
  "nextSteps": [
    { "action": "Inspect provisioning status", "method": "GET", "href": "/v1/admin/provisioning/status" },
    { "action": "Capture raw API keys from THIS response", "method": "GET", "href": "/v1/admin/api-keys" }
  ]
}

Reload is idempotent: unchanged resources count as updated, existing keys as skipped, and only genuinely new keys appear in apiKeys.generated[]. Like every AGLedger mutation, the response carries nextSteps so an agent driving the install knows what to do next without reading docs - here, that a non-empty generated[] must be captured now because the plaintext is not retrievable later.

Secrets, dry run, and pruning

Where this fits

Provisioning is the natural next step after installing a Server and minting the platform key: instead of creating each org and agent by hand, declare the whole set as code and let the Server reconcile it. The request/response shapes for the underlying admin endpoints (/v1/admin/orgs, /v1/admin/agents, /v1/admin/api-keys, /v1/admin/provisioning/*) are in the API reference. For rotating and reloading config as a recurring operator task, see day-2 operations.


Updated for API v1.4.0 on 2026-08-09: GET /v1/admin/provisioning/status carries loadErrors[], which lists every file that failed to load and is the durable signal a fail-open reconcile leaves behind once the boot WARN scrolls away; the agledger_provisioning_errors gauge carries the same state for alerting. Reconciliation and reload flow are otherwise unchanged. Not re-run end to end against a live v1.4.0 install.

Validated against API v1.2.0 on 2026-07-06 (Developer Edition, Docker Compose: boot reconciliation, GET /v1/admin/provisioning/status, and POST /v1/admin/provisioning/reload re-run live against the shipped examples/provisioning/ set. The resource counts shown assume every referenced secret (for example ${ACME_WEBHOOK_SECRET}) is set; with one unset, that file is reported in errors[] and the surrounding resources still apply - see day-2 operations. The schema-entry shape, load-time rule validation, and webhook recordTypes reflect API v1.2.0 as shipped.)

Reviewed for API v1.3.4 on 2026-08-03: unchanged. Spot-checked live on a v1.3.4 install rather than re-run end to end: GET /v1/admin/provisioning/status responds as documented on an install with no provisioning directory configured, and a webhook created with an explicit recordTypes: ['*'] is accepted. A full boot-reconciliation run against a populated PROVISIONING_CONFIG_PATH was not part of this pass.

Reviewed for API v1.3.3 on 2026-07-20: 1.3.3 (audit-vault chain-scan detection plus opt-in verdict per-actor signatures) touches no provisioning surface; boot reconciliation and reload flow unchanged.

Reviewed for API v1.3.2 on 2026-07-13: recordTypes: ['*'] normalization noted; boot reconciliation and reload flow unchanged (testbed to re-capture the transcript against v1.3.2).