Provision a Server from config-as-code
A Server's setup (its organization, 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 makes the whole set declarative and
idempotent. Organizations are the one exception. A Server runs a single org, created at first
boot, and provisioning manages that org rather than creating others: a production Server has no
create-org call, and GET /v1/admin/orgs lists the one there is. Provisioned resources are tagged
managed_by = provisioning; resources you create through the API directly are not touched by
reconciliation.
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 this loader reads, alongside trusted-issuers.yaml, which has a
loader of its own and can sit at the provisioning root or in a trusted-issuers/ subdirectory
beside them. 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
Check two things before the copy is pointed at a Server.
The org name matches the Server's org. The starter files declare the org as Default, and
agents/ and webhooks/ reference it by that name. A Server holds one org, and an install made
with install.sh already has it, named Default (from AGLEDGER_DEFAULT_ORG_NAME) unless you
changed that. If you did, read the name:
$ curl -s -H "Authorization: Bearer $PLATFORM_KEY" "$U/v1/admin/orgs"
and replace Default with it in orgs/example.yaml (name), agents/example.yaml (orgName)
and webhooks/example.yaml (ownerName and ownerOrgName). The next reconcile takes the existing
org over with its config, inline agents and keys. A YAML org under any other name is refused, and
every agent and webhook that references it fails with it:
This Server runs a single org, and "Acme Corp" would be a second one, so it was not created. The
Server's org is "Default" (<org id>). Name this YAML org "Default" to manage it from provisioning.
The one case where the YAML name wins is a Server whose first boot already has
PROVISIONING_CONFIG_PATH set: when orgs/ declares exactly one org, first boot creates the org
under that name in place of AGLEDGER_DEFAULT_ORG_NAME. An org is not renamed from YAML after
that.
trusted-issuers.yaml names your IdP. The starter file declares two trust anchors for
https://idp.example.com/ (one for operators, one for delegating principals) with
enabled: false, so a verbatim copy trusts no issuer. Put your own IdP's values in it and set
enabled: true on the entries you use, or delete the file.
On Docker Compose, set PROVISIONING_CONFIG_PATH in compose/.env and mount the directory into the
agledger-api and agledger-worker services in compose/docker-compose.override.yml (the
installer creates it empty, and COMPOSE_FILE in .env names it), then docker compose up -d:
services:
agledger-api:
volumes: ["/etc/agledger/provisioning:/etc/agledger/provisioning:ro"]
agledger-worker:
volumes: ["/etc/agledger/provisioning:/etc/agledger/provisioning:ro"]
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.
trusted-issuers.yaml is a single file rather than a directory of them, so on Kubernetes it has its
own two values:
provisioning:
enabled: true
trustedIssuers:
- issuerUrl: https://login.example.com/
expectedAudience: agledger
appliesTo: any
label: Corporate IdP
The chart renders those entries under the file's trusted_issuers: key and projects them as a
directory at <configPath>/trusted-issuers/, which is the second location the loader accepts. A
ConfigMap mounted into a single file through subPath is never refreshed by kubelet, so an operator
editing it and calling the reload endpoint would read the original bytes for the life of the pod; a
directory mount is updated in place, and kubectl apply followed by a reload applies the change
without restarting the pods. Point provisioning.existingConfigMaps.trustedIssuers at a ConfigMap
you manage instead; its key has to be named exactly trusted-issuers.yaml, since the file is
projected by name. With neither set, registering an IdP on Kubernetes means calling the admin API
after every install.
Each entry takes the same keys as the create body of POST /v1/admin/trusted-issuers, validated by
the same grammar, so a value the admin API refuses fails here too: issuerUrl, expectedAudience,
appliesTo (agent, admin, principal, or any, the default), orgId, jwksUri,
expectedAzp, claimMapping, allowedAlgs, maxCredentialTtlSeconds, autoProvisionAgents,
autoProvisionScopeProfile, autoProvisionMaxAgents, subjectAllowlist, jtiSingleUse, label
and enabled. A key this loader does not read is refused rather than ignored, because the defaults
that would apply in an ignored key's place are the permissive ones.
Two of those bound how a token may be presented, and both are worth setting from config rather than
by hand. subjectAllowlist names the exact sub values the row admits, and with
autoProvisionAgents on it is what stops an IdP-side assignment mistake from creating an agent
here. jtiSingleUse makes each admin bearer good for one request across every replica, keyed on the
token's jti claim or, when the token carries none, on the claim the row names under the
claimMapping logical name jti. Entra ID mints uti rather than jti, so an Entra row needs
that mapping for the flag to enforce anything, and the same mapping also makes the cert exchange
single use per uti, since that door reads the same resolved id and enforces one exchange per id
whatever jtiSingleUse says. The authentication guide covers which
IdPs mint an id and what each key refuses.
The loader matches a file entry to a row by issuerUrl, expectedAudience, appliesTo and
orgId. Editing expectedAudience or appliesTo on an existing entry, or binding a global entry
to an org, updates that row in place: its id, its issued certs and its token-id register survive,
and the TRUSTED_ISSUER_CHANGED chain entry carries the previous values. Moving a row from one org
to another, or widening an org-bound row to every org, is never an in-place edit: it retires the old
row and inserts a new one, and a retired row that has issued certs is kept and reported rather than
deleted. When two retired rows could be the one an entry edits, or two entries could be editing one
row, the entries involved are refused, naming every row, and nothing is changed.
Each entry succeeds or fails alone. A new entry whose OIDC discovery fails, whose jwksUri the
egress guard refuses, or whose orgId names no org is refused by index, and the rest of the file
applies. An entry that already has a row keeps its stored jwksUri when discovery fails, applies
every other field it declares, and is reported in warnings[], so setting enabled: false on the
entry for an IdP that is down still disables it. An unchanged entry with no explicit jwksUri
re-runs discovery on every reload and picks up an IdP's moved jwks_uri. The warnings from a boot
or SIGHUP pass appear on the status endpoint as trustedIssuersWarnings. When a reload reports no
errors and changed nothing, trustedIssuersUnchangedSinceLastLoad: true on the status endpoint says
the pass read the same bytes as before, which means an edit you expected never reached the pod.
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, with the starter file's supplied-key entries uncommented:
orgs:
- name: Default
# Supply the key material yourself and the reconciler stores only its
# hash. The key is in your secret store before the Server boots, so there
# is nothing to capture out of a response. The starter file ships these
# commented out, because a bare ${VAR} with no default is a parse error
# when unset. Each entry needs its own value: api_keys.key_hash is UNIQUE.
apiKeys:
- role: admin
label: gitops-admin-key
scopeProfile: admin-iac
apiKey: ${ACME_INTEGRATION_KEY}
# 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
apiKey: ${ACME_PROCESSOR_KEY}
A key declared under an org is that org's admin key, and one declared under an agent is that agent's
key, so role can be left out. Naming the other role (agent under an org, admin under an agent)
is the pair POST /v1/admin/api-keys refuses with a 400, and it fails that entry at load: for a key
under an org, the whole org entry with its inline agents; for a key under a top-level agent, that
agent. Prune is suppressed for the run.
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 and recordSchema, plus optional
completionSchema, displayName, description, category, fieldMappings, defaultGateMode
and compatibilityMode. 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-type 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. A negative maxTolerance, or more
fieldMappings than the register API accepts on any type, fails the entry. An entry with more
fieldMappings than POST /v1/schemas allows on a type with no org, but within what it accepts on
any type, still loads: it is reported under loadWarnings, naming the entry and the cap the API
would apply, and a warning does not suppress prune.
compatibilityMode takes the same values as POST /v1/schemas and is part of the manifest digest,
so a body registered through either door with the same declared value carries one digest. Omitted,
it is backward, the API's default, so the two doors also agree when the key is left out.
A schema entry whose type your org has already registered through POST /v1/schemas is refused
with a per-type error naming that org, and nothing is written: the org's records resolve to its own
registration first, so a provisioned type of the same name would not take effect there. Rename the
entry. Disabling and deleting the org's registration also clears it, but only while no record names
that type: a disabled registration still shadows the name, and one with records cannot be deleted.
The API refuses the converse, an org registration of a provisioned type's name.
The schema bodies themselves are validated at load too, by the same meta-schema walk and Ajv trial
compile that POST /v1/schemas runs. A recordSchema or completionSchema must be a JSON object
with type: object and a non-empty root required array; $id, $data, $code, $async,
prefixItems and contentSchema are refused; format must be one of the allowed values; regexes
are checked for catastrophic backtracking; the body is bounded at depth 5, 200 nodes and 50 KB; and
it has to compile under Ajv, so type: strng fails at load rather than on the first record written
against the type. GET /v1/schemas/meta-schema serves the authoritative constraints, including the
allowed formats and the applicator keywords. Compatibility against the versions already registered
needs a database round trip, so it runs at reconcile rather than at load: an entry that changes
anything the manifest digest covers is registered as the next version, as POST /v1/schemas
registers it, and is held to the same check under the current version's compatibilityMode. The
version existing records and completions were validated against stays as it was, and the new
version takes its status, so an edit does not re-enable a type someone disabled. An edit that mode
refuses is reported in errors[] and nothing is written. To make it, reload once with the schemas
unchanged and compatibilityMode: none (which registers the next version under that mode), then
reload with the change, or declare the new shape under a new type name.
Two spellings are easy to write in YAML and both fail:
schemas:
- type: DOCS-NULL-COMPLETION-v1
recordSchema:
type: object
required: [summary]
properties:
summary: { type: string }
completionSchema: # YAML null, not an empty object: refused
- type: DOCS-EMPTY-OBJECT-v1
recordSchema:
type: object
additionalProperties: false # no root `required`: refused
<file>: schemas(DOCS-NULL-COMPLETION-v1).completionSchema: must not be null. Omit the key entirely, or write
`completionSchema: {}`, for a notarize-only type (terminal at RECORDED on create): POST /v1/schemas
takes both and stores `{}`. Otherwise give it a JSON Schema object or a file path.
<file>: schemas(DOCS-EMPTY-OBJECT-v1).recordSchema: Schema must have at least one required property
Omit completionSchema, or write completionSchema: {}, for a notarize-only type; both are what
POST /v1/schemas accepts and both store {}. A bare key is null and is not the same thing.
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, format, signingAlg, and
recordTypes to scope the subscription to specific contract types (fail-closed, server-side; see
the webhooks guide). YAML also takes secret, the HMAC shared secret,
which belongs in a ${VAR} reference rather than in the file. Omit recordTypes, or write recordTypes: ['*'],
to receive every type. Omitting eventTypes also means every event, as
['*'] does, although POST /v1/webhooks requires the key. A declared webhook is held to the same
rules as one created through the API: the owner org's maxWebhookSubscriptions, the same
recordTypes bounds, and the same egress check, so a hostname that resolves to a private address is
refused unless SSRF_ALLOW_CIDRS admits it, while a lookup that fails or times out refuses nothing.
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 line Provisioning complete reports what was applied. Here it is for the starter set
with the two supplied keys above, on an install whose org is Default (name lists abridged):
{
"orgs": { "created": 0, "updated": 1, "pruned": 0, "updatedNames": ["Default"], ... },
"agents": { "created": 2, "updated": 0, "pruned": 0,
"createdNames": ["Acme Task Processor", "External Monitor Agent"], ... },
"webhooks": { "created": 4, "updated": 0, "pruned": 0,
"createdNames": ["Default:https://hooks.acme-corp.example.com/agledger", ...], ... },
"schemas": { "created": 3, "updated": 0, "pruned": 0,
"createdNames": ["CUSTOM-DELIVERY-NOTE-v1", "CUSTOM-INVOICE-v1", "CUSTOM-REPORT-v1"], ... },
"apiKeys": {
"created": 2, "skipped": 0,
"keys": [
{ "ownerName": "Acme Task Processor", "ownerType": "agent", "label": "processor-key",
"keyId": "01a0cd38-6a7d-708e-b9f3-ae86961b697b", "source": "supplied" },
{ "ownerName": "Default", "ownerType": "org", "label": "gitops-admin-key",
"keyId": "01a0cd38-6a7f-7059-afd1-0bf79f568622", "source": "supplied" }
]
},
"errorCount": 0, "dryRun": false
}
The org counts as updated, not created: the Server's org already existed, and the reconcile took it over. A webhook is named by its owner and URL.
Every key entry carries keyId and source. source: supplied means you provided the material and
the Server stored only its hash. source: generated means the Server minted it, and the log never
carries the plaintext: a key the Server mints is a credential whose home is your secret store, not
your log pipeline. The one place a minted key's plaintext appears is the apiKeys.generated[]
array of the POST /v1/admin/provisioning/reload response (below), once.
Provisioning also runs at boot, and at boot nobody called that route, so a key minted by first boot
has no readout anywhere and the only recovery is to deactivate it with a platform key
(PATCH /v1/admin/api-keys/{keyId}) and reload. To have the Server mint a key, add the entry to a
Server that is already running and call the reload route yourself. To make config the whole story,
supply the material.
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 is appended when the type is first created, and another for each version a reload
registers by changing anything the manifest digest covers (the schema bodies, displayName,
description, category, fieldMappings, compatibilityMode). The matching schema.registered
row in system_audit_log names the digest it replaced. A change to defaultGateMode alone writes
no chain entry, since the digest does not cover it, and writes a schema.default_gate_mode_changed
row instead; any change to the stored compatibility mode also writes schema.compatibility_changed.
A reload that changes nothing writes nothing.
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-09-23T07:43:42.315Z",
"managed":{"orgs":1,"agents":2,"webhooks":4,"schemas":3,"trustedIssuers":2},
"loadErrors":[],"loadWarnings":[],"pruneSuppressed":false,
"trustedIssuersWarnings":[],"trustedIssuersUnchangedSinceLastLoad":false}
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"
Run against the same unchanged directory (name lists and nextSteps descriptions abridged):
{
"orgs": { "created": 0, "updated": 1, "pruned": 0, "updatedNames": ["Default"], ... },
"agents": { "created": 0, "updated": 2, "pruned": 0, ... },
"webhooks": { "created": 0, "updated": 0, "pruned": 0, ... },
"schemas": { "created": 0, "updated": 0, "pruned": 0, ... },
"apiKeys": { "created": 0, "skipped": 2, "generated": [] },
"errors": [],
"trustedIssuers": { "configured": true, "total": 2, "inserted": 0, "updated": 0, "deleted": 0,
"errors": [], "warnings": [], "unchangedSinceLastLoad": true, ... },
"loadWarnings": [],
"dryRun": false,
"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 orgs and agents count as updated, unchanged webhooks and schemas
count nowhere, existing keys count as skipped, and only genuinely new keys appear in
apiKeys.generated[]. An org entry with no config: block leaves the stored org config as it is,
including caps set through PATCH /v1/admin/orgs/{id}/config; an entry that declares one replaces
it, and writes the chain entry and admin.org_config_updated row only when the stored value
actually changes.
A declared key carries an expiry like any other: API_KEY_DEFAULT_LIFETIME_SECONDS gives it 90 days
unless the install says otherwise. Each reconcile pushes that window forward on the keys it manages,
so a Server that boots, takes a SIGHUP or answers POST /v1/admin/provisioning/reload inside the
window keeps them alive with the same material. A Server that does none of those things for a full
lifetime lets the key lapse, and the next reconcile revives it, so reconcile at least as often as the
lifetime or set API_KEY_DEFAULT_LIFETIME_SECONDS=0. Keys minted before the install had a default
are left with no expiry, and the reconciler never adds one to them. A key that declares its own
expiresAt is the exception: that instant is capped the way the API caps it, never pushed forward,
and refused when it has already passed; once a live key's declared expiry passes, each reload
reports the key rather than calling it healthy. allowedIps is accepted with the API's validation.
A declared agent in an org deactivated through the API is refused, so no key is minted that would
fail at auth.
Like every AGLedger mutation, the response carries nextSteps so an agent driving the install
knows what to do next without reading docs: here, that any source: generated entry in generated[] must be captured now, because that
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, for orgs, agents, webhooks and schemas. A resource of one of those kinds removed from the YAML is left in place (orphaned). Set
PROVISIONING_PRUNE=trueto deactivate removed resources of those kinds on reload.trusted-issuers.yamlis the exception: an entry removed from that file is deleted on the next reconcile whateverPROVISIONING_PRUNEsays, because it has its own loader and its own reconcile pass. An issuer that has already minted an ephemeral cert is refused deletion and kept in place instead, so the certs it signed stay attributable; setenabled: falseon it rather than removing it. Removingtrusted-issuers.yamlitself deletes nothing: with no file to read, that pass reconciles no rows. -
An empty provisioning directory is read as a missing mount, not as a removal of everything. With
PROVISIONING_PRUNE=true, aPROVISIONING_CONFIG_PATHholding no YAML underorgs/,agents/,webhooks/orschemas/is reported as a load error and prune is suppressed for the run, so a renamed or unmounted ConfigMap does not read as every managed org, agent and webhook having been deleted. -
Pruning is skipped entirely for a reload whose config did not load cleanly, and the run says so. Absence from a config the Server did not fully understand is not evidence that anyone removed anything, so one typo never causes the resources around it to be un-managed.
GET /v1/admin/provisioning/statuspublishespruneSuppressed: truefor exactly this state, and the reload response carries the same thing as anerrors[]entry withresource: "config":Prune was skipped because the config did not load cleanly, so anything missing from this load was left managed and untouched. This defers real removals too: an org or agent deleted from the YAML keeps its API keys ACTIVE until a clean load prunes it, so a removal made in order to revoke credentials has NOT taken effect yet. Fix the load errors listed in this same errors[] (resource "config") and reconcile again.The deferred revocation is the half to plan around. Deleting an org or an agent from the YAML is how a GitOps install revokes its keys, and while pruning is suppressed those keys stay ACTIVE. Treat
pruneSuppressed: trueas an alert condition wherever you offboard by removing a file: fix the reported load errors and reconcile again, or revoke the key directly withPATCH /v1/admin/api-keys/{keyId}. -
Fail-open, validated at two granularities. A document-level defect (an unset
${...}substitution with no default, or a file that does not parse into the org, agent, webhook or schema array its directory expects) is skipped whole and reported inerrors[]while every other file still applies. An unknown key or a missing required field inside one entry is narrower: it fails only that entry, reported the same way, and every other entry in the file still loads, so a typo in one webhook does not block the orgs and agents around it or the other webhooks beside it. Schema entries also get every register-API check that is a pure function of the submitted body: the meta-schema walk, the Ajv trial compile, and the gate-rule wiring (duplicate ruleIds, unknown verbs, non-resolving paths), each 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, and the version of that type already registered stays live and stays managed. 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 four directories on every call and lists every unloadable file inloadErrors[], alongside anything the last reconcile'strusted-issuers.yamlpass refused, so a reconcile at boot or fromSIGHUPis as visible as one you asked for; 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.