Skip to content

Manage the configuration through the API

A node runs from two documents: the node file, which configures one process, and the deployment document, which states what the deployment is. In a cluster the deployment document lives in the raft log: providers, credentials, budgets, profiles and guardrails. The file only ever seeds it. This guide covers reading and writing that stored configuration over the admin API. A Terraform provider, an operator and a person with curl can each maintain their own part of one deployment, without any of them being able to flatten the others.

You need a cluster. Without one there is nowhere to keep a source, and these endpoints answer 501.

Address any node. Writes need the raft leader, but finding it is not your job. The node you called forwards the write and waits for its own copy before answering. A write is readable back from the same node the moment the request returns, and a load balancer in front of the admin listener needs no leader affinity. The one exception is a leader election, which answers 503. See below.

The stored configuration is not one document. It is a set of named sources, and each is written independently:

GET /admin/v1/config/sources list them
GET /admin/v1/config/sources/{source} read one, with its revision in the ETag
PUT /admin/v1/config/sources/{source} write one
DELETE /admin/v1/config/sources/{source} remove one

The gateway serves all of them composed together. The name is yours to choose, such as platform, terraform or team-a. One already exists: default, which the deployment document seeds and which POST /admin/v1/reload writes.

The rule is that sources must not overlap. Two of them defining the same provider, or both setting auth, is refused rather than merged, and the refusal names both:

config: provider openai is defined by two sources, "platform" and "terraform";
sources must not overlap, so one of them has to give it up

That refusal is the write model. A collision between two sources is a mistake somebody has to decide about rather than one the gateway silently resolves. Who may write what is decided per object, on each provider, profile or block a write touches, and the source is the document it lands in. See Say who may change what.

The listing says who last wrote each source, and the history says who made each revision:

{"name": "k8s-team-a", "revision": 41, "bytes": 812,
"written_by": {"kind": "oidc", "name": "system:serviceaccount:pistra:operator",
"issuer": "https://kubernetes.default.svc", "subject": "",
"at": "2026-08-26T09:14:02Z"}}

It is attribution, not ownership. Who may write is still the policy over the source’s name. Ask with GET /admin/v1/whoami?instance=config/k8s-team-a. It exists for the one mistake the precondition cannot catch: a person editing a source that a tool maintains. The operator and the Terraform provider both write their sources whole, at the revision they read. An edit made anywhere else is not a conflict to them, and it is gone on their next run. The console shows the last writer on every source and says so when it is not you, and the API gives you the same fact to act on. A source written by the node itself (a seed from the file, a SIGHUP) is kind: system named for the node. One last written by a version that did not record writers has no written_by at all.

The audit trail records the same writer for the same write, on every attempt, refused ones included. A successful config_source_write record carries source_revision, the number the history reports for it, so the two can be joined. (Its revision is the applied snapshot revision, which moves on every rebuild and cannot serve.)

The body is a deployment document, the same shape as the one the node file names, as YAML or JSON. It is stored as sent, so comments survive.

$TOKEN is a JWT from the issuer this gateway trusts. There is no admin secret to hold. pistra login caches one and pistra admin -url presents it for you. The curl form is here because it is what a script or an SDK sends. See Sign in from the command line.

A precondition is required, and the one you send says what you mean:

Terminal window
# Create. Fails if the source already exists.
curl -sS -X PUT https://gw.example/admin/v1/config/sources/platform \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/yaml' \
-H 'If-None-Match: *' \
--data-binary @platform.yaml

Use the following example:

Terminal window
# Replace. Read first, write back at the revision you read.
etag=$(curl -sSI https://gw.example/admin/v1/config/sources/platform \
-H "Authorization: Bearer $TOKEN" | grep -i '^etag:' | cut -d' ' -f2 | tr -d '\r')
curl -sS -X PUT https://gw.example/admin/v1/config/sources/platform \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/yaml' \
-H "If-Match: $etag" \
--data-binary @platform.yaml

There is no unconditional write. A source exists so two writers never contend for one document. Nothing stops two copies of the same writer from arriving at once, such as a retried apply or two operator replicas mid-handover. A blind overwrite is how the second one discards the first without either noticing. A 412 means somebody wrote in between: re-read, re-apply your change, try again.

