Manage the configuration through the API
A node’s file configures one process. What the deployment is, the providers, credentials, budgets, profiles and guardrails, lives in the raft log and is written through the admin API. This guide covers reading and writing that stored configuration. 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.
Sources: one writer, one document
Section titled “Sources: one writer, one document”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 themGET /admin/v1/config/sources/{source} read one, with its revision in the ETagPUT /admin/v1/config/sources/{source} write oneDELETE /admin/v1/config/sources/{source} remove oneThe gateway serves all of them composed together. The name is yours to
choose, such as platform, terraform or team-a. A cluster starts
with none, and its data plane answers 503 until the first is written.
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 upThat 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.
Who wrote it
Section titled “Who wrote it”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:kirto-gate: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. One
last written by a version that did not record writers has no
written_by.
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.)
Write one
Section titled “Write one”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. kirto-gate login caches one and kirto-gate 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:
# 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.yamlUse the following example:
# 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.yamlThere 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.
Credentials are references, never values
Section titled “Credentials are references, never values”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. A KIRTO_GATE_* name skips the environment: those variables
configure the node, the cluster secret among them, and a document never
reads one. A source that references ${KIRTO_GATE_*} directly is refused
with a 400, whatever grant its writer holds. 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.
What a refusal means
Section titled “What a refusal means”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.
Ask before writing
Section titled “Ask before writing”Every write takes ?dry_run=true. The request is decided and validated
the whole way, the same 403, 409, 412 and 422 come back where they
would have, and nothing is stored on a 200:
curl -sS -X PUT "https://gw.example/admin/v1/config/sources/team-a?dry_run=true" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \ -H "If-Match: $etag" --data-binary @team-a.yamlThe 200 carries dry_run: true and the revision the source is at now.
A dry run leaves no audit record, since nothing happened. It is a read
that asks the write’s questions, and the answer holds until somebody
writes: a document that would land now can meet a 412 a moment later,
which is what the precondition on the real write is for.
A dry run is decided as the write, so only a caller who may write can make one. A caller who may only read the source asks the check instead. It runs the same parse, composition and build and stores nothing:
curl -sS -X POST "https://gw.example/admin/v1/config/sources/team-a/check" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \ --data-binary @team-a.yamlThe check is the grant for an agent or a CI job that proposes a
document for a person to apply. It answers about the document rather
than the caller. The per-object write decisions are left to whoever
applies it, so a document that checks can still meet a 403 on the
write. The secrets, variables and credentials the document reaches are
decided as they are on a write, because the build resolves them. The
200 names the revision the document was checked against. Pass that
revision as If-Match on the write, and a source somebody wrote in the
meantime refuses the write instead of landing a document nobody
checked. kirto-gate config check -source team-a team-a.yaml is the same
request from the command line.
Has every node built it
Section titled “Has every node built it”A write that lands is agreed by every node, and each of them then
builds it against its own environment and artifacts. The written
response says the revision landed and whether the node that took the
write could build it. Whether every other node did is on the members
listing, where each node reports the store sequence its served
configuration was built from:
curl -sS https://gw.example/admin/v1/cluster -H "Authorization: Bearer $TOKEN" \ | jq '.members[] | {id, served, rebuild_error}'A write at revision N is rolled out when every member’s served is at
least N. A member below it with a rebuild_error is one that agreed
on the document and could not build it, and is serving the previous
configuration until its environment is fixed or the document is
written again. A member below it with no error has not reported yet.
Delete
Section titled “Delete”A delete is validated the same way, because a source can be the one holding what the others depend on:
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.
Edit one thing
Section titled “Edit one thing”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:
# 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 $'base_url: https://azure.example/openai\ncredential: azure-key\n'
# Read it back, comments included. Kirto-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:
# 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. With
none, the object lands in api, created on that write, where the
policy lets you create there, and is refused with a 403 where it does
not. The console writes the same way, so a fresh cluster’s first
provider is one form, and api is the source it made.
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. 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, model_artifacts, auth,
max_body_bytes, default_provider, on_fidelity_loss, public_url.
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
Kirto-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 document has a schema
Section titled “The document has a schema”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:
curl -sS https://gw.example/admin/v1/config/schema \ -H "Authorization: Bearer $TOKEN" -o kirto-gate-config.schema.jsonPoint 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=./kirto-gate-config.schema.jsonproviders: - name: openai base_url: https://api.openai.com credential: openai-keyThe same schema is committed as api/config.schema.json, printed by
kirto-gate -config-schema, and referenced from the OpenAPI spec’s
request bodies, so generated clients see it too. The configuration
file a node runs from has its own, api/node.schema.json from
kirto-gate -node-schema, which carries this one as the shape of an
inline deployment.
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.
Roll back
Section titled “Roll back”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:
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:
curl -sS "https://gw.example/admin/v1/config/sources/team-a?revision=41" \ -H "Authorization: Bearer $TOKEN" -o before.yamland put it back the way any document is put:
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.yamlThere is no rollback endpoint. kirto-gate config rollback -source team-a -to 41 does the read and the write above, and 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.
Say who may change what
Section titled “Say who may change what”A write is decided on the source it lands in, then once per object it
adds, removes or changes. A source is one writer’s declared set, so
writing into one is decided on the document itself, Resource::"config/team-a",
whatever the objects are named. Each object is a Cedar resource of its
kind under two containers: its kind, Resource::"profiles", and the
source that declares it. A team therefore needs a grant on its
document, and a kind-scoped grant narrows what may go in it:
@id("team-a-writes-its-own-document")permit(principal in Role::"team-a", action, resource in Resource::"config/team-a");
@id("team-a-declares-only-its-profiles")forbid(principal in Role::"team-a", action in Action::"write", resource)unless { resource in Resource::"profiles" && resource.id like "team-a-*" };
@id("platform-team-owns-platform")permit(principal in Role::"platform", action, resource in Resource::"config/platform");
@id("everyone-may-read")permit(principal, action in Action::"read", resource in Resource::"config");in on a source covers the document and everything in it. A document
PUT that adds a profile and a budget is one decision on the source and
one per object, and a refusal names what was refused:
403 not authorized to putConfigSource budgets/dailyThe trail records one admin.authz denial per refused object, so “who
tried to change the daily budget” is a question it answers. Every
resource carries resource.source, the source it sits in, so one
policy covers a family of documents: the operator writes every
k8s-* source under when { resource.source like "k8s-*" }. See
Restrict what an admin caller can do
for roles and issuers.
Say what a document may reach
Section titled “Say what a document may reach”A document points at things it does not carry. api_key: ${secret:NAME}
and ${VAR} are resolved by the node, and credential: platform-entra
names a credential another source declared. Whoever writes the document
chooses the base_url those values are sent to, so what a writer may
declare and what it may reach are two questions.
Each distinct thing a document reaches is decided once, whichever field names it:
| Written | Action | Resource |
|---|---|---|
${secret:NAME}, ${file:NAME} |
useSecret |
secrets/NAME |
${VAR} |
useEnv |
env/VAR |
${secret:NAME} the node answers from its environment |
useSecret and useEnv |
secrets/NAME, env/NAME |
credential: NAME, declared elsewhere |
useCredential |
credentials/NAME |
A team that keeps its own secrets under a prefix is one line, and the platform’s keys stay the platform’s:
@id("team-a-uses-its-own-secrets")permit(principal in Role::"team-a", action == Action::"useSecret", resource in Resource::"secrets")when { resource.id like "team-a.*" };
@id("the-node-environment-is-the-platform's")permit(principal in Role::"platform", action == Action::"useEnv", resource);The refusal names what the document asked for, and nothing is written:
403 not authorized to use secrets/platform.openai, env/PLATFORM_KEYA credential the same document declares is not decided here: declaring it was a write to an object, decided as one, and the secret it carries is decided like any other reference. Naming a credential is a question only when another document declares it.
A write is decided on the references inside the objects it adds or
changes, and every reference in such an object counts, not only the
ones the write adds. A grant governs where a value is sent, and the
object holding a reference is where it goes. A provider whose
base_url moves sends its unchanged ${secret:KEY} somewhere new, so
that write asks for the key. A provider that signs with a credential
the same document declares asks for the credential’s references too,
even where the credential itself did not change. An object the write
leaves alone is not decided. It sends what it sent before whether or
not the write lands, so a writer who edits one profile in a document
holding the platform’s key needs no grant for the key.
Operator-delivered secrets are named <namespace>.<name>, which is what
makes a per-namespace grant a pattern rather than a list.
Machine writers get a source too
Section titled “Machine writers get a source too”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.
kirto-gate config apply is the plain form: a file written as one source on
every pass of whatever runs it. It reads the writer the cluster
recorded on the source’s last revision, and where that is somebody
else, it refuses and names the objects they changed since its own last
retained write, the way kubectl apply conflicts on a field another
manager owns. -force writes over them. kirto-gate config diff shows
what an apply would change, and -dry-run sends the write with
?dry_run=true.
The Terraform provider in terraform-provider-kirto/ 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:
$ kirto-gate-operator \ -admin-url https://kirto-gate-admin.kirto-gate.svc:9900 \ -admin-token-file /var/run/secrets/tokens/admin \ -secrets-namespace kirto-gate-system \ -secrets-name kirto-gate-provider-keysThree things follow from a source per namespace:
- A namespace is a deployment half only. Node-level keys such as
listen,clusterandadmincannot travel in a source, and no namespaced kind can express them.ClusterConfigcarries the deployment-wide blocks and is cluster-scoped for that reason. - Its sources all carry the
k8s-prefix. One per namespace, so an operator writes beside a person’s or a Terraform run’s source and never over it. - Policy can name it.
resource.source like "k8s-*"grants the operator its sources and everything in them and nothing else.
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.
The file configures the process, and nothing else
Section titled “The file configures the process, and nothing else”A clustered node’s file holds what configures this process: listeners,
TLS, the raft identity, the trusted issuers, the admin policy. It holds
no deployment and refuses a deployment key, because the cluster is
where that node’s deployment lives and a document in the file would be
a second statement of it. An edit to the file is a restart; an edit to
the deployment is a source write, and it lands on every node without
one.
A cluster starts with nothing stored. GET /admin/v1/config lists the
sources the served configuration was composed from, empty until the
first write, and the data plane answers 503 with Retry-After until
then. The first write is the same PUT as every later one, from
whoever owns that part of the deployment: the Helm release, a
config-management run, the operator, Terraform, or you with curl.
kirto-gate config apply -source NAME FILE is that PUT with the
read-compare-write done for you: it creates the source, replaces it at
the revision it read, and writes nothing when the stored document is
already the file’s bytes.
A node with no cluster, the inspector beside somebody else’s gateway,
keeps deployment in its file, since it has no store and nowhere else
to read a policy from. That is the one shape where the file says what
the deployment is.
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.