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)
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. 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.
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}}
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. - 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.
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.
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.)