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
- Secrets via substitution, never plaintext. Every string value supports
${VAR}and${VAR:-default}, substituted from the pod environment at load time - use it for webhook HMAC secrets and the like. Substitution runs on parsed YAML values, so a variable's contents cannot inject YAML structure, and the engine's own keys (API_KEY_SECRET,VAULT_SIGNING_KEY, …) are blocked from substitution. Inject the values through your orchestrator (HelmextraEnv/secretKeyRef), not into the YAML. - Dry run. Set
PROVISIONING_DRY_RUN=trueto log what reconciliation would change without applying it. The counters are named for what an apply does (created,createdNames), so the completion line says which mode it ran in andGET /v1/admin/provisioning/statusreportsdryRun: true. Resources that reference an org or agent the same config declares resolve against it, so previewing a first install reports the creates rather than a not-found for a row the dry run deliberately did not write. Pruning is the one thing a dry run does not preview: it does not run at all, soprunedis zero in a preview whateverPROVISIONING_PRUNEis set to. - Pruning is off by default. A resource removed from the YAML is left in place (orphaned). Set
PROVISIONING_PRUNE=trueto deactivate removed resources on reload. - Fail-open per file, validated at load. One invalid file (an unset
${...}substitution with no default, an unknown key, a missing required field) is skipped whole and reported inerrors[]while every valid file still applies - a typo in one webhook does not block the orgs and agents around it. Schema entries get one deeper check: gate-rule problems (duplicate ruleIds, unknown verbs, non-resolving paths) skip that entry alone, with the same error message the register API would return. Either way nothing invalid is half-applied - a broken entry is skipped, never provisioned without its rules. The server still starts with a bad config (reconciliation errors are reported, not fatal), so watcherrors[]on boot and reload. - Check
loadErrors[], not the boot log. Fail-open means a skipped file's resources are silently absent while the Server comes up healthy, and the boot WARN scrolls away.GET /v1/admin/provisioning/statusre-reads the directory on every call and lists every unloadable file inloadErrors[]; theagledger_provisioning_errorsgauge (stage="load"/stage="reconcile") carries the same state for alerting. Prefer${VAR:-default}over a bare${VAR}anywhere a sensible default exists, since a bare ref with the variable unset fails the whole file.
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).