Govern your first provider
Put pistra in front of a provider and configure a virtual key, token budget, CEL access rule, and card-number guardrail.
This tutorial takes about ten minutes. You need Go and curl. The
repository includes a mock provider, so you do not need a provider key.
1. Start a mock provider
Section titled “1. Start a mock provider”Run this command:
$ go run ./tools/loadtest upstream -listen 127.0.0.1:9101The mock provider returns OpenAI-compatible chat completions with token usage. This is enough to inspect metering.
2. Write the config
Section titled “2. Write the config”A node runs from two documents. The node file configures this process
and names the second document. Save this as config.yaml:
listen: "127.0.0.1:8484"deployment: deployment.yaml
# Keys, budget spend and certificates live in a raft log, so every# gateway is a cluster; this is a cluster of one. `bootstrap` is spelled# out rather than inferred from a missing block, because a node that# starts a cluster when it meant to join one becomes a second cluster.cluster: bootstrap: true node_id: tutorial raft_addr: 127.0.0.1:7000 data_dir: ./pistra-data secret: tutorial-secret-not-for-real-use
# The control-plane API, on its own listener. This is where keys are# minted; the data plane never mints anything. On loopback with no# issuer it needs no credential and no policy. See below.admin: listen: "127.0.0.1:8485"The deployment document is what the deployment is: providers,
budgets, profiles, rules. Every node agrees on it, and the cluster
stores it. Save this as deployment.yaml beside the first:
auth: virtual_keys: true
providers: - name: demo dialect: openai base_url: http://127.0.0.1:9101 api_key: sk-real-provider-key models: ["gpt-*"] model_aliases: {fast: gpt-4o-mini}
budgets: - name: team-daily limit: 2000000 window: day cost: {type: TotalToken}
# What a key may do. A key carries who it is; a profile carries what it# can do, and the key names one. Editing the profile changes it for# everyone holding it, and `GET /admin/v1/profiles` answers "what can# this key do?" without anyone reading this file.profiles: - name: ml-team budget: team-daily
access_rules: - name: embeddings-off at: [llm] condition: 'route == "embeddings"' action: deny
guardrails: detectors: - type: pii entities: [CREDIT_CARD] rules: - name: block-cards action: deny message: card numbers must not be sent to the modelThere is no admin secret in the node file, and none to generate. A caller on the network presents a JWT from an issuer you configure. A caller on loopback is “whoever holds this host”, which needs no credential and nothing for a policy to sort. You are about to use the loopback door, and it is also how you get back in the day the identity provider breaks.
Bind the admin listener to anything but loopback and pistra refuses to
start without an issuer under admin.issuers, see
Restrict what an admin caller can do.
Run pistra check before you start the gateway. It reads both files
as the gateway will and checks each configured dependency:
$ go build -o pistra ./cmd/pistra$ ./pistra check -config config.yamlconfig ok config.yaml + deployment.yaml: 1 provider, 1 profile, 1 budget, 1 access rule, guardrails with 1 detector and 1 rulecluster ok node tutorial: cluster of one, data_dir ./pistra-data (log not yet created)admin ok listening on 127.0.0.1:8485, local callers onlytls -- unset: the front door is plain HTTPpublic_url -- unset: resource metadata, agent cards and the MCP registry are not servedprovider ok demo http://127.0.0.1:9101: HTTP 200 in 1ms, with its credentialguardrails -- vectorscan not found: patterns run in pure GoA FAIL line is something the gateway would refuse or a dependency
that did not answer. A -- line is a choice the file made by omission,
reported rather than refused. The last line names the native library
the pattern detector uses when it is installed. Without it the same
detector runs in pure Go, slower and otherwise the same. Then run it:
$ ./pistra -config config.yaml3. Mint a key
Section titled “3. Mint a key”Mint keys through the admin API. The gateway stores the token’s SHA-256 and returns its plaintext only once. The Raft log preserves it across restarts.
$ curl -s -X POST localhost:8485/admin/v1/keys \ -H 'Content-Type: application/json' \ -d '{"name":"dev","metadata":{"team":"ml"},"profile":"ml-team"}'The response looks like this:
{"name":"dev","token":"pistra_Yl50M786DPKCUWDWg49e1xzov54XbKL5pfJ2OlsU1XQ1OZwC1", "created_at":"2026-08-22T10:11:25.451701Z"}Save the token now because you cannot retrieve it again. Set $KEY for
the remaining commands:
$ KEY=pistra_Yl50M786DPKCUWDWg49e1xzov54XbKL5pfJ2OlsU1XQ1OZwC1There is no way to write a key into config.yaml. Minting is the only
path, so created_at, expiry and revocation mean something, and one
key cannot have two homes.
4. Try it without a key
Section titled “4. Try it without a key”Run this command:
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8484/v1/chat/completions \ -d '{"model":"fast","messages":[{"role":"user","content":"hi"}]}'401auth.virtual_keys: true means the gateway issues the credentials now.
Whatever a client sends is checked against the keys you minted, and
never forwarded upstream. The other source an auth block can name is
an identity provider’s tokens, for agents that already have an
identity. See
Authenticate agents with your identity provider.
5. Use the key
Section titled “5. Use the key”Run this command:
$ curl -s localhost:8484/v1/chat/completions \ -H "Authorization: Bearer $KEY" \ -d '{"model":"fast","messages":[{"role":"user","content":"hi"}]}'The response looks like this:
{"id":"chatcmpl-load","object":"chat.completion","created":1700000000,"model":"gpt-4o", "choices":[{"index":0,"message":{"role":"assistant","content":"Hello there, how can I help you today?"}, "finish_reason":"stop"}],"usage":{"prompt_tokens":21,"completion_tokens":10,"total_tokens":31}}Three things happened that the response does not show. The model fast
was rewritten to gpt-4o-mini in the request body, and only that field
was touched. Authorization: Bearer $KEY was replaced with
the real provider credential. Finally, 31 tokens were settled against
team-daily. They were reserved before the request went out, then
corrected to the actual usage when the response came back.
6. Watch the guardrail refuse
Section titled “6. Watch the guardrail refuse”Run this command:
$ curl -s localhost:8484/v1/chat/completions \ -H "Authorization: Bearer $KEY" \ -d '{"model":"fast","messages":[{"role":"user","content":"charge 4012888888881881"}]}'The response looks like this:
{"error":{"code":"guardrail_denied","message":"card numbers must not be sent to the model","type":"pistra_error"}}The provider never received it. Detection ran in-process, with no service call and no sidecar. The refusal came back in the client’s own dialect, so an OpenAI SDK raises an ordinary API error rather than something it cannot parse.
The detector says what to look for and the rule says what to do about
it. A rule with no when fires on whatever its detectors found, so a
policy that acts on everything it detects is a detector and an action.
When one rule has to tell findings apart, when is a CEL condition
over them, '"CREDIT_CARD" in entities' for this one, and the
second tutorial writes one.
The detector read the request: the user turn, the system prompt and
any tool arguments. It did not read the model’s output. Response
scanning is opt-in with apply_to: [output], because inspecting a
response delays a stream.
What you built
Section titled “What you built”You built a gateway that holds the provider credential so clients never do, meters what they spend before they spend it, and refuses content that should not reach a model. It took under sixty lines of configuration, plus one API call for the key.
- Redact PII from model output, the same detectors pointed at responses, the harder direction.
- Run a detector in shadow mode, how to introduce one without risking false denials.
- Mint, rotate and revoke a virtual key, what to do with the key you just minted when it has to change hands, and the four different ways one stops working.
- Configuration reference, every key you did not use here.