> Markdown version of https://agledger.ai/docs/install/provisioning/
> Full index of this site for AI assistants: https://agledger.ai/llms.txt

# 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`](https://github.com/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`:

```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](/docs/guides/webhooks/)). 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:

```json
{
  "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"
```

```json
{
  "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 (Helm `extraEnv` /
  `secretKeyRef`), not into the YAML.
- **Dry run.** Set `PROVISIONING_DRY_RUN=true` to 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 and `GET /v1/admin/provisioning/status` reports
  `dryRun: 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, so `pruned` is zero in a preview whatever `PROVISIONING_PRUNE` is set to.
- **Pruning is off by default.** A resource removed from the YAML is left in place (orphaned). Set
  `PROVISIONING_PRUNE=true` to 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 in `errors[]`
  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 watch `errors[]` 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/status` re-reads the directory on every call and lists every
  unloadable file in `loadErrors[]`; the `agledger_provisioning_errors` gauge (`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](/docs/install/) 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](/api/). For rotating and reloading config as a recurring operator task, see
[day-2 operations](/docs/operations/day-2/).

---

*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](/docs/operations/day-2/). 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).*