A source is stored in the raft log, kept in its revision history and readable by anyone the policy lets read config. A write that carries a credential as a literal is refused with a 400 naming the field, whether that is an api_key: sk-…, an access_key_id or a client_secret. Write ${secret:NAME} instead. Each node resolves the name from wherever the platform delivered it: a file NAME under its secrets_dir, then the environment variable NAME, then the cluster’s secret store. The store holds a value written once with PUT /admin/v1/secrets/NAME, for the person with a console and no way to put a file on a node. See Store a secret through the API.

The document itself never carries the value, and reads the same whichever way the value arrives.

The write is validated all the way to a snapshot before anything is stored. A document that cannot be served is refused here rather than agreed by every node and then broken on all of them at once. The status says which step said no:

Status Meaning What to do
400 The document does not parse, or has a key nothing reads Fix the document
409 It overlaps another source One of you gives that object up
412 The source moved since you read it, or another source did while this write was being validated Re-read and re-apply
422 It parses, but the deployment it would make does not build Fix what it references
428 No precondition Send If-None-Match: * or If-Match
503 The cluster is mid-election, so nobody can take the write Retry; Retry-After says when

Nothing is written on any of them. Whatever was serving keeps serving.

The 503 is the only one worth retrying blindly, and the only one that is about the cluster rather than the document. The rest are answers about what you sent, and sending it again gets the same answer.

One caveat applies to 422. Two of the validation steps are per-node by construction: ${VAR} references resolve against this node’s environment, and a model reference against this node’s cache. A cluster whose nodes disagree about their environment can still be handed a document that builds on the node that took the write and not on its peers. Keep the variables a document depends on wherever the document is.

A delete is validated the same way, because a source can be the one holding what the others depend on:

Terminal window
curl -sS -X DELETE https://gw.example/admin/v1/config/sources/team-a \
-H "Authorization: Bearer $TOKEN" -H "If-Match: $etag"

Removing the source that held the credentials every provider names is a 422, not a broken cluster. The consequence is that tearing a deployment down means deleting in dependency order.

Deleting the last source does something specific. An empty store says nothing about what the deployment is, so the node’s deployment document takes over again. The node’s own source is the one source the API does not delete; see below.

A document PUT replaces everything the source says, which is right for a tool that renders its whole document and wrong for changing one provider by hand. Every object is also addressable on its own, by kind and name, without saying which source holds it:

Terminal window
# Replace one provider, at the revision of the source that declares it.
# The body may omit the name; the path supplies it.
curl -sS -X PUT https://gw.example/admin/v1/config/providers/azure \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \
-H "If-Match: $etag" \
--data-binary $'preset: azure\ncredential: azure-key\n'
# Read it back, comments included. Pistra-Source names the source that
# declares it, and the ETag is that source's revision.
curl -sSi https://gw.example/admin/v1/config/providers/azure \
-H "Authorization: Bearer $TOKEN"
# List every provider across sources, each with its source and revision.
curl -sS https://gw.example/admin/v1/config/providers \
-H "Authorization: Bearer $TOKEN"
# Remove it. Removing the last element removes the key with it.
curl -sS -X DELETE https://gw.example/admin/v1/config/providers/azure \
-H "Authorization: Bearer $TOKEN" -H "If-Match: $etag"

An object that no source declares yet is the one write that has to say where it lands, since nothing stored can answer that:

Terminal window
# Create, naming the source. A source that does not exist is created.
curl -sS -X PUT 'https://gw.example/admin/v1/config/providers/vllm?source=team-a' \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \
-H 'If-None-Match: *' \
--data-binary $'dialect: openai\nbase_url: http://vllm:8000\n'

?source= may be left out when exactly one stored source is open to you for that object; with several the answer is a 409 naming them, and with none a 400 asking for one. The file’s own source, default, is never chosen for you. A reload rewrites it from the file, and an object placed there by the API would be gone the next time somebody sends SIGHUP.

The addressable paths are the document’s own keys. The name-keyed collections take a /{name} segment: providers, credentials, budgets, profiles, mcp, a2a, access_rules. An unnamed provider is addressed by its preset, the same rule composition claims it under. The guardrails block’s two lists are collections here too, guardrail_detectors and guardrail_rules, so one rule is written without the block around it. The first one written creates the block and the last one removed takes it away, and a detector left unnamed is addressed by its type. The blocks and scalars are addressed bare: guardrails, reconcile, models, auth, max_body_bytes, default_provider, on_fidelity_loss, public_url. catalog_overlays is read at /admin/v1/config/catalog_overlays, one object per source that carries it, and a document write is decided on it like any other object. No path writes it: every source’s list is folded in source order, so there is no one source to splice into, and position inside the list is meaning. Edit it in the source document.

