Skip to content

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.

Run this command:

Terminal window
$ go run ./tools/loadtest upstream -listen 127.0.0.1:9101

The mock provider returns OpenAI-compatible chat completions with token usage. This is enough to inspect metering.

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 model

There 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:

Terminal window
$ go build -o pistra ./cmd/pistra
$ ./pistra check -config config.yaml
config ok config.yaml + deployment.yaml: 1 provider, 1 profile, 1 budget, 1 access rule, guardrails with 1 detector and 1 rule
cluster 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 only
tls -- unset: the front door is plain HTTP
public_url -- unset: resource metadata, agent cards and the MCP registry are not served
provider ok demo http://127.0.0.1:9101: HTTP 200 in 1ms, with its credential
guardrails -- vectorscan not found: patterns run in pure Go

A 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:

Terminal window
$ ./pistra -config config.yaml

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.

Terminal window
$ 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:

Terminal window
$ KEY=pistra_Yl50M786DPKCUWDWg49e1xzov54XbKL5pfJ2OlsU1XQ1OZwC1

There 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.

Run this command:

Terminal window
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8484/v1/chat/completions \
-d '{"model":"fast","messages":[{"role":"user","content":"hi"}]}'
401

auth.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.

Run this command:

Terminal window
$ 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.

Run this command:

Terminal window
$ 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.

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.