Inference through a proxy
The agent needs a model and must not hold the credential that pays for it: a credential in the sandbox is one a prompt injection can send somewhere, and one with no spending cap. So inference goes through a proxy that holds the provider credential. The agent gets the proxy's URL and a token for this run. The proxy checks the token, adds the real credential, forwards the request and counts what the run uses; when the run is ended its token admits nothing more.
The proxy is either
praxis-credential-broker,
which its operator runs and which the agent's runner has to reach (how to
run one is in its README), or, for a caller with only a provider's API
key, one that run serves itself (below). The
agent sees the same thing either way: an address and a token for the run.
The README sums up what each needs.
tests/inference.rs exercises registration with a mock proxy. Real-agent
deployment evidence is reported in the README.
This page describes the client protocol, not the broker's current deployment.
What run does
The proxy is the [inference] table of the binary's configuration.
url is its root, http or https; without it there is no proxy and the
rest of the table is refused. anthropic-url and openai-url say
where it serves each API, by default URL/anthropic and URL/v1:
Claude Code is given the first and opencode the second. Vibe uses
URL/mistral/v1/chat/completions, with the same run token; its route is
not selected by openai-url. register says
how the run gets its token, and has no default. A proxy without it
is refused, and so is a key that belongs to another mode:
github-oidcasks GitHub for the job's identity token, made out toaudience, and sends it as the bearer ofPOST /v1/runs. The job needsid-token: write, or an earlier one with it registers:agentic-job registerwrites the run token to its step's output, andrungiven it inAGENTIC_JOB_INFERENCE_RUN_TOKENtakes the run over onceGET /v1/runs/selfsays it is active, and counts and ends it as its own (the run token). The reusable workflow does that, from a job ofagentic-job.yml, so the broker'scalled_workflowsentries admit it as before. This is the only identity token the binary knows how to get; another forge needs another proof. Neither request takes the job'sHTTP_PROXY, since a proxy would see the tokens. On a host that reaches the CI system only through a proxy,identity-proxynames an http or https CONNECT proxy for the identity-token request alone: it is refused unless that request's URL is https, so the proxy relays TLS it cannot read.POST /v1/runsstill goes directly.plainsends a name and no proof: below. Either of these two can haverunstand in front of the broker:front.token-filereads a token from the absolute pathtoken-file. There is no run API then: nothing counts the run's requests and nothing ends it, so a cap on requests is refused.api-keyhas no broker:urlis the provider's API root,providersays which API, andrunserves the proxy itself (below).
The reusable workflow passes github-oidc unless its caller names
another (workflow.md).
The first two speak praxis-credential-broker's run API; which jobs may
register is the broker's
run-token policy,
whose default audience is praxis-credential-broker. run clones
the target first and registers second, so a clone that fails leaves no
registration behind. It expects 201 with a token and a usage
record. A try that gets no answer, a 429 or a 5xx is repeated, four
tries three seconds apart, and stops if the run is told to stop; any
other answer fails at once. A 404 is such a failure, not a proxy that
needs no token. A run that cannot register never started
(exit state 4). run warns if the run
expires at the proxy before the agent's timeout.
The token stays in memory and goes into one file of the sandbox user's
(layout.md). While the agent works run reads GET /v1/runs/self every five seconds; the record's requests plus
unmetered is the count the request cap reads. A count that cannot be
fetched leaves the last one for five failed polls. After six consecutive
failures the count becomes unavailable and a session with max-requests
stops; a session without that optional cap continues. Each poll takes at
most 30 seconds followed by a five-second wait, so an outage is detected
within 210 seconds of the last successful poll (plus scheduling delay).
This prevents missing accounting from silently disabling the request cap;
it is not a broker-side hard spending limit.
Whatever way the run ends, run then sends DELETE /v1/runs/self,
with the same retries. A 401 means the proxy no longer knows the token,
which ends it without a usage record. If the run cannot be ended its
token may still be live, and nothing it produced is left to upload.
Under token-file there is nothing to end and the upload goes ahead.
All of these requests go straight to the proxy, ignoring HTTP_PROXY,
and follow no redirect.
The agent's own requests go through the egress proxy unless
egress.direct opens the broker. The proxy resolves names with the
host's resolver, but the rules for its uid refuse it DNS to any but the
configured resolvers, and loopback, private, shared (the tailnet) and
link-local addresses: PROXY_DENIED_V4 and PROXY_DENIED_V6 in
crates/secure-host/src/network.rs. A broker named by an in-cluster or
tailnet name that the proxy would have to reach therefore gets a 502,
though root on the host reaches it
(#1810).
Give the broker by its tailnet IPv4 address, so that egress.direct
opens it, or pass that address as inference-address
(workflow.md).
The host and port of url, anthropic-url and openai-url
(host:port) are redacted from the run's logs and files as its secrets
are, except under api-key, whose URL is the provider's public one. A
URL that names no port is written without one, and is not matched:
give the gateway's port explicitly to have it redacted. They are no credential, but the address of a
gateway on a private network, and an agent's error names it: Claude
Code's 529 message did, in a public job log (#1860). As with every
redacted string, a published file that still holds one after the
redaction, such as a patch, stops the upload.
Registering without proof
plain is for a client with no identity token. The request is POST /v1/runs with no Authorization, no body, and the header x-run-id: NAME, 1 to 128 letters, digits, ., _ and -. The broker refuses a
slash or a colon, so run joins the run's identifier and attempt from
--meta with a hyphen, and refuses a name that does not fit before
sending it. The reply is 201 {"token": "praxis-run-...", "usage": {...}} whose record says "proof": "none"; run does not read that
field.
The broker registers a run this way only if its operator's policy file
has an unproven section. That is absent by default. A broker without it
answers 401; run then says the policy must
admit unproven runs.
A name stays taken until a day after its run expires, and asking for it
again is a 409, which run does not retry. Within one registration, a
try that got no answer may have taken the name, so the next asks for
NAME.retry1; the run the lost reply registered is left at the broker,
since nobody holds its token. Starting run again with the same
--meta asks for the same name and fails. The other refusals: 429 and
503 are retried as above, which will not outlast a quota's window; 400,
and 403 (the request carried Origin or Sec-Fetch-Site, which run
does not set), fail at once with the status and the start of the
broker's text.
Without front, the per-run cap under plain is
a convention. The sandbox user must
reach the proxy to work, so the agent can register a run of its own and
get a fresh token with a fresh cap, and the session's request cap reads
only the run the job registered. What still bounds spending is the
broker's cap on all runs together, its quota max_registrations of
unproven registrations per window (an hour by default), shared by all
callers, the session's timeout and the end of the job. With front, the
agent cannot reach the broker, so only the job's own processes can
register: plain is then safe for a trusted caller whose broker listener
only job runners can reach, and OIDC registration is optional rather than
required.
Fronting the broker
front = true in [inference], for github-oidc and plain, is off by
default. With it, run serves the agent the loopback proxy of
api-key, with the broker as its upstream: the
same request filter, content-block allowlist, credential stripping and
hard 403 past max-requests, counted at admission. The agent is given
that proxy's address, with the broker's Anthropic, OpenAI and Mistral
routes under /anthropic, /openai and /mistral/v1, and a token made
for the run. run adds the run token itself, so the agent never holds it.
The broker's run API is not forwarded.
The sandbox user must not reach the broker, by either of its two ways out:
- Around the egress proxy.
agentic-job configdoes not defaultegress.directto the broker, and the check refuses anegress.directentry that opens any of its URLs, as well asegress.proxy = false. - Through the egress proxy.
frontneeds the Rust proxy (egress.rust-proxy, the default):sandbox setupstarts it with--refusefor each of the broker's hosts and ports, and it refuses the sandbox user any request or CONNECT there, by any method and whatever its policy says. It refuses the broker's hosts at every port, by name or an address in any spelling, and every connection it would open to one of their addresses: another name for the broker, or one that resolves to it only later, is refused too. Its 403 says so inX-Egress-Denied. Setup andrunalso refuse an egress policy that names the broker's host in a[[write]]rule or under an[egress] allowdomain.frontdoes not go withegress.write-policy, under whichrun's own requests to a broker on port 80 or 443 would need such a rule. A broker inegress.directneeds none: the write policy lets the runner's user reach it at its address and port.
run resolves the broker's names once, when it fronts the run, and its
proxy then connects to those addresses alone. Before the agent starts it
checks that none of those addresses is on loopback or in
egress.direct, whether it answers now or not, and probes that:
- the sandbox user cannot open a TCP connection to any of them that
runreaches; - the egress proxy refuses a GET and a POST of
/v1/runs, and a CONNECT, to each of the broker's names and addresses, with its own 403. With podman, a container of[sandbox.check] container-image, as a subordinate uid, is refused the GET and POST too; - no process the sandbox user can read holds the run token.
The controls are that the broker resolved, that run reaches it and
that the sandbox user reaches run's proxy. Any failure stops the run.
front needs a sandbox user: --runner-home refuses it.
The request cap reads the higher of the broker's count and the proxy's.
Ending the run closes the proxy's port first, then ends the run at the
broker. Under front the proxy listens on loopback port 3130, the same
for every run, and Claude Code's managed settings pin
ANTHROPIC_BASE_URL to it: a target repository's .claude/settings.json
cannot point Claude Code elsewhere. A run whose port is taken stops.
Which workflows a broker admits
Under github-oidc the agent cannot register a run: the sandbox user
cannot ask for the identity token (sandbox.md). Which
jobs' tokens the broker accepts is its operator's policy, and
agentic-job fixes none of it. The broker supports two admission forms:
any_workflow: any workflow, narrowed by owner or repository id, orany: true.called_workflows: a reusable workflow called from another repository, named by its file at a ref or at the commit injob_workflow_sha, with the calling repositories by id. With callers pinning a commit the entry is the sha form, and has to be updated whenever the pin moves.
A caller that writes the agent job itself
(the pieces),
as example-compose.yml
does, runs it in its own workflow, not a called one: the token's
job_workflow_ref names the caller's file, so only the first form,
narrowed to that repository's id, admits it. That entry is the
permissive one below: any workflow of the repository that can ask for
an identity token can register a run. The shipped dispatch.yml and
the other examples call agentic-job.yml, which called_workflows
admits; composing the agent job is an opt-in that widens admission
until the broker can name a caller's own workflow file.
Under a permissive entry, an agent that holds a GitHub credential able to push a workflow to an admitted repository could start a job of its own that registers a run. What an operator turns on for this workflow is linked from workflow.md.
How the default was chosen
The workflow defaults to github-oidc because network reachability alone
does not bind spending to one run. Under plain without
front, the agent can register another run. The binary has no default registration mode; non-GitHub
callers must choose one explicitly.
A provider API key
The reusable workflow refuses this mode for now, GitHub's billing
included: its agent job runs the agent as the runner's user, which could
read the key from run, and run --runner-home refuses api-key until
the host keeps the runner's processes from reading each other
(workflow.md). What follows is what the binary
does for a caller that composes its own agent job with a sandbox user.
register = "api-key" is for a caller with a provider's API key and no
broker. provider is anthropic, for Claude Code, or openai, for
opencode at an OpenAI-compatible API with a bearer key (opencode's
built-in configuration uses the Responses API); url is the root the
agent's client would be given, such as https://api.anthropic.com or
https://api.openai.com/v1, and must be https (plain http only to
loopback). github is GitHub's billing, below. anthropic-url and openai-url are refused, and so is an
agent the provider's API does not serve: Vibe, and a child agent.
Threat detection (detect) needs a registered run, and the key never
reaches its host, so enabled detection fails closed in this mode and blocks
apply. The caller must unset AGENTIC_JOB_DETECT_MODEL to use API-key
inference without detection. run reads the key as
AGENTIC_JOB_INFERENCE_KEY, which only the step that runs it should get.
agentic-job.yml, review.yml and dispatch.yml declare the secret
INFERENCE_API_KEY, so that
callers that pass it still parse, and give it to no job.
run reads the key from its environment and asks the provider for its
list of models with it, retried as a registration is. A missing key, one
that is not a key, and one the provider refuses (401 or 403) stop the
run before the agent starts, with the variable's name and the provider's
answer, the key cut out of it. agentic-job check-key --config FILE
makes the same check, so that a step before sandbox setup can fail on
a bad key in seconds rather than after the minutes of setup; it is no
control, as run checks again, and the reusable workflow does not run
it while it refuses the mode. Then run listens on a loopback port and
gives the agent that address and a token it made for the run, in the
same places a broker's would go. The sandbox user reaches loopback
without the egress proxy. For each request the proxy:
- refuses it (401) unless it carries the run's token, in
x-run-token, as a bearer, or inx-api-key, and once the run has ended; - refuses (403) all but the model requests (
POST /v1/messagesand/v1/messages/count_tokens;POST /responsesand/chat/completions) and the list of models. A batch would run many model requests that the count sees as one, and an uploaded file stays at the provider after the run; - refuses (403) a model request that offers the model a tool the
provider runs, such as web search or fetch, code execution or an MCP
server, or that names Anthropic's
mcp_serversorcontainer,web_search_optionsor a storedprompt. The provider would reach the network for the agent, outside the egress filter, with whatever the agent puts in the request. Only tools the agent runs itself pass:functionandcustomones, and Anthropic's own client tools; - refuses (403) a model request that names OpenRouter's
pluginsor fallbackmodels, or a model whose name says it searches the web with every request (search,sonar,:online). Names are the provider's choice, so this list cannot be complete: a caller whose provider serves other such models should not use this mode with them; - refuses (403) a model request whose content the provider would fetch:
an image, document or file named by URL or by a file id at the
provider (the run uploads none). Content is read by its API's shapes at
every depth, such as a tool's result, and a content block of a type the
proxy does not know is refused, so a new provider feature stays closed
until it is named. Inline content (text, base64,
data:URLs) passes. What is sent is the JSON that was checked, written out again, so the provider cannot read the request otherwise than the check did; - refuses (403) a model request past
max-requests, counted when it is admitted, so that requests sent at once cannot pass the cap together; - drops every credential the agent sent, adds the key, and passes the
answer back as it streams, with its content type, request id and
retry-afterand no other header of the provider's. A refusal of the provider's is read whole first, under 64 KiB and not compressed, and the key cut out of it, as some providers quote the key they refuse; any other refusal is answered 502 instead.
It counts every model request it forwards, and that count is what
max-requests reads, as it reads a broker's. Ending the run closes the
port and cuts off every connection, so a request whose body was still
arriving is not sent. Before the agent starts, run checks that no process the sandbox
user can read holds the key, beside the probes
of the run token. The key is redacted from the run's logs and files, as
the run token is.
What this does not give: the key is long-lived and the provider's own
limits are its only spending cap. The run's request cap, budget and
timeout bound one run, as with a broker, but nothing ends the key when
the job ends. A deployment that wants no long-lived key in its
repositories uses a broker. gh-aw does the same with a proxy container
on loopback beside the agent; here the proxy is part of run, so
nothing else is started.
GitHub billing
provider = "github" is the same proxy with a GitHub token as the key,
so GitHub bills the run's inference as gh-aw's
Copilot engine has
it billed. In the reusable workflow inference-provider: github
implies register = "api-key" and the root
https://api.githubcopilot.com, so the agent job refuses it for now,
as above. Another root, such as GitHub Enterprise's, takes
inference-url with inference-register: api-key, so that the URL of a
broker left in a caller never gets the token (Enterprise is untried).
The API is GitHub's Copilot API, the one gh-aw's
API proxy
forwards to, at one root for both agents: Claude Code's POST /v1/messages and opencode's POST /responses and /chat/completions,
with every check of the section above. The proxy sends the token as a
bearer with gh-aw's Copilot-Integration-Id, agentic-workflows,
in place of any the agent sends: the API refuses a job token without an
integration id its App allows (HTTP 400), and the id also decides which
models it offers. The key is checked against GET /models. model has
to be a name that list offers: they are GitHub's, such as
claude-sonnet-4.5, not the provider's. Claude Code's background
requests name its own small model, which GitHub may not know under that
name. opencode's built-in configuration uses the Responses API, which
GitHub serves for some of its models only. GitHub Models
(models.github.ai, models: read) is not used: it serves only OpenAI's
chat completions, and no Anthropic model.
GitHub takes two tokens for this, as it does for gh-aw:
- a fine-grained personal access token with the Copilot Requests permission, billed to its owner's Copilot. This is the one an agent job of your own passes; the reusable workflow refuses the mode for now, above.
- the job's own token where the job has
copilot-requests: write, billed to the organization if its Copilot policy allows Copilot CLI billed to it (GitHub's docs). The reusable workflow does not ask for that permission: every caller would have to grant it to every call, and the agent's read token, the job's, would then pay for inference. An agent job of the caller's own grants it and passesinference-api-key: ${{ github.token }}torun, withgithub-tokenempty.
run refuses a key that is also the agent's GitHub token, GH_TOKEN:
the agent would hold what pays, outside the run's caps. It cannot tell
whether another token the agent gets has Copilot access; in a job that
grants copilot-requests: write, the job token must not be the agent's. A token GitHub
refuses stops the run before the agent starts with GitHub's reason, such
as a token without the Copilot Requests permission, and says which
tokens it takes.