What makes this a projection rather than a second write path:

  • It is the same write. The server finds the source that declares the object, splices your one object into that document and runs the whole thing through the pipeline a document PUT runs. The spliced document is composed against the other sources, built to a snapshot, written only if all of it worked, and audited as a write to the source. Deleting the provider a route still names is a 422 here too.
  • The precondition is the owning source’s revision. Two operators editing different providers in the same source are still two writers of one document. The second composes against the first’s change or is told to re-read. Every read here returns that revision in the ETag, and the source in Pistra-Source.
  • The rest of the document is left alone. Other keys, order and comments survive a splice, best-effort. A source kept as commented YAML by a person and also edited through object paths by a tool will lose formatting eventually. Give the tool its own source.

A scalar’s zero value (auth: "", max_body_bytes: 0) is refused on write. Composition treats it as “not set” without claiming ownership, so storing it would make a key that looks set and is not. Unset a field by deleting it. That is the one spelling.

The body of a source PUT is opaque to the transport, stored as sent, byte for byte, but not undocumented. The gateway publishes a JSON Schema for what a document may contain, generated from the same structs that parse one:

Terminal window
curl -sS https://gw.example/admin/v1/config/schema \
-H "Authorization: Bearer $TOKEN" -o pistra-config.schema.json

Point an editor at it and a source kept as a file in a repository gets completion, hover documentation and shape errors while it is being written:

# yaml-language-server: $schema=./pistra-config.schema.json
providers:
- preset: openai
credential: openai-key

The same schema is committed as api/config.schema.json, printed by pistra -config-schema, and referenced from the OpenAPI spec’s request bodies, so generated clients see it too.

It is shape only, deliberately. It knows every key, every type, and that an unknown key is refused. Those are the strict parse’s rules. Nothing in it is required and the empty document is valid, because a deployment document’s keys are optional and the zero value means unset. It cannot know whether a document composes with the other sources and builds into a deployment. Those answers depend on state no schema can carry, and they arrive as the write’s own refusal, per the table above.

Every write is retained for a while: the last ten revisions of each source, inside a deployment-wide budget. Undoing a bad change does not depend on whoever made it still having the old document:

Terminal window
curl -sS https://gw.example/admin/v1/config/sources/team-a/revisions \
-H "Authorization: Bearer $TOKEN"

lists what is held, oldest first, deletes marked as such. The last entry is the current document. Read the one from before the mistake:

Terminal window
curl -sS "https://gw.example/admin/v1/config/sources/team-a?revision=41" \
-H "Authorization: Bearer $TOKEN" -o before.yaml

and put it back the way any document is put:

Terminal window
etag=$(curl -sSI https://gw.example/admin/v1/config/sources/team-a \
-H "Authorization: Bearer $TOKEN" | awk 'tolower($1)=="etag:" {print $2}' | tr -d '\r')
curl -sS -X PUT https://gw.example/admin/v1/config/sources/team-a \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \
-H "If-Match: $etag" --data-binary @before.yaml

There is no rollback verb, on purpose. The restore is an ordinary write. It needs the current revision in If-Match, because the historical response’s ETag is the revision the document came from and sending it is a 412. It passes the same validation, so a document that named a provider another source has since defined collides now even though it did not then. It lands as a new revision, audited like any other. The sequence records that the deployment went there and back. Nothing rewrites it.

This also works for a deleted source. Its history keeps the last documents behind the tombstone, so the undo is reading the pre-delete revision and re-creating the source with If-None-Match: *.

Retention is a working set, not an archive. A revision that has aged out answers 404. The audit trail records every write attempt, successful or refused, and holds the long-term record of who changed what.

A write is decided once per object it adds, removes or changes. Each object is a Cedar resource of its kind, Resource::"profiles/team-a", under two containers: its kind, Resource::"profiles", and the source that declares it, Resource::"config/platform". A policy scopes by either, so a team’s grant names its objects and never a document:

@id("team-a-owns-its-profiles")
permit(principal in Role::"team-a", action in Action::"write",
resource in Resource::"profiles")
when { resource.id like "team-a-*" };
@id("platform-team-owns-platform")
permit(principal in Role::"platform", action,
resource in Resource::"config/platform");
@id("auth-is-the-platform's")
forbid(principal, action in Action::"write", resource == Resource::"auth")
unless { principal in Role::"platform" };
@id("everyone-may-read")
permit(principal, action in Action::"read", resource in Resource::"config");

in is the spelling that covers a document’s objects. == on a source names the container alone, which is what creating or deleting the source itself is decided on. A document PUT that adds a profile and a budget is two decisions, and a refusal names the object refused:

403 not authorized to putConfigSource budgets/daily

The trail records one admin.authz denial per object, so “who tried to change the daily budget” is a question it answers. Every resource carries resource.source, the source it sits in, for a policy written from the writer’s side. See Restrict what an admin caller can do for roles and issuers.

A source is not only for people. Anything that maintains part of the deployment writes one the same way, such as the Terraform provider or the Kubernetes operator. It gets the same two guarantees out of it. The composition refuses by name if it strays into somebody else’s territory, and the compare-and-swap refuses a write composed against a document that has since moved.

The Terraform provider in terraform-provider-pistra/ is this arrangement packaged: a source, an object or a field as a resource, with the validation refusals surfacing as apply errors. Its README has the resource reference.

The operator in operator/ is the worked example. Every object in a Kubernetes namespace (Provider, Credential, Budget, Profile and the rest) renders into one document, written as source k8s-<namespace> at the revision it was read at:

Terminal window
$ pistra-operator \
-admin-url https://pistra-admin.pistra.svc:9900 \
-admin-token-file /var/run/secrets/tokens/admin \
-secrets-namespace pistra-system \
-secrets-name pistra-provider-keys

Three things follow from a source per namespace:

  • A namespace is a deployment half only. Node-level keys such as listen, cluster and admin cannot travel in a source, and no namespaced kind can express them. ClusterConfig carries the deployment-wide blocks and is cluster-scoped for that reason.
  • It cannot write default. That is the source a gateway’s own config file seeds, and the file is its one writer; the API refuses anyone else. The operator’s sources all carry the k8s- prefix.
  • Policy can name it. resource.source like "k8s-*" grants the operator its sources and everything in them and nothing else, where a reload could only ever be granted as “may rewrite the deployment”.

The token file is re-read on every request, so a projected ServiceAccount token that rotates keeps working. operator/README.md has the status conditions and the RBAC the binary’s calls need.

A node runs from two documents. The node file holds what configures this process, listeners, TLS, raft identity, admin.policy_file, and names the deployment document with deployment:. Only the deployment document is stored, and only it changes hands:

  • On first boot, if the default source does not exist, the deployment document seeds it.
  • After that, the document is that source’s one writer. A document with a version, a positive integer you raise with every edit, is applied at start whenever its version is higher than the stored one. A start never applies a document that does not say it is newer, because a rolling restart that reverted the deployment to whatever each node’s file said would be worse than serving what is stored.
  • POST /admin/v1/reload re-reads both files and applies the deployment document now, versioned or not, unless its version says it is behind. It writes the default source and leaves every other source alone. The response carries file_status, so a reload that applied nothing says why.
  • The API does not write default. A document or object write to the node’s source is refused with a 409 naming the file, because the next reload would erase it. Edit the deployment document and reload, or write another source. The rule runs the other way too: a default that an API caller created, on a node that names no deployment document, is that caller’s, and a document that appears later is reported as behind rather than applied over it.

GET /admin/v1/config reports where the file stands as file_status, with file_version, stored_version and a file_hint saying what to do:

file_status meaning what to do
current the file is what is stored nothing
differs no version on either side, and they differ POST /admin/v1/reload to apply the file; give it a version to have starts apply it
newer the file’s version is higher and it has not been applied, the write did not reach a leader, or lost a race POST /admin/v1/reload
bump same version, different content you edited without raising version; POST /admin/v1/reload applies it, and raising version past stored_version has starts apply it
behind the stored document is a later edition the file is not the deployment any more; edit where it lives, or raise version past the stored one to take it back

Versioning a file is how a start knows to apply it. The version travels inside the document, and a source is one writer’s: the API refuses every writer but the file on the source the file seeds, so a file edit never competes with anybody’s. Make API and console edits in a source of their own.

Render the file from something that already counts and the bump is free. The Helm chart stamps .Release.Revision as the version of the deployment: it renders, so every helm upgrade that changed it lands when the pods roll.

A deployment managed entirely through the API names no deployment document. A clustered node with no providers starts fine and waits to be told what it is. Without a cluster it refuses, because there is nowhere the configuration could arrive from later.

For Terraform specifically, the resources are already written. See Manage the deployment with Terraform, which expresses this page’s ownership model as HCL. For a Kubernetes operator reconciling custom resources, see the operator/ module.