The practical version comes first. Open the technical section when you want implementation detail, exact fixes, or security notes.
Cloud changes arrive without you doing anything. Self-hosted changes arrive when you upgrade — which is why one half of this page carries a version number and the other carries a date.
If you sign in to Claude Code with your own Pro or Max plan, `passcontrol configure claude-code --subscription --write` brings PassControl's stop, its live board and your usage window to those sessions. Your login stays between Claude Code and Anthropic: PassControl never sees it and never sits in the path.
One stop, through Claude Code's hooks
Before each prompt, each tool and the next model call in a turn, Claude Code asks your sidecar. A suspended agent, the kill switch or a stopped session ends the turn with the reason. It runs inside Claude Code, so it is a guard rail for your own sessions, not a boundary. If the sidecar is not running, Claude Code carries on.
Every call on the board, without your identity
Claude Code's own telemetry sends each model call to the sidecar, and `passcontrol watch` shows it as SEEN with the model, session, sub-agent, time and what it would have cost on the API. That cost is shown only and never charged to a budget. The email and account ids Claude Code attaches to each event are dropped on arrival.
How much of the window is left
The status line, `watch` and `/passcontrol` show how much of the 5-hour and 7-day allowance is used, as Claude Code reports it. Shown only: Anthropic enforces its own window.
Calls from a Claude subscription, on the agent's page
On a Claude Pro or Max login, the model calls Claude Code reports now also reach the agent's dashboard page, under "Reported by Claude Code". They are marked as what they are: reported by the client, not proxied, no receipt.
Kept apart from proxied calls
Reported calls have a table of their own. They never enter the call record that carries receipts, and their cost is shown as API-equivalent: what the call would have cost on the API. It is never charged to a budget, because the plan is a flat fee. Rows older than 90 days are removed: on Cloud by a daily sweep, and on a self-hosted install the next time that agent reports a call.
Sent once, without your identity
The sidecar uploads in batches, as the agent, and retries with the same event key, so a retry never counts a call twice. Only the model, session, sub-agent, tokens, time and cost are sent: never your email or account ids. A stopped agent's reports are not recorded. `passcontrol sidecar --no-report-calls` keeps them on your machine.
Tool rules for Claude Code on a Claude subscription
On a Claude Pro or Max login, an agent's page now says which tools Claude Code may run on your machine: refuse a tool, hand it to Claude Code's own prompt, or hold it until you say yes from Telegram, Slack, Discord or the Approvals page.
Rules that read like what they stop
A rule names a tool and, where it has one, a condition: a command for Bash (`git push*`), a path for the file tools, a domain for WebFetch. MCP tools are named like `mcp__github__create_issue`. A Bash rule is checked against each command in a chain, and the strictest matching rule wins. Tools no rule names are left to Claude Code's own permissions.
Ask me first, without a silent yes
The tool waits while you are asked, two minutes unless you set otherwise, and runs only after your yes; no answer means it does not run. The question shows the tool's whole input (the command, the file and what would be written to it, the address, or an MCP tool's arguments), and your yes covers exactly that input, once. An input over 16 KB is refused rather than shown in part.
A record without the command
The agent's page lists what the rules decided: the tool, the decision, the rule and how long you took. Never the command, file or address. The rules travel with the agent in the workspace and account exports. Like the stop, the rules work inside Claude Code: a guard rail for your own sessions, not a boundary against a client that skips them.
An offset install keeps its port, and `passcontrol watch` shows whole rows
Two fixes for things that went wrong on an ordinary update. An install made with `setup --port-offset` keeps its dashboard port through `passcontrol update`, and `passcontrol watch` no longer cuts the last character off every row.
An offset install stays where you put it
`passcontrol update` on an install made with `--port-offset N` reset the dashboard port to 3000, which pointed receipts and sign-in at the wrong address. It now keeps 3000 + N, and an install an earlier update moved is put back.
Technical details +
Fixed
`passcontrol watch` lost the last character of every row in some terminals, such as macOS Terminal ("LATENC" for LATENCY). The board now leaves the window's last column empty.
The database has an index under every foreign key, so deleting an account or an agent no longer reads whole tables to find what referred to it.
`passcontrol watch` is a live, full-screen board of the calls your sidecar relays: one row per call with its time, session, sub-agent, model, whether it was boarded or denied, and how long it took, under the agent's budget. When Claude Code or Codex fans work out to sub-agents, you see each one's calls as they happen instead of the bill afterwards.
A refusal says why
A call the gateway refuses shows DENIED with the refusal's code, such as `blocked_suspended` for a stopped session. A provider's own error shows as ERROR with its status. The board tells them apart by the shape of the error body, so a custom endpoint whose errors happen to look like a PassControl code can show as DENIED.
Nothing is written down
The board reads a feed the sidecar serves on its own loopback port, to this machine only. It carries ids, codes and timings, never a prompt or a response, and it lives in memory: the sidecar's journal still records receipt ids and nothing more.
What it does not show
Only calls that go through that sidecar are on it, and the board cannot stop anything. Sessions and sub-agents are the ones the client declares. Restart a sidecar started by an older version before watching it; the board says so if you forget.
A Claude Code plugin that shows this agent's budget and status, and says why its calls are being refused when they are. It answers on your machine without calling the model, so it still works while the gateway is refusing every call.
Install
Run `claude plugin install passcontrol --marketplace Vertias3u/PassControl`, then type `/passcontrol`. It needs the PassControl CLI and Claude Code sending its calls through the sidecar. The plugin is MIT; the rest of PassControl stays BSL 1.1.
When calls are refused
It says which kind of refusal it is, as far as the gateway says: this session was stopped by its owner (other sessions are not), the agent is suspended or the kill switch is on, or a limit is spent. It links to the dashboard page where the owner can lift it.
The right agent
It asks the sidecar Claude Code actually uses, read from Claude Code's own ANTHROPIC_BASE_URL, so an agent of its own on port 8790 is the one reported. If Claude Code's calls do not go through PassControl at all, it says that instead.
An agent built with the SDK can now sign a delegation for a worker it starts: a narrower model scope, a spending cap and an expiry, handed to the worker's own key. The worker exchanges it for a visa and can never call outside that scope, spend past that cap or outlive it, and suspending the agent stops every worker it delegated to.
The parent never holds the worker's key
The worker generates its own key and sends only the public half. `passport.delegate()` signs offline; `PassControl.fromDelegation()` is the worker's client. A worker can delegate once more, narrower again, and no further.
Caps that add up
Each delegation's cap is enforced in the same atomic step as the agent's own budget, so two workers each inside their own cap still cannot together pass the cap of the delegation above them.
Receipts say who acted
A worker's receipt names the worker's key and the delegation it acted under, never the agent's passport, and anyone holding the chain can check the two belong together with `verifyReceiptDelegation`. These receipts are version 3: verifiers from earlier releases refuse them as too new rather than misreading them, so update the SDK or CLI to verify them.
Revocable
See the delegations an agent's workers have used in the new Workers section of its page, and revoke one, or every delegation the agent issued before now, there, with `passcontrol agent revoke-delegation`, or through the control API. A revocation is permanent, and if PassControl cannot confirm a delegation is still valid it refuses the call rather than guess. Both kinds are published on the signed revocation list at /.well-known/passport-revocations, so anyone holding a worker's receipt can check offline whether its delegation had been revoked.
An agent you create in the dashboard, or through the key-import on-ramp, now starts with a spending limit of $20 per UTC day. Past it, its calls are refused until midnight, each with a signed receipt. The amount is filled in on the form and you can change it there or later on the agent's page. Agents you already have are not changed.
Removing it is a choice you make on purpose
Clearing the field does not remove the limit; the form refuses it. To create an agent with no daily limit you tick a box that says so in plain words, and the audit log records that you did. The wording follows Simon Willison's argument that hard spending caps should be the default and leaving them off should be explicit.
Calls that cannot be priced
Under any dollar limit, PassControl refuses a call it cannot price, because it could not hold that call to the limit. That refusal now says which limit caused it and where to remove it. Agents that only call local models or Azure, which PassControl never prices, are created without the limit.
What does not change
Agents created with the CLI or the control API get exactly what they got before. A self-hosted install can change the amount, or turn the default off, with `DEFAULT_AGENT_DAILY_LIMIT_USD` in `.env.docker`. This release adds a database migration: `passcontrol update` applies it for you, and if you pull by hand, run the migrations before restarting the dashboard.
The fleet kill switch and each service's stop (and, on PassControl Cloud, each session's stop) were kept in Redis alone. If Redis lost its data, every one of them was lifted without a word, and the next call went through. Each stop is now also saved in the database, and the gateway rebuilds Redis from it before it decides the first call after a loss.
What a loss looks like now
Nothing, to you: a stop you armed is still armed, on the dashboard and at the gateway. Arming still takes effect at once, because Redis is still what every call checks.
If the database is down when you press Stop
The stop is in force anyway, and the control says it was not saved, with a button to try again. If the database cannot be read while Redis is being rebuilt, the gateway follows the kill switch's existing setting: it lets calls through by default and refuses them with KILL_SWITCH_FAIL_CLOSED=true.
Stops armed before you update
They were only in Redis, so they have no saved copy yet. On PassControl Cloud they are saved for you as part of this release. On a self-hosted install, updating starts the local Redis empty one last time, which clears them: after `passcontrol update`, check the kill switch and your service stops, and arm again any that should still be on.
Cloud
Stop one session, not the whole agent
Each session in an agent's Sessions view now has a Stop button. A stopped session's next call is refused, and Claude Code shows why. The agent's other sessions keep running, and Resume lets the session continue.
What it stops, and what it does not
The stop is keyed on the session id the coding tool declares, so it holds when that session is resumed later, and a brand-new session starts unstopped. To stop everything the agent does, suspend the agent as before. Every stop and resume is in the audit log and sends an alert.
An agent's policy can now cap what one Claude Code or Codex sub-agent may spend in one session. The cap is checked in the same atomic step that holds money for each call, so sub-agents running in parallel cannot pass it together. A sub-agent at its cap is refused with a message saying so; the main agent and the other sub-agents keep running.
A guard rail, and it says so
The session and sub-agent ids come from the coding tool, so a new session or a new sub-agent starts at zero. The cap stops a runaway sub-agent; the agent's own limits remain the hard ones and apply to everything. Set it in the policy editor, in dollars.
Before going back to an older version
Versions before this one do not know the setting and refuse every call for an agent whose policy contains it. Remove it before going back to an older version.
Set an amount in an agent's policy, and when one Claude Code or Codex session of that agent has spent it, your Slack, Discord or Telegram destination gets one message with the amount so far and a link to the agent. It only alerts: nothing is paused or refused. On Cloud the link opens the agent's Sessions view, where Stop ends that one session.
Before going back to an older version
Versions before this one do not know the setting and refuse every call for an agent whose policy contains it. Remove it before going back to an older version.
Cloud
The live demo: your own session, your own Stop
The demo on the home page now gives each visitor a session of their own: a main agent and two sub-agents, run through the real gateway. Stop it, and the gateway refuses that session's next calls with a signed receipt. Everyone else's demo keeps running.
Only yours
The demo used to have one switch for the whole demo account, so one visitor's click blocked everyone. Now the switch stops only your session. Try it in two browsers: stop one, and the other keeps running.
Sub-agents, as declared
The main agent runs two sub-agents, `researcher` and `writer`, shown as a tree with what the gateway decided for each. Their names are declared by the caller and recorded on each receipt. PassControl records them; it does not prove them.
It clears itself
A demo stop lasts ten minutes, or until you resume it. Start a new session and you get one that was never stopped. A stop an owner sets from the dashboard does not expire.
Cloud
The demo hands you its receipt
The live demo on the home page always signed a receipt for the call you ran, then kept it. Now it prints the receipt under the console with two links: one opens it on the verify page, which checks the signature in your browser against our public keys, and one opens a copy with the cost changed, which fails the same check on its signature. Block the demo tenant and run again, and the refusal comes with its own signed receipt.
What it does and does not prove
The receipt proves what the gate decided and what it charged, signed by a key the agent never held. The demo provider is synthesized, so it does not prove a model ran, and the slip says so. A receipt covers the request, not the answer.
Only your own call
The page receives the receipt of the call it just made and nothing else. The gateway looks it up by the id it gave that call, only among the demo agent's own records, and returns the signed receipt alone.
Cloud
Passport links unfurl as the passport
Paste a passport's verification link into Slack, X or Discord and it now shows that passport's own card: its sigil, who issued it, and whether it is valid, suspended, revoked or expired. A README can carry the same status as a badge.
The card says what the page says
Each status uses the verification page's own words, and a key replaced by rotation says so beside Valid. The owner line follows what was proven: a name the operator typed reads as self-declared, never verified. The card prints when it was checked, so an old copy in a chat dates itself.
A badge for your README
Every passport page now ends with a line of Markdown you can paste into a README. The badge shows the passport's status and links back to its page, which is where anyone can check it for themselves.
What it does not say
Neither the card nor the badge is a live authorization. Like the page, they report issuance and revocation, and they say nothing about what the agent may do or spend. A change shows within about five minutes on our side; chat apps keep their own copies of a preview for longer.
Cloud
Check a session seal in your browser
The verify page has a Session tab. Paste the seal the Sessions view downloads, and optionally the journal your sidecar wrote, and it shows four checks with a tick or a cross each: the signature, that the seal adds up to its receipts, that every receipt belongs to the session, and that none of the calls your own machine recorded was left out. It also draws the session's agents and sub-agents.
Nothing leaves your browser
The seal, its receipts and your journal are checked on the page with the same open code as `passcontrol verify session`. The only request goes to the issuer the seal names, for its public keys. Without a journal the page says plainly that a seal proves no more than a spend statement.
Auto mode's safety checks are free again behind PassControl
In auto mode, Claude Code asks Anthropic to check risky actions inside the model call itself, at no charge. PassControl dropped the beta that switches this on, so Anthropic refused the first call of every session, Claude Code showed a "this session isn't eligible" notice, and its separate classifier calls were billed to the agent's budget. PassControl now passes that beta, so those checks are free again and the refused call is gone from the dashboard.
Passed only when understood
The beta travels only when every requested check is auto mode's dangerous-tool-use check, carrying Claude Code's description of its own environment and nothing else. That context goes to Anthropic, which already receives the conversation, and PassControl stores none of it. Any other shape keeps the beta dropped, and Claude Code falls back as before.
Verdicts come back unchanged
Anthropic's verdicts arrive in the same response and reach Claude Code untouched. PassControl makes no decision from them, and holds, receipts and limits are unchanged.
Claude Haiku 5.5 costs five times more per token once a prompt passes 100,000 tokens, and PassControl used to set money aside at that higher price on every call. A typical Claude Code call on Haiku 5.5 held about eight times what it cost, so a small per-sub-agent cap refused sub-agents before they had spent anything, and calls held against the day's limit at five times their price. A prompt well under the line is now held at the lower price.
Well under the line, with room to spare
The lower price is used only when the prompt's estimate, plus anything hosted tools may add to it, is at most half the 100,000-token line. The estimate already runs high, so a prompt held this way cannot realistically cross the line. Anything larger is held at the higher price as before, and every call is still charged what Anthropic reports.
A cap refusal says what did not fit
A sub-agent or a delegated worker refused at its spending cap used to be told it had used its cap. What did not fit was the most this call could cost, added to what it had already spent, and the message now says that.
A request can name more than one advisor model. PassControl checked only the first against the agent's allowed models and held money for the last, so an allowed advisor listed first could carry a forbidden one, and a cheap one listed last could leave the hold short. It now checks every advisor and holds for the most expensive.
What you may notice
A request that lists a forbidden advisor anywhere in it is now refused before anything reaches the provider. A request with several advisors holds money for the most expensive one, so its hold can be larger than before.
A call's session and sub-agent, where you look at the call
The Claude Code or Codex session and sub-agent a call declared were signed into its receipt in 1.4.0, but only `passcontrol verify receipt` showed them. The call drawer in the dashboard and the receipt verifier on the web now show them too, in the same words: declared by the client, not verified.
Said on the same line, every time
Session and sub-agent ids come from the coding tool, and any program on the same machine could send different ones. So wherever they appear, the line says they were declared and not verified. They group calls; the passport or key identity still says which agent made the call. A call without them looks exactly as before.
Links into an agent's page land on the section they name
Following a link such as an agent's "Edit allowed access" or "Policy lab" from elsewhere in the dashboard opened the agent's page at the top, or partway down, instead of at that section. It now lands on the section, below the page's sticky navigation, on a laptop and on a phone. The overview at the top of the dashboard no longer hides under the navigation either.
Nothing to change
No link address changed, so bookmarks and links you saved before keep working. They now open where they point.
Claude Code and Codex can each run as their own agent
`passcontrol configure claude-code --dedicated --write` (and the same for codex) gives the tool its own agent: its own passport, budget, scope and kill switch, so the two no longer share one identity. The passport is kept in a private profile on this machine, and `passcontrol sidecar --profiles claude-code,codex` serves both from one terminal, each on its own port.
Created once
Running the command again reuses the profile; it never makes a second agent. `passcontrol profile list` shows each profile's passport and port, never its secret. A project's `.passcontrol` cannot choose which profile a sidecar speaks as.
Started before the gateway was up, `passcontrol sidecar` printed a red "could not mint initial visa" and carried on as if nothing had happened. It now says it is waiting, retries on its own (every few seconds at first, then every 30), and says when the gateway answers.
A refused passport stops it
If the gateway answers and refuses the passport (unknown, wrong key, or the agent is suspended), the sidecar now exits with the gateway's reason instead of staying up and failing every call. After resuming a suspended agent, start the sidecar again.
Setting up or removing Claude Code no longer needs a passport
`passcontrol configure claude-code` and `passcontrol env claude-code` only write or print settings that point Claude Code at the sidecar, and the sidecar holds the passport. They no longer refuse to run when this machine's PassControl config has no passport, so `--remove` can undo the settings even after that config has been deleted.
Cursor and Claude Desktop are unchanged
Their setup still needs a passport in the global config, because those apps start `passcontrol mcp`, which signs in with it.
The self-hosted stack's Redis used to live in memory only, so every Docker restart emptied it. That is where PassControl keeps each agent's running spend, and an agent with a dollar limit whose spend record is gone is refused until its budget is rebuilt, because the gateway will not guess. Redis now writes to a Docker volume and comes back as it was.
What the update does
`passcontrol update` moves Redis onto its volume as part of updating, so there is nothing extra to run. The move starts Redis empty one last time: check the kill switch and your service stops afterwards.
What stop and reset do now
`passcontrol stop` keeps Redis data, as it already said it did. `passcontrol reset --local` still wipes it. A hard crash can lose about the last second of changes.
Claude Code, Codex, OpenRouter and Ollama each have a one-page guide in docs/integrations: how to connect the tool, what PassControl governs, and what is declared by the client rather than proved.
Where to find them
Each guide is linked from the README and sits in docs/integrations in the public repository, beside the existing guides for the Passport SDK and Hermes.
Receipts name the session and sub-agent a call came from
Claude Code and Codex already tell the gateway which working session each call belongs to, and which sub-agent made it. PassControl now records that on the call log and signs it into the receipt, so twelve calls from a reviewer sub-agent no longer look like twelve calls from the agent itself.
Declared, and labelled that way
These values come from headers the coding tool sends, and any program on the same machine could send different ones. So the receipt marks them `declared`, `passcontrol verify receipt` prints "declared by the client, not verified" beside them, and nothing in PassControl allows or refuses a call because of them unless you set a sub-agent model list, which is a guard rail and says so. The passport still says which agent made the call; the session and sub-agent say how the agent organised its work.
Nothing changes for calls without it
A call that declares no session gets exactly the receipt and log row it got before, and receipts keep their version number, so every verifier already installed keeps working. A malformed value is ignored rather than refused.
Cloud
Seal a coding session and check nothing was left out
Every agent page now lists its Claude Code and Codex sessions as a tree of the main agent and its sub-agents, with calls, cost and refusals for each. One click seals a session: PassControl signs a commitment to every receipt in it, and you download the seal with the receipts. `passcontrol verify session` checks it offline, against the list your sidecar kept.
Four checks, one of them yours
The seal's signature, its totals recomputed from the receipts, every receipt verified and in the right session, and every call your own sidecar recorded present in the seal. That last check is what a daily statement cannot do: it catches a call left out. A missing call is reported as missing, because a lost log write looks the same.
Declared, and labelled that way
Session and sub-agent ids come from the coding tool, not from a signature, so the view and the verifier both say "declared by the client". They group calls; the passport still says which agent made them.
From the CLI
`passcontrol sessions` lists them, `passcontrol seal <session> --agent <id>` saves a seal, and `passcontrol verify session <file> --journal <file>` checks it.
`passcontrol sidecar` now writes one small file per run listing the receipt id, time, HTTP status and session of every call it relays. It is your independent record: when you check a session seal, every call the journal holds for that session has to be in the seal, so a call left out is caught, and the other sessions the same run served are left out of the check.
Ids only, and only yours
Each line is a receipt id, a time, a status code and the session id the coding tool declared. No prompt, model, other header or response is ever written. The file lives under your PassControl config folder, readable only by you, and the sidecar prints its path when it starts.
Off with one flag
`passcontrol sidecar --no-journal` writes nothing. Session seals still verify without it, but they then prove no more than a daily statement does, and the check says so.
An agent's policy can now hold a sub-agent model list. When Claude Code or Codex says a call comes from a sub-agent, the gateway allows it only on a listed model; the main agent keeps its full scope. Set it in the policy editor on the agent page, under Sub-agent models.
It can only narrow
A model on the list but outside the agent's own scope is still refused. An empty field means sub-agents are not restricted; choosing "no model at all" refuses every sub-agent call. The refusal says it was the sub-agent rule, and Claude Code shows that reason to you.
A guard rail, not a security boundary
The sub-agent is identified by a header the coding tool sends, and any program on the same machine can leave it out and be treated as the main agent. The list stops a model your tool picked for a sub-agent from running up cost; it does not stop a process that wants around it. The editor says so beside the field.
Before you downgrade
Versions before this one do not know the setting and refuse every call for an agent whose policy contains it. Clear the list before going back to an older version.
An agent's policy can now check every request for things that look like keys: provider keys, GitHub and cloud tokens, private keys, PassControl's own keys. Choose to replace them with a placeholder, so the model never sees them, or to refuse the call. Keys a model writes into an answer are replaced too.
Replace or refuse
Replace sends the call on with each key swapped for a placeholder naming its kind, the same one every time, so a coding agent's prompt cache keeps working. Refuse sends nothing and says what was found and where, never the key itself. Claude Code and Codex re-send the whole conversation on every call, so after a refusal the key has to leave the history (/rewind or /clear); the editor says so.
Measured against real work
We ran it over every file of PassControl's own repository and a full Claude Code request before turning it on. It ignores made-up test values, the public demo credentials and image data, and flagged nothing in either. The key itself is never logged, stored or put in a receipt.
What it does not cover yet
It is off until you turn it on for an agent. It checks calls to AI providers, not calls to services such as GitHub or traffic tunnelled through the sidecar. A key the model writes into a streamed answer is detected and reported, not removed; one in a non-streamed answer is removed. Personal data and asking you first are planned next.
Claude Code now sends Anthropic's advisor tool on every main-model call when you have an advisor picked, and PassControl refused it, so Claude Code stopped with `400 server_side_tools_unsupported`. The advisor is now priced: its model must be one this agent may use, it is reserved for before the call goes out, and it is charged at its own model's rates from what Anthropic reports.
The advisor is a second model, and is treated like one
The advisor's model has to pass the agent's allowed models and its policy's deny rules. A sub-agent model list does not apply to it: it limits the models a sub-agent picks, and the advisor is your own choice. Its tokens count against a token limit, and its cost against a dollar limit; the receipt's cost includes it. Under a dollar limit an advisor the agent left uncapped gets one use and 4,096 output tokens per call, so one Opus advice cannot spend a day's limit; Claude Code carries on without it if it asks twice.
A refused hosted tool now says which one, and shows up in your logs
A hosted tool PassControl cannot price (tool search, an MCP connector, an advisor model with no price) is still refused before anything is sent, but the error now names the tool and says what to do, including how to turn Claude Code's advisor off (/advisor, No advisor), and the refusal is written to the activity feed with its own status instead of disappearing.
Under a small dollar limit, Claude Code reserves a lot while a call runs
Before each call goes out, PassControl reserves the most it could cost. Claude Code asks for up to 128,000 output tokens on every call, so one call at the start of a session (about 100,000 tokens of prompt) reserves about $0.39 on Haiku 5.5, $1.55 on Sonnet 5.5 and $3.09 on Opus 5.5. An advisor adds what it could cost if consulted, the whole conversation at its own rates plus its output: about $1 more with Opus. Most calls end up costing cents, and each reservation is settled to the real cost when Anthropic reports what it used, so a limit is never overshot. But a daily limit smaller than one call's reservation refuses every call: Sonnet with an Opus advisor needs more than $2.57 free before its first call goes out. On a small budget, use Haiku or Sonnet, a cheaper advisor or none, or raise the limit.
Claude Code's newest request features stay off through PassControl, for now
Claude Code 2.1.295 started asking Anthropic for three new beta features: inline tools, mid-conversation tool changes and per-turn control. PassControl passes on only the beta features it has checked, and one of these lets a request add tools in the middle of the conversation, where the gateway's checks on hosted tools do not look yet. So Anthropic refuses those requests, and Claude Code retries without the features and carries on. A session or sub-agent starts a second or two slower, the refused tries cost nothing, and the three features stay off until the gateway checks them.
Agents can see their own budget, and so can you in Claude Code
An agent can now ask the gateway what it has left: `GET /api/v1/self` answers with its scope, its limits, what is used and when a daily or monthly limit resets. The MCP server gains a `budget` tool, and `passcontrol statusline` puts "PassControl · $0.42 of $2.00 today" in Claude Code's status line.
Same door, nothing spent
`/self` authenticates exactly like a model call, sender proof and kill switch included, and answers only about the calling agent. It writes no log row, no receipt and no hold, and it has its own rate limit, so polling it never uses up the agent's allowance for real calls. The policy's deny rules and the owner's details are not in the answer.
The same numbers the gateway enforces
Used means spent plus what calls in flight are holding, the way the gateway counts before admitting the next call, and the decision trace now uses the same arithmetic. A count that cannot be read shows as unknown, never as $0.00.
Status line, opt-in
`passcontrol configure claude-code --write --statusline` adds it. A status line you already have is left alone, and `--remove` takes out only PassControl's. It reads through the running sidecar, caches for 20 seconds, and always prints one line, so it cannot hold up Claude Code.
Limits hold closer to the real cost on newer Claude models
Before a call goes out, PassControl reserves what it expects the call to cost, and that reservation is what keeps a daily or total limit from being passed. It estimated every prompt at four characters a token. Claude 4.7 and later count the same text as about a third more tokens, so a Claude Code request was reserved at 70% of its real size. Estimates now follow the model's tokenizer.
Measured, not guessed
We sent a real Claude Code request, source code and Bulgarian prose to old- and new-tokenizer Claude models and compared the token counts Anthropic reported. The estimate now counts the prompt's UTF-8 bytes and divides by 2.4 for Claude 4.7 and later, Fable and Mythos (through OpenRouter too), by 3.2 for older Claude models, and by 4 for every other model. Counting bytes rather than characters also fixes Cyrillic and other non-Latin text, which was reserved at about 60% of its size.
Cached prompts are held at the write rate
A request that asks Anthropic to cache its prompt, as Claude Code does, may pay 1.25 times the input price to write it (2 times for the one-hour cache). Its reservation now uses that rate, so a session's first call is covered; later calls read the cache far cheaper and are charged what Anthropic reports.
Images are held by what they can cost
Anthropic bills an image by its pixels, and resizes it so one image costs at most 4,784 tokens on Claude 4.7 and later and 1,568 on older models. Images were reserved by their file size instead, so a 1 MB screenshot reserved about 330,000 tokens and an agent with a small daily limit could be refused a call that costs cents, while an image sent by URL reserved almost nothing. Each Claude image now reserves that ceiling, whatever its size or source. PDFs and other providers' images are still counted by size.
What it changes
Agents near a limit are refused a little sooner, because each call reserves more while it runs; reported usage is still what is charged. On the rare outcome where a call may have been billed but the provider reported nothing, the estimate is what is charged, so that charge rises too. It narrows the gap, it does not close it: unusual text can still tokenize denser than the estimate, and other providers are estimated at four bytes a token, which can still fall short for Chinese or Japanese text.
Prompt-cache reads on Claude Opus 5.5, Sonnet 5.5, Fable 5.1 and Mythos 5.1 are now charged at Anthropic's own rates, which are lower than on other models. Claude Sonnet 5.5, Haiku 5.5, Mythos 5.1 and Mythos 5 have prices, so an agent with a dollar limit can use them.
Cache reads were overcharged on four models
Every cache read was charged at a tenth of the input rate. Anthropic charges a twentieth on Opus 5.5 and Sonnet 5.5 and a fortieth on Fable 5.1 and Mythos 5.1. A long Claude Code session reads most of its prompt from cache, so its receipts showed up to twice (Opus 5.5) or four times (Fable 5.1) the real cache cost, and that cost counted against its limit. The error was always upward; no call was undercharged.
Haiku 5.5 is priced by prompt length
Up to 100,000 tokens it costs $0.10 in and $0.50 out per million; above that, $0.50 and $2.50 for the whole request. The prompt counts cached tokens too, so a mostly cached long prompt is billed at the higher rate, as Anthropic bills it. While a call runs, its hold is sized at the higher rate.
A model with no price is still refused under a dollar limit
That is why Sonnet 5.5 and Haiku 5.5 were refused before. Mythos 5.1 and Mythos 5 are available only to verified organisations, and their model ids follow Anthropic's naming scheme because its pages do not list them.
Point Claude Code at PassControl and it now works. Before, every call came back from Anthropic as "400 context_management: Extra inputs are not permitted", because the gateway did not pass on the beta features Claude Code asks for. It now passes the ones that change neither what a call can reach nor what it costs, and drops the rest.
Your own tools pass straight through
Claude Code's tools run on your machine and reach Anthropic unchanged. Anthropic's hosted tools, such as web search, are covered by a separate entry on how they are priced against a budget.
Technical details +
Fixed
Claude Code's start-up check no longer shows up as a refused call on the agent's activity.
`passcontrol configure claude-code --write` now routes Claude Code's own model calls through PassControl, not only its chat tool. It adds two settings to the project's `.claude/settings.local.json`, or with `--global` to every Claude Code session, and Claude Code never holds a provider key.
Your settings file stays yours
Only those two settings are added, the previous file is kept as a `.bak`, and a different gateway already set there is not replaced unless you pass `--force`. `--remove` takes them back out.
`passcontrol configure codex --write` writes a Codex profile that sends Codex's model calls through PassControl. Start Codex with `codex --profile passcontrol` and its calls carry no OpenAI key: PassControl adds the real one, and the agent's scope, limits and kill switch apply to every turn.
Your Codex config stays yours
The profile is a file of its own, `~/.codex/passcontrol.config.toml`; Codex's `config.toml` is never touched, and plain `codex` still works as before. `--remove` deletes the profile, and only one PassControl wrote.
Codex's tools work
Shell commands and Codex's long-session compaction run through the same governed endpoint, and its web search is priced against the budget.
Store one OpenRouter key and an agent can reach the models in its catalog through PassControl. Point an OpenAI-compatible client at `/api/v1/openrouter`; the agent's scope names which models it may use, and each call is charged at the cost OpenRouter reports for it.
A dollar limit holds
One model on OpenRouter can run on several providers at different prices. Under a dollar limit, PassControl reserves at the most expensive of them and tells OpenRouter not to route above that price, so a call cannot cost more than was reserved.
The model is the one the agent named
Requests that let OpenRouter pick a different model (fallback lists, routers such as `openrouter/auto`, presets) are refused. The free router `openrouter/free` is allowed, and is the default for a new OpenRouter agent.
The account stays out of reach
Only chat completions and the model list go through. Key management, credits and BYOK settings are refused, and OpenRouter's own tools and web search are refused for now.
Apps that talk to Ollama can run through PassControl
An app that only lets you set `OLLAMA_HOST` can now use your local models through PassControl. Run `passcontrol sidecar --ollama-port` and point `OLLAMA_HOST` at `127.0.0.1:11435`: chat and generate calls are governed and logged with Ollama's own token counts, and model information and listing work as before.
It can run models, not manage them
Pulling, pushing, creating, copying and deleting models are refused, so an app connected this way cannot change what is installed on your machine.
Give it a token budget
Local models have no price, so an agent with a dollar limit is refused. A token budget limits it instead, counted from what Ollama reports.
Claude's web search, web fetch and code execution work, and count against the budget
An agent can now use Anthropic's web search, web fetch and code execution through PassControl. Each is priced as Anthropic bills it: 1 cent per search, web fetch at the tokens it brings in, code execution by the time it runs. Before the call is sent, PassControl holds enough of the budget for what the tool may use, so a dollar limit still holds.
A cap of 5 when the agent sets none
For an agent with a dollar limit, a search or fetch with no `max_uses` gets a cap of 5, so one call cannot run up an open-ended bill. A cap your agent sets itself is left as it is.
Still refused
Anthropic's advisor tool, tool search and MCP connector have no published per-use price, or reach servers the agent names, so they are still refused with `400 server_side_tools_unsupported`.
Technical details +
Fixed
A streamed Anthropic call that searched the web was charged for its prompt alone. Search results are added to the input while the call runs, and PassControl now reads the final count.
OpenAI's web search, file search and code interpreter work, and count against the budget
OpenAI's web search, file search, code interpreter and hosted shell now run through PassControl and are priced as OpenAI bills them: $10 per 1,000 searches ($25 for the preview tools), $2.50 per 1,000 file searches, and containers by the minute for their memory size. Before the call is sent, enough of the budget is held for what the tools may use.
A cap of 5 when the agent sets none
For an agent with a dollar limit, a request with hosted tools and no `max_tool_calls` gets a cap of 5. A cap your agent sets is left as it is. Reusing an existing container under a dollar limit is refused as `unpriced_option`, because the request does not show the container's size, which sets its price.
Still refused
Image generation, remote MCP servers and server-side tool search are still refused with `400 server_side_tools_unsupported`.
OpenAI's cached input is priced as OpenAI bills it
When OpenAI serves part of a prompt from its cache, it bills those tokens at a fraction of the input rate. PassControl charged them at the full rate. It now uses each model's published cached-input rate, so a coding agent that resends a long conversation every turn no longer reads several times higher on the dashboard than on OpenAI's bill.
Token limits still count every token
Only the price changes. Token budgets, the call log and receipts still count every input token, cached or not.
The reservation stays cautious
Before a call, PassControl cannot know what OpenAI will serve from cache, so it still reserves at the full rate. A call close to a dollar limit can be refused for room it would not have used.
GPT-6 and GPT-5.6 are charged at their normal-length price
OpenAI charges these models more only for prompts above 272K tokens. PassControl still reserves at that higher price, then charges what the call actually cost: the normal price for a shorter prompt, with cached reads and cache writes each at their own rate. A response that does not report the standard service tier is charged at the higher price. GPT-6.1 Sol is priced too.
Ask me first always answers the agent before the time limit
A call waiting for your approval now stops waiting exactly when its 15 seconds are up, even if Telegram is slow to answer. Before, a Telegram that hung could keep the call open several seconds longer, close enough to the hosting platform's 25-second limit that the agent could get a timeout instead of PassControl's answer.
Nothing changes when you answer in time
Tap Approve within the wait and the call goes through as before. Answer later and the agent's retry finds your answer, as before.
`passcontrol update` tells you what to do next, and keeps the noise out
An update now ends by telling you to run `passcontrol start` when the dashboard was not running, instead of stopping at "App updated". The rest of its output is shorter: one line for the code that changed instead of every file, no npm warnings, and only the database migrations it actually applied, with a count of the rest. Supabase's start-up banner, which printed the local stack's keys, now shows only if starting Supabase fails.
Finishing an interrupted update, out loud
If an earlier update stopped before its migrations, the next one says it is finishing that update and lists every migration it will apply, not only the newest ones. The rollback hint names the code you had before this update, not an older one.
Technical details +
Fixed
`passcontrol status` printed some labels run into their values, such as "Key storage:tier 1".
The "Update available" notice no longer appears at the end of `passcontrol update` itself. The update to this version still shows it once, because the version doing that update prints it.
Settings now has Models on this machine. If Ollama is running, Use Ollama connects it, and your agents can use its models through PassControl with the same scopes, token budgets, kill switch and audit trail as any provider. It needs no key and no env file: the local stack allows your own model servers by default, and `PROVIDER_ENDPOINT_MODE=off` turns that off.
A provider of its own: local
Local models are the `local` provider, so they sit alongside a real OpenAI key instead of replacing it. Point the OpenAI SDK at `/api/v1/local/v1` with the agent's key. LM Studio, vLLM and other OpenAI-compatible servers work too: add a local credential with the server's address and, if it needs one, its key.
Recorded honestly
Receipts and the audit log say `local`, never a cloud provider. Calls are logged with their tokens and no cost, and nothing is counted as spend. An agent with a dollar limit is refused, because there is no price to hold it to; give local agents a token budget, and the agent wizard reminds you.
The model server stays yours
An agent key reaches chat and the model list only. The server's own controls, such as Ollama's pull and delete, are refused, and the agent wizard offers the models your server already has.
Self-host gets focused: built for one developer on their own machine
Self-hosted PassControl now carries only what you use on your own computer. Opening it goes straight to your dashboard. Pages built for an audience a local install never has are gone: public profiles, owner verification and agent listings, which exist for strangers to look at, and the bot check and confirmation emails, which defend a public sign-up page. Setup creates your account and sign-up stays closed unless you set PASSCONTROL_SIGNUP_MODE. "Report a problem" opens the project's issue tracker.
Six new features, and still smaller
This release adds local models, Ask me first, alerts, and Brave Search, Notion and Discord, and self-host still comes out with less code than 1.1.0: fewer pages to load, fewer moving parts, less to read if you audit it.
Every control is untouched
Nothing removed ever handled an agent's call. Identity checks, scopes, budgets, the kill switch, signed receipts and the audit trail are the same as before.
When you outgrow localhost
Self-host is made for localhost. To run agents on other machines, or give other people access, use PassControl Cloud.
System Health opens for the person who runs the stack
Setup now names the account it creates as this install's operator, so System Health, the page that checks your own install, is reachable without editing configuration.
Still behind two-factor, and your list wins
System Health still asks for two-factor verification before it opens. If you already set the operator list yourself, setup leaves it as it is.
Settings has a new Animations switch. Turning it off stops the dashboard's motion, including the control graph's momentum, and is remembered by this browser.
Your system setting still wins
Your operating system's reduce-motion setting turns the dashboard's motion off whatever the switch says.
Calls to your own server no longer add phantom spend
A provider key pointed at your own server (Ollama, vLLM, LiteLLM) used to add an estimated dollar charge to the agent's spend for every call, priced from the provider's own table, even though PassControl cannot know what your server costs. Those calls now add nothing to an agent's cost budget. Their tokens still count, and an agent with a dollar limit is still refused them, so no cost cap depends on a price nobody knows. Charges recorded before this update stay in the agent's history.
Give local agents a token budget
A dollar limit cannot hold a call with no known price, so an agent that has one is refused these calls. A token budget counts them like any other call.
Updates stop before they start when another stack holds the ports
If another local stack is using your install's database or gateway ports, `passcontrol update` now stops before it changes anything and names that stack, with the exact command that frees the ports. Before, it pulled the new code and reinstalled first, then stopped part way with only an npm exit code.
It names what is in the way
The message names the Supabase project or container holding the ports and gives its stop command, `supabase stop --project-id <project>` or `docker stop <container>`, both of which keep that stack's data. `setup` gives the same answer.
Cloud
A clearer front page, and a verify page that looks like the rest
The front page now explains the control path as one line, in the order PassControl runs it, and compares Cloud with self-hosting side by side instead of as two sales cards. The passport and receipt verify pages, the ones strangers open to check an agent, now match the rest of the site; what they check, and what they say, is unchanged.
Same checks, new look
A pasted receipt is still checked entirely in your browser, against the same eight steps as before. Only the page around it changed.
The dashboard reads more evenly and moves with more care
Buttons and tables use one size scale instead of inheriting oversized text, the agent page's long row of section links becomes three tabs (Identity, Access and Record) that show one row of links for the part you are in and follow you as you scroll, and the spend ledger is no longer a box inside a box. The control graph keeps the momentum of a drag, resists at its edges and eases its zoom. New approval requests slide in, and an answer appears only once the server has recorded it.
Calm where you work all day
The command palette, navigation and filters stay instant, because you use them all the time. Motion is kept for moments that need it, and follows your system's reduce-motion setting.
Ask me first: approve an agent's action before it happens
Tick "Ask me first before each write" on an agent's GitHub, Notion, Discord or Telegram access, and every call that changes something waits for your answer. The question arrives on Telegram with Approve and Deny buttons, in Slack or Discord with a link, and on the new Approvals page. Approving sends that exact request once: a changed message is a new question. Denying refuses it every time the agent retries. Once a Telegram bot token is stored, Connect an agent offers reading and sending messages, with Ask me first, already ticked.
You approve what you can read
The Approvals page lists every waiting request with the exact method, path and the start of the body the agent wants to send, so the decision is about that request and nothing else.
Settings now has an Alerts section. Paste a Slack or Discord incoming-webhook URL, or a Telegram bot token and chat ID, and PassControl posts in that chat when an agent is refused, runs out of budget, has its passport rotated or gets a break-glass grant, so you no longer have to keep the dashboard open to find out.
Quiet by design
Each kind of alert is sent at most once per agent every 10 minutes, and calls refused because you suspended an agent or used the kill switch never alert. Choose which kinds you want, and use Send test alert to check the channel.
The webhook URL and bot token are treated like keys
They are stored in the Vault and never shown again; the page shows only the service and the last four characters. Changing or removing them needs two-factor verification when you have it on, and only Slack, Discord and Telegram addresses are accepted.
Agents can post to Discord as your bot, without holding its token
Discord joins the services PassControl governs. Store your bot's token in Settings, then give each agent one channel and choose what it may do there: read messages, send messages, add reactions or start threads. The token stays in PassControl, and every call is checked against the agent's rules and receipted.
Never allowed
Webhooks (even reading them, because they carry tokens), changing servers, channels, roles or permissions, kicks, bans and prunes, invites, bulk deletion, and changes to the bot itself are refused whatever an agent's rules say. The list was written from Discord's own route reference.
Works with discord.js
Point discord.js's REST client at the governed URL (`api`, version 10, and `authPrefix: "Bearer"` with the agent's key). Only API v10 is served, so a client cannot fall back to Discord's old default version.
From the CLI
`passcontrol env discord` prints `DISCORD_API_URL` for the local sidecar. The sidecar now refuses to tunnel to discord.com, including with `--allow-connect`: an agent that posted to a Discord webhook through the sidecar should use the governed Discord route instead.
Agents can work in Notion, without holding its token
Notion joins the services PassControl governs. Store a Notion integration token in Settings, then choose what each agent may do on its page: search and read pages and databases, create pages, edit content, or comment. The token stays in PassControl, and what it can reach is still only what you shared with the integration in Notion.
Works with Notion's SDK
Point `@notionhq/client` at the governed URL with its `baseUrl` option and give it the agent's key: paths and the `Notion-Version` header pass through as Notion expects.
Never allowed
OAuth endpoints, file uploads, and running or changing Notion's own AI agents are refused whatever an agent's rules say; starting a Notion AI session spends the workspace's credits.
From the CLI
`passcontrol env notion` prints `NOTION_API_URL` for the local sidecar, and the sidecar refuses to tunnel to api.notion.com.
Agents can search with Brave Search, without holding its key
Brave Search joins GitHub and Telegram as a service PassControl governs. Store the Brave Search API key in Settings, then tick what each agent may do on its page: search the web, fetch search context for a model, or look up local places. The key stays in PassControl, every search is checked against the agent's rules, and each one gets a receipt.
A lower default limit, because Brave bills per search
Brave charges every search to the card on your Brave account, with no spending limit on its side. So an agent with no limit of its own is held to 30 searches an hour, not the 500 calls other services allow. Set the agent's own limit on its page.
Read-only
Only GET searches are served. Brave's token-billed chat/completions endpoint is refused: it is a model call, and belongs on the model gateway under a budget.
From the CLI
`passcontrol env brave` prints `BRAVE_SEARCH_API_URL` for the local sidecar, and the sidecar refuses to tunnel to api.search.brave.com.
Step 2 of a Direct Agent Key's setup is now a single command: paste it into a terminal in your project folder and it writes `passcontrol.env` and loads it. Before, the screen showed the variables and asked you to save them as a file. Pasted straight into a terminal, they became shell variables that a curl test could read but an SDK started afterwards could not, so the agent failed with a missing key.
A new terminal needs one line
The command makes the file readable only by you, since it holds the agent's key; add it to your `.gitignore` so it stays out of git. In a new terminal, `set -a; . ./passcontrol.env; set +a` loads it again. A reopened Setup view writes a placeholder in place of the key, which is shown only once: edit the file, put in the key you stored, and run that line.
Cloud
Importing a key at the agent limit no longer saves the key
If your workspace is at its agent limit, importing a provider key now stops before the key is saved and says which limit you reached. Before, the key was saved and only the agent was refused, leaving a stored key that no agent used.
The same import can finish
The refusal does not use up the import. Revoke an agent you no longer need in another tab, then create again from the same screen within ten minutes, without pasting the key again.
Cloud
A guide to GitHub access for agents
A new guide in Learn explains how to let an agent read GitHub without holding your token: a fine-grained token as the ceiling, per-agent rules as the floor, and the wire details that decide whether a gateway's rules actually hold.
For plugin and tool-server authors too
It explains why a GitHub tool server needs a base URL setting before a gateway can sit in front of it, and why a credential in an environment variable alone does not keep the token out of the agent.
Cloud
PassControl Cloud is open to everyone
You no longer need an invitation. Create an account at /signup, connect one agent, and the free beta gives each new workspace 300 governed calls a month. Existing invitees keep their allowance.
Tell us how setup went
After your first governed call and refusal, the dashboard links to a short feedback form. Every account can use it, not only invited ones.
Cloud
Fair-use limits on agents, keys and credentials
With sign-up opening, each Cloud account can now hold up to 10 agents, 3 active keys per agent, 10 stored credentials and 10 control-API keys, and create at most 50 of any one kind a day. Revoking, suspending and rotating are never limited.
The message says what to do
Hitting a limit names it and the fix (revoke something you no longer use) in the dashboard and in workspace import, instead of a generic error.
Cloud
A human check on sign-up, sign-in and password reset
The account forms now carry a Cloudflare Turnstile check, so scripts cannot mass-create accounts or hammer sign-in. The check is enforced by the authentication service itself, which also covers anyone calling it directly rather than through these pages.
A failed check says so
If the check does not go through, the form says the human check failed rather than reporting a wrong password, and it never counts toward locking an account.
Dashboard errors name the problem instead of a generic failure
When a dashboard action was refused, such as adding a key with an unknown provider, revoking a key that was already revoked, or reaching an account limit, the site showed a generic "An error occurred in the Server Components render" message instead of the reason. Those actions now return their reason and the dashboard shows it.
Unexpected failures stay private
Only messages written for people are shown. Any other failure reads "Something went wrong. Please try again.", so an internal error can never echo back something you typed, such as a provider key.
Cloud
Lost your confirmation email? Ask for a new one
If a sign-up confirmation link expires or never arrives, the new page at /signup/resend sends a fresh one. Signing in before confirming now says so and links there, instead of reporting a wrong password.
Nothing to learn about other people's accounts
The page gives the same answer whether or not an unconfirmed account exists for the address, and the sign-in hint appears only after the correct password.
Sign-in no longer fails when the lockout store is down
The account lockout after repeated wrong passwords keeps its state in Redis. If Redis could not be reached, the sign-in page used to fail outright, for every account. It now skips the lockout for the duration of the outage and lets the password check decide, so operators can still reach the dashboard and its kill switch.
The password still decides
Nothing gets in without the right password during an outage. Only the counting of wrong attempts pauses, and the lockout works again as soon as Redis is back.
Cloud
Workspaces with no published owner no longer look it up on every call
Every governed call checks whether the workspace has a published owner to name on its receipt. For a workspace with none, which is most of them, the answer was meant to be cached for five minutes, but it was never read back from the cache, so every call asked the database again. It is now cached as intended. Receipts are unchanged, and publishing an owner still shows up on the next call.
Less work per call
For a workspace with no published owner, each governed call now makes one fewer database query and four fewer cache commands.
Agents can write to GitHub, one exact rule at a time
An agent can now open issues, comment, label, merge and update files through PassControl, with the same identity, kill switch and signed receipts as its reads. Each write is a rule that names one method and an exact path, and the writes that could do the most damage are refused whatever a rule says.
Exact rules for writes
A rule can now be `POST`, `PUT`, `PATCH` or `DELETE` as well as `GET`, and admits only the method it names. A write rule names its path exactly: `*` still matches one segment, but the trailing wildcard that matches the rest of a path is for read rules only, so `POST /repos/acme/web/issues/*/comments` lets an agent comment on issues in one repository and nothing more.
Some writes are never allowed
Deleting a repository or changing its name or visibility, collaborators and other access changes, webhooks, secrets and variables, dismissing security alerts, anything under `.github/` (workflow files run code with the repository's secrets), updating or deleting a branch or tag, and any change to an account or organization are refused with `403 service_endpoint_refused`, however the path is cased and whether the repository is named or addressed by its numeric id. The dashboard refuses to save a rule that could only ever reach one of them.
The receipt proves what was written
A write's body is read only after the call is admitted, must be JSON, and is limited to 1 MiB. It is sent to GitHub once, exactly as received, and its digest is signed into the receipt. The body itself is never logged.
Agents can use a Telegram bot without holding its token
Store a Telegram bot token once in PassControl, give each agent the Bot API methods it needs, and its calls go through the same identity, stop switches and signed receipts as its model and GitHub calls. The token, which Telegram puts in the URL, never reaches the agent.
Rules name methods
A Telegram rule names one Bot API method, such as `sendMessage` or `getMe`, matched in any case. Telegram accepts GET and POST for every method, so the verb grants nothing. The agent calls `/api/v1/svc/telegram/<method>` and the gateway builds Telegram's URL itself.
What is refused
`setWebhook`, `deleteWebhook`, `logOut` and `close` are refused whatever a rule says, and so are file downloads, whose URL carries the token. A stored token that is not shaped like a bot token is never put into a URL. Uploads are refused; send a file by URL or `file_id` instead.
Through the sidecar
`passcontrol env telegram` prints `TELEGRAM_API_URL` for the local sidecar, which adds the passport's proof; PassControl adds the bot token. The sidecar refuses to tunnel to api.telegram.org, because a tunnel would carry the token past every control.
Know the limits
A rule for `sendMessage` can reach any chat the bot is in, because the chat is chosen in the request. Agents that share one bot also share its update queue.
Stop one service for every agent, without stopping their models
A new Services page shows, for each API other than a model provider, the token in use, which agents can reach it and the last hour's calls, with a switch that stops that service for every agent in the workspace while their model calls carry on.
A stop for one service
Stop all GitHub calls refuses every GitHub call from every agent until you resume, and leaves model calls alone. It is read together with the workspace kill switch on every call, so it holds in the same conditions: on Cloud, a stop that cannot be read refuses the call. Stopping and resuming need no second factor, are written to the audit trail, and the page shows what the gateway reports afterwards rather than what was asked for.
Who can reach it
Each agent with access is listed with its number of rules, how many of them are writes, and its hourly cap, and an agent whose stored rules are not valid is flagged, since every one of its calls is refused. Tokens are still added and rotated under Settings.
Connect an agent and Issue passport now ask what the agent will call. Choose Only GitHub or Telegram and it is created with no model access and no provider key, and its setup shows how to point a GitHub or Telegram client at PassControl instead of a model SDK. A passport agent's setup wires Octokit through the PassControl SDK, which signs a fresh visa for every request.
No model access means none
Every model call from such an agent is refused, and it can call nothing at all until you give it GitHub or Telegram rules on its page. Its access now reads, for example, No model access · GitHub: 3 rules, rather than claiming it is allowed nothing.
An agent's GitHub access is now a repository and a few choices: read code, issues and pull requests; open issues; comment; open pull requests. Telegram's is read messages and send messages. Each choice writes ordinary rules, so nothing about enforcement changes.
Name the repository once
Type `owner/name` or paste the repository's github.com link. Reading covers the repository itself as well as everything under it, which a hand-written wildcard rule for everything under it did not.
Custom rules are still there
Anything the choices do not cover goes under Advanced, one method and path per rule. Rules saved before this change open with their choices ticked and the rest listed under Advanced, so saving never drops one. A path typed without its leading `/` gets one.
Smaller fixes
GitHub and Telegram carry their logos on the agent page, the Services page and Settings, and Settings links to where each token is made. An agent with service rules and no model access no longer reads "this agent can reach nothing".
passcontrol status no longer calls Cloud offline when its first answer is slow, doctor --deep stops running self-host checks on a machine that only talks to Cloud, and the menu shows whether the gateway is up even without a control key.
Status waits for a remote gateway
The version probe allowed every gateway 1.2 seconds, which suits one on this machine and not a remote one paying for DNS, TLS and a cold start on the first call. A remote gateway now gets 6 seconds, and a timeout is reported as "no answer within 6 s" rather than "offline".
doctor --deep for Cloud users
With a remote gateway and no local app checkout, Docker, Supabase and the stack's ports are skipped with a sentence saying why, and the signing-key line says the gateway signs receipts. A passport with no model access, made for GitHub or Telegram only, is told its refused demo call is expected instead of being sent to add a provider key.
The menu
The Gateway row is checked on a machine that holds only a passport, since the probe needs no credential, and the Key row says env var when the key came from PASSPORT_SECRET rather than a file.
A second local install moves as a whole, and doctor checks the right ports
passcontrol setup --port-offset now moves the dashboard too, not only Supabase and Redis, and passcontrol doctor checks the ports your install actually uses. Before, a second install's dashboard tried :3000, and doctor reported a working offset install as having its ports taken by another project.
The dashboard follows the offset
With --port-offset 500 the dashboard runs on :3500, and the saved gateway, Supabase's site URL and the receipt issuer all say so. start, stop and status follow it. Without an offset nothing changes.
doctor and status agree
doctor reads the offset setup recorded in supabase/config.toml, as passcontrol update already did, and names it in its port check: Local stack ports (offset 500).
OpenAI and Mistral embeddings go through PassControl
On a self-hosted gateway, an agent can now call OpenAI's and Mistral's embeddings endpoints through PassControl, so a retrieval agent no longer needs a second, ungoverned key just to embed text.
Governed like any other call
`POST /embeddings` and `/v1/embeddings` on the `openai` and `mistral` routes check the model against the agent's scope, reserve budget, settle on the provider's reported `prompt_tokens`, and write a log row and a receipt. Nothing is generated, so nothing is reserved for output, and an output ceiling on the agent's policy does not refuse them.
Streamed, not buffered
The response is forwarded to the agent as it arrives instead of being collected in the gateway first. If the response ends without a usage report, the call is charged its estimate rather than nothing.
Never swapped for another model
An embeddings call does not fail over to a configured fallback, and a failed one suggests no alternative provider. A different model's vectors would not match the ones the agent has already stored, so the original error is returned instead.
Only where usage is reported
Azure OpenAI embeddings go through the same way. Embeddings on other providers are still refused as unsupported endpoints. Together's embeddings responses do not report token usage, so PassControl could not charge them accurately. `stream: true` on an embeddings request is refused with `400 stream_unsupported`, because embeddings responses are not streamed.
The account export includes each agent's daily or monthly limit
Exporting everything PassControl holds about you now includes each agent's daily or monthly spending limit. It was left out of that export before, though the workspace export already carried it.
Nothing else changed
The limits were always enforced and always shown in the dashboard; only this export omitted them. A deployment that has not applied the migration that added them still exports, without them.
Cloud + self-hosted
Agents can read GitHub through PassControl
An agent's GitHub token no longer has to live next to the agent. Store it once in PassControl, give each agent the GitHub reads it needs, and every call goes through the same identity, kill switch and signed receipts as its model calls.
Deny by default, per agent
Add a GitHub token under Settings, Services, then give each agent its own rules on its page: a method and a path, such as `GET /repos/acme/*/issues`. An agent with no rules cannot make a single GitHub call. Rules are checked on every call, so removing one stops the next call, and a rule set that cannot be read refuses every call rather than allowing any.
Read-only, and scoped on the wire
Reads came first: `GET` and `HEAD`. Writes, each named by one exact rule, followed in 1.1.0. GitHub's GraphQL API is refused, because one endpoint that can do anything the token can cannot be scoped by path. Redirects are handed to the agent rather than followed, so the token only ever goes to api.github.com, and page links keep the path the agent was allowed to read.
Counted in calls, not dollars
A GitHub call has no price, so it sits outside the agent's dollar limit and its receipt says so (`unp: true`, `cls: "svc"`). Each agent has an hourly GitHub call cap instead, 500 unless you set one.
In the record, and in your exports
GitHub calls appear on the Departures board and in the control API's `/logs`, which now returns each call's `call_kind` and `endpoint` and takes `class=service` to list them alone. An agent's GitHub rules travel with the workspace export and are checked again on import, and the account export includes them. The only GitHub token a workspace holds can now be deleted from Settings, after a confirmation; GitHub calls are then refused until a token is stored again.
Point a client at it
Use `https://<gateway>/api/v1/svc/github` as the GitHub API base URL and give the client the agent's PassControl credential in place of a GitHub token. Octokit's `auth` option works as-is: this route reads GitHub's own `Authorization: token` scheme as well as `Bearer` and `x-api-key`. With CLI 1.1.0, `passcontrol env github` prints `GITHUB_API_URL` for the local sidecar and the client sends no credential at all. The sidecar refuses to tunnel to api.github.com, even where `--allow-connect` names it, because a tunnel would carry the token past every control.
Cloud + self-hosted
Azure OpenAI is a supported provider
Agents can call Azure OpenAI through PassControl with the same identity, scope, kill switch and receipts as every other provider. An Azure key is stored with the address of its resource and is only ever sent there.
The address is part of the key
Add an Azure key under Settings, Provider credentials, together with its resource address: `https://<resource>.openai.azure.com/openai/v1` or `https://<resource>.services.ai.azure.com/openai/v1`. Only those Microsoft-owned hosts are accepted, on every deployment, and the address is checked again on every call. A key with no usable address is refused with `409 endpoint_required` and never sent anywhere else. Opening Endpoint on a stored key shows the address it is sent to.
Chat and Responses
Azure's v1 API is served for Chat Completions, Responses and model listing. Point the plain OpenAI client at `/api/v1/azure/v1` and send your deployment name as the model. Hosted tools such as web search and code interpreter are refused, as they are for OpenAI.
No calculated cost
A deployment name does not say which model or price is behind it, so Azure calls are recorded with their token counts and no cost. An agent with a dollar limit is refused with `402 unpriced_endpoint` before anything is reserved. Token limits apply as usual.
The sidecar no longer tunnels to Azure
From CLI 1.1.0, `passcontrol sidecar` recognises Azure OpenAI resource hosts. A plain HTTP request to one is routed through the governed `azure` route, and a TLS tunnel to one is refused, including where `--allow-connect` names the host, because a tunnelled call would carry your key past every control. If you allowed an Azure host that way, point the client's base URL at the gateway's `/api/v1/azure/v1` instead.
Cloud + self-hosted
xAI (Grok) is a supported provider
Agents can call Grok through PassControl with the same identity, scope, budget, kill switch and receipts as every other provider, using xAI's Responses API.
Responses only
`POST /responses` on the new `xai` route, plus model listing. xAI's older Chat Completions endpoint is refused, because on it nothing documented bounds how much a reasoning model may generate. The connect snippets for an xAI key use `client.responses.create`.
Reasoning is billed
Output is counted as everything the call produced except its input, so a Grok model's reasoning tokens are charged at the output rate, as xAI charges them.
Search and other server-side tools are refused
xAI charges web search, X search and code execution per call or per item, outside tokens, so a budget could not hold them. A request that enables one is refused with `400 server_side_tools_unsupported` before anything is reserved or sent. Function tools your agent runs itself still work.
Cloud + self-hosted
A refused call no longer says the provider answered it
When PassControl refused a call, its recorded-call detail still said "Provider answered HTTP 403" (or 402 for a spending limit), and the Departures board on the Overview showed that code beside the refusal. No provider saw those calls. The provider's status now appears only on calls that were actually sent.
Nothing about the refusals changed
The calls were refused exactly as before and never left PassControl. The code shown was the one PassControl returned to the agent, and the signed receipt still records it; only the label that attributed it to the provider was wrong.
Cloud
If Cloud cannot check a stop control, it refuses the call
When PassControl Cloud could not read an agent's kill switch, suspension or policy, the call used to go ahead. It is now refused instead. A full outage of the store behind these controls already refused every call, so this changes the answer only for a partial failure, where the control could not be read but the call could otherwise have been sent.
Why refuse
A stop control is only useful if it holds when something else is going wrong. If you suspend an agent during an incident, a call should not get through because that one read failed.
What you might notice
The policy revision on signed receipts (`pol`) changes once, because how an unreadable policy is handled is part of the rules each call is checked against. Self-hosted deployments keep their own setting, and their default is unchanged.
`passcontrol update` updates the CLI and your self-hosted app
One command now brings a self-hosted PassControl up to date: it shows what will change, asks once, updates the CLI, then moves the app checkout to the latest release, installs its dependencies, applies new database migrations and restarts the dashboard. `passcontrol update --check` only shows the plan.
What it will not touch
A checkout with local changes, one that has diverged from the public repo, one that is not a clone of it, or one that was moved since setup is left alone, with the reason. Migrations are listed before anything runs, because they only go forward. If an update stops part way, `passcontrol update --app-only` finishes it.
The update notice works again
The CLI's "update available" line never appeared: the registry refused the request it made. It now reads the registry correctly and points at `passcontrol update`. Moving from 0.9.x to this version is one last `npm install -g passcontrol@latest`.
The CLI explains each refusal with the fix that applies
When the gateway refuses a call, `passcontrol call` and the MCP server now say what to change for that refusal, instead of one line per status code. An output-ceiling refusal says to send a smaller `max_tokens` and gives the limit; a spent daily or monthly limit says when it resets; a model or option PassControl cannot price says that raising the budget will not help.
New default models
Where a provider retired or stopped publishing a price for the default model, new setups suggest one it still prices: `gemini-3.8-flash`, `deepseek-flash`, `openai/gpt-oss-20b` on Groq and `openai/gpt-oss-120b` on Together. Existing configurations keep their model.
Cloud + self-hosted
A new draft policy starts from the live one
Writing a draft policy used to start from an empty form, so a draft written to add one rule, such as an output-token ceiling, would drop every other live rule when promoted. A new draft now starts as a copy of the live policy; change what you mean to change, and promoting an untouched draft changes nothing.
A policy the gateway cannot read is not copied
If the live policy is stored but unreadable, the draft starts empty and the button does not claim otherwise, because copying only its readable parts and promoting them would drop the rest.
Every price re-read, and a dollar limit now refuses what it cannot price
PassControl's price table was checked against every provider's own pricing page. Several rows were too low: OpenAI's newest and most expensive models had none and were estimated at `gpt-4o`'s rate, so `gpt-5-pro` counted at a twelfth of its price and `o1-pro` at a sixtieth, and DeepSeek's rows were about a quarter of today's prices. An agent with a limit in dollars could spend far past it. Now a call PassControl cannot price is refused under a dollar limit instead of guessed at.
What is refused, and only under a dollar limit
A model with no price of its own gets `402 unpriced_model`, shown as `UNPRICED` in the activity feed. A request asking for a more expensive processing option gets `402 unpriced_option` with the field named: a `service_tier` such as `priority` or `fast`, or Anthropic's fast mode or US-only inference; that one is returned to the agent but not written to the activity feed. Nothing is reserved or sent. Agents with only a token cap, or no cap, are not affected.
Prices
Each row is one model and its dated snapshots, read from the provider's page, where before a row could also price a newer sibling it had never seen. Where a page gives two rates, the higher is used: long prompts, cache writes, audio input, DeepSeek's peak hours. Models a provider has retired or no longer prices publicly lost their rows, including `deepseek-chat`, `deepseek-reasoner` and Groq's Llama models.
402, not 409
These refusals, and the existing `unpriced_endpoint` refusal for a custom endpoint under a dollar limit, now answer `402`, like a spent budget, instead of `409`. The OpenAI and Anthropic SDKs retry a `409` on their own, so a refused call was sent up to three times; a `402` is not retried. The `error` field says which refusal it is, and raising the budget fixes none of the three.
New defaults
Where a default model was retired or unpriced, new setups now suggest `deepseek-flash` for DeepSeek, `openai/gpt-oss-20b` for Groq and `openai/gpt-oss-120b` for Together. Existing agents keep the model they were set up with.
Not covered
An OpenAI project whose default processing tier is set to Fast in OpenAI's own settings is billed at the Fast rate even when a request asks for nothing, and PassControl cannot see that setting.
Gemini 3.8 Flash is priced, and is the new Gemini default
Google no longer offers `gemini-2.5-flash` to new accounts, so for anyone with a new Google account, an agent set up with the default Gemini model failed with a 404. The default is now `gemini-3.8-flash`, and it is priced at Google's rate instead of PassControl's most expensive Gemini rate.
Prices
`gemini-3.8-flash`: $0.75 per million input tokens and $3.75 per million output tokens, thinking included, Google's rate through 31 December 2026. `gpt-5-mini`, the OpenAI default, now has its own row too: $0.25 in and $2.00 out. Both were billed at the provider's highest listed rate before, which over-charged them.
Existing agents
An agent keeps the model it was set up with. Only new setups get the new default.
Cloud + self-hosted
Streamed Gemini calls are recorded with their real usage
Every streamed Gemini call was recorded as usage unknown and charged at least its full reservation, because Gemini reports usage on its last content chunk rather than on the separate chunk other OpenAI-compatible providers send. PassControl now reads Gemini's own ending, so a streamed call is charged what Gemini reports and shows as a normal call.
What counts as a finished stream
The usage on the last chunk before `[DONE]`, when that chunk carries a `finish_reason`. A Gemini stream that is cut off, or keeps going after that chunk, is still recorded as usage unknown.
Earlier activity
Streamed Gemini calls before this update keep their usage unknown status and the charge they were recorded with, which was never less than the reservation.
Cloud + self-hosted
Gemini thinking tokens are now charged
Google bills Gemini's thinking at the output rate but leaves it out of `completion_tokens` on the OpenAI-compatible endpoint PassControl proxies. PassControl charged only `completion_tokens`, so a non-streamed thinking call could be recorded at under half its real cost. Output is now charged as `total_tokens` minus the prompt.
What was wrong
A checked call reported 13 prompt tokens, 127 completion tokens and 304 in total. Google bills 291 output tokens for it; PassControl recorded 127.
What changes
Gemini calls are charged `total_tokens` minus `prompt_tokens` as output, never less than `completion_tokens`. A Gemini usage report without a usable `total_tokens` is treated as incomplete and charged at least its reservation. Other providers are unchanged.
Earlier activity
Calls made before this update keep the figures they were recorded with, so Gemini spend from non-streamed thinking calls before it is understated. Streamed Gemini calls were charged at least their full reservation instead.
Cloud + self-hosted
OpenAI hosted tools are refused, because budgets cannot hold their fees
OpenAI charges for web search, file search and code interpreter per call or per session, on top of tokens. PassControl budgets and receipts count tokens, so a request that could run one of those tools is now refused instead of being charged less than it costs.
What is refused
An `openai` request carrying a hosted tool (`web_search`, `file_search`, `code_interpreter`, `image_generation`, `mcp`, a hosted `shell`, or any tool type PassControl does not recognise), Chat Completions' `web_search_options`, or a stored Responses `prompt` is refused with `400 server_side_tools_unsupported` before anything is reserved or sent. The search models `gpt-4o-search-preview` and `gpt-4o-mini-search-preview` are refused as `blocked_endpoint`.
What still works
Tools your agent runs itself: `function`, `custom`, `namespace`, `computer`, `computer_use_preview`, `local_shell`, `apply_patch`, `shell` on a local environment, and client-side `tool_search`.
If an agent starts failing
This refusal is returned to the agent but not written to the activity feed, the same as a malformed request. If an agent that worked before now gets `400 server_side_tools_unsupported`, it is sending a hosted tool; remove the tool, or call that feature outside PassControl.
Cloud + self-hosted
A second local operator can sign up with the shared code
The source checkout's local stack showed a shared invite-code form, but Supabase also required a personal database invitation, so every signup from that form failed. The local stack now uses its shared-code flow, and a second operator account can be created through the form.
Hosted invitations keep their separate check
PassControl Cloud's database-invitation Auth hook is unchanged. The local stack is development setup, not a production signup configuration.
Cloud + self-hosted
Changes in the Control Tower appear as soon as they are saved
Saving a budget, promoting a policy, suspending an agent or adding a new one now updates the page as soon as the change is confirmed. Before this, a production deployment could keep showing the old state until you reloaded, even though the change had already been applied.
The cause was in the framework's bundled React
React could discard the signal to finish a background update when that update's data arrived while the page was still rendering. PassControl now applies React's own upstream fix to the copy Next.js 15 bundles, at build time, and stops the build if that copy ever changes shape.
Cloud + self-hosted
Suspending an agent now shows up in its audit log
An agent using a Direct Agent Key that was suspended had its calls refused as if the key were invalid: 401 invalid_credential, and nothing in the agent's audit log. It is now refused with 403 blocked_suspended, the same answer a passport agent gets, and each refused call appears in the log with zero tokens and no cost.
No calls got through
Suspended agents were always refused. What was missing was the record: during an incident, an operator could not see what a suspended agent kept trying to do.
Two records of a suspension, either one is enough
The gateway now checks the agent's stored status on every Direct Agent Key call as well as the fast suspension flag, so a suspension still holds if the fast flag is lost. Revoked agents keep getting the generic 401.
Cloud + self-hosted
Key import suggests current models first
After importing a provider key, the model picker used to show whatever the provider listed first, which for OpenAI meant GPT-3.5, old dated snapshots and text-to-speech models. It now suggests current general-purpose models, newest first, and lists everything else the key reports in a collapsed section marked as not recommended.
Suggestions are a convenience, not the grant
The allowed-models list is still the only thing that authorizes an agent, it still starts at the one model the agent will call, and any exact model id can still be typed into it. Ordering is a heuristic over model names, because the providers' model listings do not say which endpoint a model works on.
Nothing the key reports is hidden
Dated snapshots whose stable alias is also available, superseded families, and audio, image, embedding and moderation models move to the secondary list rather than disappearing.
Cloud + self-hosted
Connect a worker in one pass, and prove PassControl can refuse it
Importing a provider key now ends by giving one worker its own Direct Agent Key, which its existing SDK can use in place of the provider key. The worker's setup can be reopened later from its agent page. The first-run guide now finishes when that same worker has made one allowed call and then been refused a model outside its access.
Passport is still one click away
The key-import screen offers a Passport as the stronger alternative for code that can sign challenges. Nothing about existing Passport agents changes.
No credential for a provider PassControl cannot use
Creating a worker's key is refused, with a link to add one, when no key for that provider is stored. If PassControl cannot tell which keys are stored, it says so instead of guessing.
Setup without the secret
Each Direct Agent Key agent has a Setup section with the install command, the configuration file, how to load it, and a smoke test. The key itself is a placeholder there, because PassControl keeps only a hash. A lost key is replaced with a new one, not recovered.
The refusal is the proof
After the first allowed call, the guide asks you to start a refusal test and names a model this worker is not allowed to use. When that request comes back as `blocked_scope` it is never sent to the provider and costs nothing. A kill switch, another agent's refusal, or an older refusal does not complete the guide. Accounts that finished the old guide stay finished.
Cloud + self-hosted
Suspend and the kill switch now show what they observed
Each agent's page now opens with its state, what it may reach, its caps and a Suspend button. Suspend, reactivate and the fleet kill switch now report what PassControl checked afterwards, including when it could not check.
Could not confirm is its own answer
If a stop control's result cannot be read back, for example because the store behind it is unavailable, the dashboard says it could not confirm the change and offers to retry the same action. It no longer says nothing changed, and it never undoes a stop on its own.
Partial states are visible
Reactivating an agent checks both the agent's status and the gateway's suspension flag. If one of them has not caught up, the page says calls are still refused instead of calling the agent active.
An unreadable kill switch is not shown as off
When the fleet kill switch cannot be read, the bar says so, and says whether this deployment refuses or admits calls in the meantime. PassControl's platform-wide stop is shown separately from your own switch.
Clearer words for three different stops
Suspending stops one agent. Revoking an installation key stops only that key. The kill switch stops every agent in the workspace. None of them recalls a call already sent. When an access change takes effect now depends on how the agent authenticates, and the page says which.
Cloud + self-hosted
Call history names the worker and says why a call failed
Live calls, the call history and the call detail now show the agent's name first, use one set of words for every outcome, and send you to the place that fixes it. Spend figures now say which numbers were reported by the provider, which were calculated, and which are unknown.
One word for one outcome
A call is allowed and forwarded, refused by the agent's access, a policy rule or a budget cap, stopped by suspension or the kill switch, failed at the provider, or not sent because PassControl could not complete a check. The board, history, detail view and agent page now use the same words. A Direct Agent Key refusal no longer reads "NO VISA", and a provider error no longer reads "DIVERTED". Only deliberate refusals are counted as refused.
The next step for each failure
The call detail leads with the outcome, the provider's own HTTP answer when one was recorded, and the agent. A provider 401 links to provider credentials, a scope refusal to the agent's access, and a PassControl-side failure to System health. The Setup page lists the errors a worker can see and which of them leave a call record.
Reported, calculated, unknown
Token counts are the provider's report. Costs are PassControl's list-price calculation, not an invoice. A refused call shows as not sent rather than as zero tokens, a missing count shows as not reported, and a call whose usage was never confirmed is kept out of the reported totals and counted separately.
Caps are cumulative
Agent token and cost caps are labelled cumulative: they count every charged call and do not reset monthly. The hosted monthly allowance is shown separately, in calls.
Receipts
"Verify a receipt" on the home page now opens the receipt verifier instead of the passport verifier. The landing copy now says a governed call returns a receipt ID and records a signed receipt, rather than returning the receipt itself.
Cloud + self-hosted
Budget checks now count system prompts and tools
Before a call is sent, PassControl estimates its size to decide whether the agent's cap can cover it. That estimate now includes the system prompt, tool definitions and Responses instructions, not only the messages.
Near-cap agents are refused sooner
A request with a large system prompt or many tools used to be estimated as if it were a short chat, so an agent close to its cap could be admitted for a call its remaining budget did not cover. Such requests now reserve more up front. What is finally charged is still the provider's reported usage.
Cloud + self-hosted
Give each agent a daily or monthly spend limit
Next to the lifetime caps, an agent can now have a spend limit per UTC day or per UTC month. It is checked in the same step that reserves a call's cost, so concurrent calls cannot run past it together.
A refusal says when to try again
A call that would take the agent past its limit is refused with `402 blocked_budget_period` before anything is sent, and a `retry-after` header gives the seconds until the next reset. Because it is a 402, SDKs do not retry it on their own.
Today means today
Setting a daily limit in the afternoon counts what the agent already spent that morning. A call counts toward the period in which it finishes, the same moment it appears in Activity, so the dashboard and the limit agree.
What is still running is shown, not hidden
The fleet table and the agent page show this period's spend and any reservations still in flight. An attempt that never finished keeps counting against the limit until it is resolved, and it is named as such rather than folded into the total.
Cloud + self-hosted
Set a maximum output size for each agent
An agent's policy can now carry an output ceiling. Every request from that agent must state its own output limit at or under the ceiling, or PassControl refuses it before anything is reserved or sent.
Refused, never shortened
A request whose `max_tokens`, `max_completion_tokens` or `max_output_tokens` is missing or larger than the ceiling is refused with `403 blocked_policy`. The response names the rule and the ceiling, so the agent can fix its request. PassControl never edits the request to fit, because a silently shortened answer looks the same as a model that stopped on its own.
Try it in shadow mode first
The ceiling is part of the policy draft on the agent page, so you can watch how many real requests it would have refused before promoting it. Many SDKs send no output limit by default, and shadow mode shows that before it matters.
It bounds each request, and with the hourly cap, each hour
Combined with the hourly request cap, the ceiling puts a stated upper bound on how much output an agent can ask for in an hour, without any extra state.
Cloud + self-hosted
Budget checks count every completion a request asks for
The pre-call estimate now reserves the largest output limit a request states, multiplied by the number of completions it asks for with `n`.
Requests for several completions reserve for all of them
A request with `n: 4` used to be sized as one completion, and a request stating both `max_tokens` and `max_completion_tokens` was sized by whichever came first rather than the larger. Both could admit a call that a near-cap agent's budget did not cover. Such requests now reserve more up front; what is finally charged is still the provider's reported usage.
The CLI checks a dashboard's PID is still its own before stopping it
passcontrol remembers the process ID of the local dashboard it starts. If that dashboard died without cleaning up, for example after a reboot, the same ID could later belong to an unrelated program, and `passcontrol stop` would have stopped it. The CLI now checks the process is still the dashboard it started.
Stale records are cleared, not acted on
If the recorded process is now something else, the CLI forgets the old record and reports that no CLI-managed dashboard is running. If it cannot tell, it stops nothing and tells you which process to check.
Cloud + self-hosted
Verify pages say who else can check your receipts
A self-hosted instance whose issuer is localhost, a private address or a .local name now says so on its verify pages: receipts it signs verify on that machine, but nobody elsewhere can fetch its keys to check them.
Nothing is switched off
Local verification still works and still proves a receipt was not altered. The pages now name the issuer, explain the limit, and point to PASSCONTROL_ISSUER for making receipts checkable from outside. An instance with no issuer is told it signs no receipts.
Cloud + self-hosted
A Passport secret sent as an API key is now named and flagged
A Passport secret is a private key, and some clients invite you to paste it into an API-key field. When that happens the gateway now recognises it, refuses it with its own error, and marks the agent so you know to rotate the key.
Proof, not a guess
The gateway derives the public key from what was sent and checks it against your agents. A match means that agent's private key was transmitted, so the refusal says so (passport_secret_presented_as_bearer) and the agent's page shows "Passport private key exposed — rotate it" until the passport is rotated. The key is never logged or echoed back.
Failed visa floods are limited
Repeated failed visa authentications from one address are now limited, as Direct Agent Key attempts already were. Valid visas are never counted by this limit.
Clearer instructions when the key is shown
The passport screen now lists a client's API-key field among the places the secret must never go, and the sidecar instructions say the tool gets a placeholder while only the sidecar holds the secret.
Cloud + self-hosted
The kill switch, access editor and overview no longer show a state they have not confirmed
Three places in the dashboard could show a confident answer they did not have. Each now says what it actually knows.
A lost kill-switch response reads "not confirmed"
If the dashboard loses the response to an arm or disarm, the change may already be in force. The fleet safety bar used to keep showing the state from before the click, so it could say "Fleet operational" while calls were being refused. It now says the state is not confirmed and offers Refresh status and Retry until it reads the switch again.
Access changes state when they take effect
A Direct Agent Key is checked against the agent's current access on every request, so a change applies to its next call. Only a passport work-visa keeps the access it was issued with until it expires. The access editor used to describe the visa delay for every agent, including agents with no passport. It now states the rule for the credentials the agent actually has.
A failed agent read is not zero agents
When the agent list cannot be read, the overview now shows the agent count, settled budget charges and needs-attention figures as unavailable. Before, it showed 0 agents and $0.00 beside a call history that correctly said it was unavailable. An empty workspace now reads "No agents yet" instead of "No passports issued".
Cloud + self-hosted
A runnable example of the whole integration
examples/direct-key-worker.mjs is an ordinary OpenAI-SDK worker configured only by the file the agent's Setup page gives you. It makes one governed call and one call PassControl refuses, and reports each for what it was.
Two variables, no code changes
Save the Setup page's passcontrol.env, load it, and run the script. It calls the model the agent is allowed to use and prints the provider-reported usage and the receipt ID, then asks for a model outside the agent's access and shows the 403 blocked_scope refusal, which never reaches the provider.
It does not overclaim
If the agent's access admits the test model, the script says it was allowed instead of calling it a refusal. A provider error is reported as the provider's. It exits successfully only for one forwarded call and one refusal, never prints the key, and turns off the SDK's automatic retries so each line is exactly one governed call.
Cloud + self-hosted
An upstream that repeats your provider key can no longer pass it to the agent
PassControl adds your real provider key to each request after all its checks. If the service at the other end repeats that key in its answer, in an error message, a JSON reply or a stream, the agent now receives [REDACTED_PROVIDER_KEY] instead.
Every path back
Error bodies, ordinary JSON answers and event streams are all covered, including a key that arrives split across stream chunks. Streams are not slowed down: output is only held back while it could still be the start of the key.
Recorded, without the key
When a key is removed the gateway raises a security event naming the provider and agent, never the key or the body that carried it. Usage accounting and receipts are unchanged.
What it does not claim
It removes the exact key PassControl sent. A key the other end has encoded or cut up is not recognised. This matters most for custom endpoints, where the service at the other end is one you configured.
Cloud + self-hosted
A custom endpoint is stored exactly as you typed it, or not at all
A custom endpoint containing %2e%2e, a half-encoded .%2e, or a backslash used to be saved as a different path, because URL parsing resolves those as "..". Such an endpoint is now refused with the existing "not an endpoint we can use" message, so the address that receives your provider key is always the one you read.
The same rule for request paths
The gateway also refuses an encoded "." or ".." segment in the path it forwards, the same as a literal one. Other encoded characters, such as a model name containing %2F, pass through unchanged.
Cloud + self-hosted
OpenAI calls through the gateway no longer fail with a duplicate parameter error
The gateway was adding its own internal routing values to the query string of every request it forwarded, and OpenAI rejected those calls outright with a duplicate parameter error. Requests now reach the provider carrying exactly the query string the client sent, and nothing else.
A standard OpenAI client works against a fresh install
Pointing the official OpenAI SDK at your gateway and calling chat completions now works, including the Python SDK's structured-output parse helper. Both base URL spellings are accepted, with and without the version segment, so it does not matter which one your client is configured with.
Every provider was affected; only OpenAI said so
The same internal values were being sent to Anthropic, Groq, Mistral, Together, DeepSeek and Gemini, all of which ignored them rather than refusing. Those calls were succeeding, so nothing was broken for them, but the requests were not clean and they are now.
Your own query parameters still travel
Anything a client legitimately sends is forwarded exactly as written, including the pagination parameters on a model listing. Only the gateway's own routing values are removed.
Technical details +
Fixed
OpenAI chat completions through the gateway no longer return a 400 naming a duplicate path parameter.
Requests forwarded to every provider carry only the client's own query string. The values that were being added were internal routing values, never credentials.
A model listing called with pagination parameters forwards them unchanged.
Cloud + self-hosted
Importing a provider key starts an agent at one model, not fifty
Key import used to copy every model the provider listed straight into the new agent's capability grant, which filled it to the maximum a grant may hold. Adding one more model of your own was then rejected as invalid. The grant now starts at the single model the agent will call, and the rest of the listing is offered as one-click additions.
What the key can reach and what the agent may use are now separate
Detection describes the key; the grant authorizes the agent. The import screen tells you how many models the provider reported and how many of those this gateway can actually route, and shows how many of them it is listing rather than implying the list is everything.
Models the gateway cannot call are no longer suggested
A provider's listing includes embedding, speech, image and moderation models, and those were being granted alongside chat models. The gateway has no route to them, so authorizing them added nothing but noise to an audit trail whose value is that every line means something. Where a provider's listing does not look like what we expect, the full list is still offered rather than an empty one.
The model to call is a chat model
The concrete model sent to the provider used to default to whichever id the provider happened to return first, which for OpenAI is an embeddings model. It now prefers the model the documentation names.
Technical details +
Fixed
Adding a model to a scope produced by key import no longer fails with an invalid models error.
The allowed models field is no longer pre-filled to its maximum before you have chosen anything.
The model to call defaults to a model the gateway can actually send to a chat endpoint.
Cloud + self-hosted
The Fleet table's Actions menu opens where you can see it
Opening Actions on an agent in the Fleet table showed only the top edge of the menu; the rest was cut off by the edge of the Fleet section. On a workspace with only one or two agents, which is every new one, the menu was hard to find at all. It now opens fully in view.
The menu is no longer trapped inside the table
The menu used to be drawn inside the Fleet section, which hides anything that spills past its rounded border. On narrower windows the table's horizontal scroll area clipped it as well, and Windows display scaling makes an ordinary laptop count as a narrower window. The menu now sits above the page, anchored to its button, opens upward when there is no room below, and follows the button while you scroll. The phone layout, which shows these actions as plain buttons, is unchanged.
Technical details +
Fixed
Fleet: the Actions menu on an agent row opens fully visible instead of being clipped by the Fleet section, on any number of agents and at any window width.
Self-hosted
The documentation starts with a first call, helps when you get stuck, and agrees with the code
The public README now explains what PassControl does, which setup fits you, and how to reach a governed call before it introduces protocol detail. A new self-host guide is organised around what you see when something goes wrong. Every public document was checked against the CLI, the gateway and the tests, and its commands now run on Windows as well as macOS and Linux.
A first run you can follow, with the limits still visible
The self-host path goes from installation and setup to a Passport call, a Direct Agent Key call, a budget refusal and the kill switch, in the order you would try them. Authentication, accounting and operator detail is still there, further down or in the tutorial and reference. The README still states that PassControl is not independently audited, that the public developer API has not shipped, and what gateway controls and receipts cannot prove.
Help organised by symptom, not by subsystem
`docs/self-host.md` starts from what you see. It covers prerequisites for each operating system, which lifecycle command to reach for, and the refusals that are correct behaviour but read like bugs: a Passport private key pasted where an API key belongs, a system clock that is wrong, a sidecar address missing its provider path, a suspended agent, and a budget whose counters cannot be trusted. It links to the existing budget recovery and production guides rather than repeating them.
Examples you can paste on any system
Examples that relied on curl, jq or inline environment variables are now short Node commands or plain instructions, and configuration snippets are marked as file contents rather than shell commands. The demo scripts remain bash scripts and now say so.
Technical details +
Fixed
The sidecar base URL is documented with its provider path, for example http://127.0.0.1:8788/api/v1/anthropic. A bare port fails with an error that points at the provider instead of the address.
The error reference lists invalid_credential for a rejected Direct Agent Key and blocked_budget_state for a budget whose counters cannot be trusted.
The logs endpoint is documented as supporting cursor pagination, which it already did.
Release numbers pinned in the SDK and statement-format documents were removed rather than left to go stale.
local-logs --follow is documented as unavailable on Windows, which matches what the command does.
0.9.5 — setting PassControl up on Windows works, and stops reporting success it did not have
Every part of this release came from one self-hoster installing PassControl on Windows and writing down what happened. Three separate things were broken along that path, and each of them failed in the same style: the tool said the right thing while doing the wrong one. The dashboard printed a setup command that could not run on the machine it was printed for. `passcontrol start` reported the local dashboard online and then stopped it. The onboarding steps asked you to copy a secret and then immediately asked you to copy something else over it. None of these produced an error a reader could act on, which is why all three survived a green test suite.
The commands the dashboard gives you can be pasted into the shell you actually use
After issuing a passport, the dashboard shows a `passcontrol passport import` command and a sidecar setup snippet. Both wrapped their values in single quotes, which is how bash and zsh work and is not how Windows `cmd.exe` works: cmd passes the quotes through as part of the value. So the command was copied, pasted, and refused, and the refusal talked about the value being wrong rather than about the quotes nobody typed. The commands now carry no quoting at all, which is correct in cmd, PowerShell, bash and zsh at the same time.
The local dashboard keeps running after the command that started it finishes
On Windows, `passcontrol setup` and `passcontrol start` reported the dashboard online and then took it down as they exited. The report was accurate at the moment it was printed, which is what made it hard to see: the server was running, answering, and gone a second later, with nothing in its log because nothing had failed. It is now started as an independent process, as it always has been on macOS and Linux, and stopping it still stops the whole tree.
The setup steps are in the order you carry them out
The passport panel asked you to copy the one-time secret, then to copy the import command — which replaced the secret on your clipboard before the command had asked for it. Since the secret is shown once and cannot be retrieved, the flow destroyed the thing it had just told you to keep. The command comes first now, and the secret is copied at the point the CLI is actually waiting for it.
Technical details +
Fixed
A passport whose ID begins with two hyphens can now be imported. Roughly one issued ID in 4096 does, because the encoding's alphabet includes the hyphen, and the previous command form read such an ID as an option name rather than a value. This was never platform-specific; the quoting that appeared to prevent it never did, because the shell removes quotes before the command sees them.
`passcontrol stop` no longer refuses to stop a dashboard it started, with a message about needing to be forced. It already knew how to escalate and was giving up one step early.
A rejected `--gateway` or `--id` now says when the value still has quote characters attached to it, instead of describing only the string that arrived.
Values in a generated command are checked, not quoted
The builder behind those commands validates each interpolated value against a conservative character set and emits it unquoted, rather than wrapping it in a syntax only one family of shells understands. A value that fails the check raises an error instead of being quoted, because a command assembled around an unchecked value is the problem quoting was there to solve, and nothing that reaches the builder can legitimately fail it. The gateway and the passport ID use the `--flag=value` form so an ID beginning with a hyphen cannot be read as an option.
Why the tests did not catch any of this
All three defects lived between two components that were each correct alone: a command generator and a command parser, a process launcher and the platform's idea of process lifetime, two user interface steps and the single clipboard between them. The new tests assert across those joins — one runs a command the dashboard generated through the shipped binary and checks it still means the same thing. The Windows lifecycle assertions read the code that decides rather than the behaviour, because that behaviour cannot run on the machines the test suite runs on; that limitation is stated in the tests themselves.
0.9.4 — passport import no longer removes the key it was replacing
0.9.3 shipped `passcontrol passport import` with a cleanup step that removed the stored key when an import failed part-way. That is correct when the import created the item, and wrong when it did not. Re-importing the Passport ID that was already configured is exactly the case where it did not, and the 0.9.3 note described the safe version of this as though it always held. This release makes the code match that description, and corrects the description.
A failed import can only remove an item it created
`passport import --replace` accepts the Passport ID that is already configured. Credential stores are keyed by that ID — on macOS the Keychain entry is updated in place, and on Windows the protected file is named from the ID — so on a same-ID import there is no new item, only the one already there. If the store then failed to return what had just been written, or the configuration file could not be saved, the rollback deleted that entry and left the machine still configured to look for it. The command now reads the store before writing, and removes an entry on failure only when it was the one that put it there.
An import that would change nothing no longer rewrites anything
The command already refuses to continue unless the secret you paste derives the Passport ID you named, so a same-ID import is provably the same key the store is already holding. When that is the case it is verified and left alone rather than written again, because rewriting it cannot change its value and the write is the step that can fail. The command tells you that is what happened, instead of reporting an import that did not move anything.
Technical details +
Changed
A configuration file that cannot be written now says so in the error, along with what happened to the key, rather than reporting only the underlying filesystem message.
The sidecar setup on the passport screen no longer repeats the `passcontrol passport import` command that the step above it already gives you. Following both steps as written ran the import twice, and the second run was refused because a passport was by then configured.
Fixed
A correction to the 0.9.3 note, which said that if the credential store does not return what was just written, the new item is removed and your configuration is left exactly as it was. That was true only when the import created a new item. On a same-ID replacement it removed the key that was already there.
The rollback is now somewhere it can be tested
The write, read-back and commit sequence moved out of the CLI entry point into `cli/passport-key-store.mjs` as `importPassportKey`, which takes the credential store and the configuration write as arguments. The previous version could not be tested without a real Keychain, which is why a rollback that deleted the wrong item went unnoticed. One limit remains, and it is worth stating: a store that is locked reads the same as a store that is empty, so this narrows the window rather than closing it.
0.9.3 — the budget counter shows its own arithmetic, and a passport reaches a machine without passing through your shell history
Two unrelated pieces of the product had the same problem: they asked you to trust a number, or a key, without showing you where it came from. The all-time spend figure that budget admission charges against was a single number with nothing behind it, so a divergence between it and the stored call history had nowhere to appear. And a passport issued in the dashboard reached the machine that needed it by being typed into a command, which put a private key into shell history and process arguments. This release puts the arithmetic behind the first one and removes the shell from the second. The local self-host commands also stopped consulting your active gateway to decide what they are managing, which is what made running a local stack alongside a hosted one awkward.
A difference between the counter and the call history is shown, not hidden
The figure your budgets are enforced against is now labelled Settled budget charges and displayed as an equation: the charges attributable to durable call rows, plus operator adjustments, plus whatever difference remains against the live counter. When the two disagree, the size of the gap is on the page. The read that explains the total is read-only and touches neither side, so examining a divergence cannot change it, and it uses exactly the same view and adjustment precedence the reconciliation job uses — a difference here is a real difference and not two functions counting differently. If the database or Redis cannot be read, the panel says unavailable. It does not say zero, because those are different facts about money.
Money the gateway is holding is kept out of money it has spent
Open budget holds are shown separately and marked not yet charged. A hold is an estimate the gateway reserved for a call still in flight; some of it will be returned. Adding it to a settled total would report money as spent that may never be spent, and subtracting it would hide traffic you are on the hook for. It is its own line for that reason, and when the live reservation state cannot be read it says so rather than reporting zero open holds.
Import a passport without putting its secret in a command
`passcontrol passport import` reads the secret from a hidden prompt, so the command you run and the command your shell remembers contain no key. It derives the public key from what you paste and refuses unless it matches the passport ID you named, which means a mistyped key fails at the prompt rather than becoming a machine that cannot sign a challenge. The key goes into the macOS Keychain, the Linux Secret Service or Windows DPAPI, and is read back and compared before your configuration is touched at all: if the store does not return what was just written, the new item is removed and your configuration is left exactly as it was. Replacing a passport that is already configured needs an explicit flag, because the key being replaced cannot be recovered.
Running a local gateway no longer argues with your hosted one
`setup`, `start` and `restart` used to read whichever gateway your agents are calling in order to decide what they were managing, and refused outright when it pointed at a hosted PassControl. They now manage the local dashboard independently of that. The machine-wide active gateway is only rewritten once PassControl at the local address has answered and its launcher is still running, so a start that never comes up leaves your configuration untouched. `status` reports the active API gateway and the local dashboard as two lines with separate health, because one of them being down says nothing about the other.
Dropping credentials is its own decision
Switching this machine to a local gateway forgets the credentials bound to the previous one. That now requires an interactive confirmation or an explicit flag, and a general yes-to-everything flag is not accepted in its place — deleting a key is not the kind of thing a convenience flag should authorise. Forgetting them locally does not revoke the remote key or the passport, so the command names the remote identity that may still need cleaning up. A gateway-bound value coming from your environment or a project file is refused and named before any service starts, rather than being overwritten by a machine-wide setting that the environment would immediately shadow.
The call history pages back through the stored rows
The call table stopped at its first window. It can now load older durable rows on demand, using a cursor over the stored order rather than an offset that shifts underneath you as new calls arrive.
Technical details +
Changed
The CLI and the dashboard now read one shared list of default models per provider instead of keeping a copy each. A test walks every supported provider through the real scope matcher, so the default model a passport is issued with is provably a model its own scope permits.
The interactive command browser lists `passport import` under Trust, next to the command that moves a file-stored key into the OS credential store.
`--port-offset` moves the local Supabase and Redis ports only. The local dashboard stays where it was, so the address you have bookmarked keeps working.
Fixed
A local URL with no port is now refused instead of being interpreted as port 80, which is not a port any local PassControl dashboard listens on.
One migration accompanies this release
The spend explanation reads through a new read-only database function that performs no reconciliation and no writes, is restricted to the service role, and repeats the tenant boundary inside itself. Apply the migration before deploying the new code: without it the panel correctly reports that no durable explanation is available, which is accurate but is not what the feature is for. No configuration change is required, and nothing in this release changes the shape of a receipt, a work-visa, or the proxy API.
0.9.2 — what PassControl is allowed to claim when something fails
This release is the result of an adversarial security review of the whole gateway, run in four passes over the credential path, the authentication classes, the caches and money, and the truth claims. Twenty findings came out of it and all twenty are closed here. Almost every one had the same shape: PassControl refused, or degraded, exactly as it should — and then described what had happened in stronger terms than it had actually established. A read that failed became a zero. A cache miss became a rotation. A retired signing key became a forged receipt. The refusals were right; this release makes the statements right.
A receipt stays checkable after you rotate your signing key
Receipts never expire, and anyone holding one verifies it against the keys your deployment publishes right now. A deployment could publish two: the current key and the one before it. So the second rotation silently dropped the first, and every receipt ever signed under it stopped verifying — for whoever was holding it, permanently, with nothing you could do afterwards to reach them. A deployment can now publish every key it has ever retired, in a list that is meant to be appended to and never trimmed. The verification page also stopped saying a receipt whose key it cannot find has been altered: that is not something the check establishes, and a withdrawn key produces the same result.
A dashboard that could not read no longer reports zero
The Control Tower loads your governed calls once and shares that one result with the fleet cards, the live board, the spend chart and the activity history. When that read failed those panels received an empty result and stated it as fact — zero refused calls, no call history, no recent spend — while a panel further down the same page correctly said the read was unavailable. The page was giving two answers at once and the confident one won, during the exact database fault that makes audit evidence least reliable. Every panel now says unavailable, calls that arrive live in that state are labelled as having arrived since the page loaded rather than presented as the whole window, and a downloaded support bundle marks its failure sample unavailable instead of shipping an empty list.
A change you make applies to the next call, not the next credential
Several settings were written to the database, confirmed on screen, and then not read again for up to fifteen minutes. Lowering an agent's budget left any work-visa it already held gating on the old figure. Switching an agent to required proof-of-possession left a window in which a stolen visa was still accepted. Rotating a provider credential, or changing where it is sent, could have a lookup that began before your change finish afterwards and put the old copy back. All of these now take effect on the next request, and a late write that would overwrite a fresh change is refused rather than published.
A refusal now names its own cause
Immediately before sending, the gateway re-checks that the destination and the secret still come from the same version of your credential, and refuses if they do not. It was reporting that same refusal — in the audit row and in the signed receipt — when the check could not be completed at all, so a cache read failure was described to whoever read the receipt as an operator rotating a credential mid-call. Those are now two different answers. The same correction reached the spend figure: the amount charged against a budget is now labelled as what was charged rather than as what a provider billed, because for a call PassControl cannot price those are deliberately not the same number.
A restore that restores nothing is no longer reported as complete
A truncated or hand-edited workspace export that had lost its agents list was read as a workspace with no agents. The import created nothing, reported complete, and exited successfully — on the one command whose whole purpose is stopping a partial restore from looking like a whole one. Both the command line and the import API now check the file's shape before planning anything. A missing agents list is a damaged file and is refused; an empty one is a workspace that genuinely has no agents and still imports exactly as before.
Self-hosting: the custom provider endpoint reaches your server
Pointing a credential at your own model server was announced on 6 September and had never worked. With the endpoint mode set, every proxied call was refused before it was sent, on every provider, whether or not that credential named an endpoint at all. That is fixed, and your base URL now owns its own version segment rather than having one appended to it. Neither defect ever sent a credential somewhere it should not have gone.
What this release does not claim
None of these findings is known to have been triggered in the wild, and none of them exposed a provider key, crossed a workspace boundary, or altered a stored call record. The review was adversarial and thorough, and it was still one review of one codebase: PassControl remains unaudited by an independent third party, and the public developer API has deliberately not shipped. Two smaller items from the same review are known and not fixed here — the attempt counter on device-authorization codes is not atomic under concurrency, and the cache purge that follows a credential change stops at 200 agents in a single workspace.
Technical details +
Changed
Self-hosting only: an agent whose budget is set in dollars can no longer call a custom endpoint. PassControl cannot know what your endpoint charges, and it was enforcing the cap with the built-in provider's list price. It now refuses the call and says so. Budgets set in tokens are unaffected, and so is every built-in provider. Remove the dollar cap, or route that agent through a provider PassControl prices.
Anything that parses a call status, in the audit table or on a receipt, can now see credential_state_unavailable. It means the gateway could not confirm your credential was unchanged, answered 503, and sent nothing. It is not a report that anything changed.
The fleet overview card that read Tracked spend now reads Charged to budgets, and the control API reports the basis of that figure on every row. The amount is unchanged.
Self-hosting only: rotating the receipt-signing key now has a first step. Record the retiring key's public half in the new signing-key history, which is what keeps receipts signed under it verifiable, and only then rotate. The command line prints the exact value to store.
Fixed
The public documentation still introduced itself as 0.9.0, told you to install that exact version, and described several behaviours as being how 0.9.0 works — three releases after 0.9.0. The install commands are no longer pinned to any version, the titles no longer carry one, and the behavioural notes are stated in the present tense after re-checking that each still holds. The OpenAPI description now reports the same version as the package.
On a Mac, moving a passport key into the login Keychain had never worked from a terminal. The command hands the key to the system keychain helper on its standard input, but whenever a terminal is present that helper ignores what it was given and asks the person to type the key instead — twice. So the command appeared to hang, gave up on its own timeout, reported that the move had failed and left the key in its file. It worked correctly everywhere there is no terminal, which is why every automated check passed. The helper is now run with no terminal to ask on.
Calls from a killed workspace or a suspended agent were counted against that agent's per-minute request allowance before being refused, so traffic during an incident could keep the allowance exhausted after the stop was lifted.
The 4 MiB request limit was measured in characters rather than bytes, and an upload that never declared its length was read in full before being refused. It now bounds bytes and stops reading at the limit.
Google and Gemini API keys are 39 characters, just under the general long-token threshold, so they were not being scrubbed out of free text you type into PassControl. They are now removed explicitly.
Withdrawing, changing or demoting a verified owner could be undone for up to five minutes by a lookup that had begun before the change, and a receipt signed in that window would have carried the old claim indefinitely. The same protection now covers the failover list and the stored provider list.
The protection that refuses calls when a budget's spend counters have gone missing had a gap in the window after an agent's first budgeted call, where a missing counter was read as a fresh start instead.
Security
Revoking a control-plane key has always taken effect immediately, but the database had no opinion about the reverse: the browser permission that lets you revoke a key was, read literally, permission to write that field in either direction. Nothing in the product ever wrote it backwards; a session that had never completed two-factor could have. The database now refuses.
A per-request limit that ran after the work it was meant to bound is now enforced before it.
Cached values the gateway signs or enforces against are now written behind a fence, so a read that started before your change cannot republish the old value after it.
INSTANCE_SIGNING_KEY_HISTORY
A new optional setting for self-hosted deployments: a comma-separated list of the public halves of every signing key you have retired, each written as its key id and key together. Generate the value with the keygen command's retire flag. It takes public keys and never private seeds — a retired key you keep is one that can no longer sign — and each entry is checked against its own key id, so a mistyped or wrong value is ignored rather than published. Leaving it unset behaves exactly as before.
Upgrading
No configuration change is required. Two database migrations accompany this release and should be applied before the new code is deployed. Nothing in this release changes the shape of a receipt, a work-visa, or the proxy API.
Cloud
Your Cloud allowance now counts only what you actually used
Four changes to how the hosted beta allowance is counted and administered. They are listed separately from the 0.9.2 release above because the allowance is part of the hosted service rather than the software: none of this is in the package you install when you self-host, so it carries no version number.
Refused calls are no longer charged
A request PassControl refused — by scope, by budget, by the kill switch — was still coming out of your monthly calls. The allowance is now charged at the moment a call is handed to the provider, and not before. A provider having a bad minute still costs you the one call you made, not the retries behind it.
Model discovery is no longer charged
Most SDKs list the available models when they start, and some ask about a specific model before every prompt. Those questions were coming out of your allowance, which meant the per-prompt ones could quietly double what a workload appeared to use. They are not charged now. This is not an unmetered path: it is still rate-limited and still recorded, and your usage figure may simply read lower than you remember.
The allowance can be set per workspace
It used to be one figure applied to every workspace in the beta. A workspace that needs more room can now be given it without changing anyone else's. Your dashboard and the gateway always read the same number, a change applies to your next call rather than the one in flight, and lowering a limit stops new calls without undoing anything you have already done. If we cannot read your allowance at all, we refuse rather than guess.
Allowance can be returned without erasing history
We can now return a month's allowance without deleting anything. Your call history stays exactly as it was; only what counts against the current month changes. Your workspace allowance and the shared beta ceiling remain separate figures. The operator warning for damaged reset metadata was also fixed — labels left over from an earlier reset could hide it, which made a damaged month look healthy.
Running the sidecar is how an agent gets sender proofs, but it and the MCP server were the two commands the interactive menu never listed — so the people who most needed them could not find them. This shipped in 0.9.1 and is recorded here after the fact; the note it was owed was written at the time and then missed when the release went out.
The command that required mode requires
The sidecar is the path that signs a proof for each provider request, which is what required sender-proof mode checks. It had been kept out of the menu because it is a server that holds the terminal until you stop it, and a menu entry that appears to hang is its own kind of bug. The effect was that turning on required mode pointed you at a command the front door did not mention.
Both of them, in one place
The sidecar and the MCP server now share a group called Connect an agent, which is the second entry in the menu. They are the two ways an agent's traffic reaches the gateway, so they belong next to each other rather than filed among the diagnostics. Making room for them cost a heading that told you the list underneath it was a list of groups.
Listed without being launched
Selecting either one prints the command to run and returns you to the menu rather than starting a server underneath it, the same way the entries that need an agent id or a prompt behave. You get the command line to copy, and the menu stays a menu. That is why they can be listed at all: both hold the terminal until you stop them.
Each one says the thing that catches people out
For the sidecar: an agent's base URL has to include the provider path it serves, not just its host and port. Aiming a client at the bare address fails with an error that reads as though the provider returned nothing, which sends you looking in the wrong place. For the MCP server: its chat sends a bearer visa and attaches no sender proof, so a workspace with sender proofs set to required will refuse that client while the sidecar keeps working.
Self-hosted
Setting up locally on Windows now works
Self-hosting on Windows failed on the first command with a five-word error, and the next two things you would try failed differently. Three separate causes, none of them named by the message you saw.
The first command no longer stops at EINVAL
Recent Node versions refuse to launch a Windows batch file without a shell, and the setup command launches npm, which is one. It reported `spawn EINVAL` and nothing else — a message that names no file and suggests no fix. Every place the command line reaches for npm now runs it the way Windows requires. The places that reach for Docker and Supabase deliberately did not change, because passing those through a shell would break a checkout whose path contains a space.
The bash it needs is the one you already have
The local stack is brought up by a bash script, and the `bash` that Windows puts on the path first is normally not a shell — it is the Microsoft Store placeholder, or the launcher for a Linux subsystem that may have nothing installed behind it. That second one fails with a message about `/bin/bash` being missing, which reads as a broken script rather than the wrong interpreter. PassControl now looks for the bash that Git for Windows installs, checks it by running it rather than by checking that a file exists, and uses that. You do not have to reorder anything. If yours lives somewhere unusual, `PASSCONTROL_BASH` points at it.
Starting the dashboard is no longer a shell command
The command that runs the dashboard against your local stack used to be a line of shell wrapped in single quotes. Windows runs these commands through a shell that does not treat a single quote as quoting, so that line was broken there even with Git Bash correctly installed and first on the path. It is a small script now, which means it behaves the same way everywhere, and one less process sits between the command line and the server it starts — so stopping PassControl stops the server rather than a wrapper around it.
What this does not claim
Git for Windows is now a prerequisite for the local stack, alongside Docker Desktop and the Supabase command line, and the tutorial says so. Windows with no bash at all is still not supported: the scripts that create your database and apply migrations are bash, and rewriting them was a worse trade than asking for a dependency most people setting this up already have. The stack bootstrap itself has not been exercised on Windows end to end, so report what you hit.
Self-hosted
Documentation matches the 0.9 system
The README and integration guides now distinguish the credentials, accounting states, and evidence the current gateway actually supports.
Current providers and authentication
Documents Gemini, OpenAI Responses, passport lifecycle, sender-proof modes and operating-system key storage. The sidecar supplies request proofs; the direct command-line call, the MCP chat and the TypeScript SDK do not attach them in 0.9.
Accounting and verification limits
Explains unknown pricing, conservative charges, persistent holds and manual recovery. Receipts and spend statements authenticate issuer assertions; they are not an independent billing audit. The public tree includes statement verification, not statement operations.
Setup and reference corrections
Updates self-host runtime requirements, custom-endpoint policy, declared versus verified ownership, command discovery and package links. Corrects the statement format currency conversion.
Self-hosted
The published build is green again
Continuous integration in the public repository ran a database test that is not part of the public repository, so every build failed at the same step — including yours, if you forked it.
A step that named a file nobody outside had
The workflow file is published as written, and one of its steps ran an invariant check for a hosted signup flow that is not part of this source. The file was never here, so the step could never pass. It has been removed from the published workflow; every other database invariant it runs is here and still runs.
Why it stayed broken quietly
The same workflow passes where it is written, because the file it named is present there. Nothing failed on our side, so the only signal was a red build in a repository we do not watch as closely as our own. The check that removes it is now general: any future step naming a file this tree does not carry is dropped when the tree is built, rather than needing to be noticed again.
Cloud + self-hosted
An existing budget survives the upgrade
An agent that was already spending when budget accounting shipped is no longer treated as a brand-new grant. Its recorded spend is adopted rather than re-seeded at zero, which is the same protection every other budgeted agent already had.
An agent that was already spending is not a fresh grant
If the gateway's hot-path counters are lost during the upgrade window, an agent with recorded spend now has that spend adopted, and admission is refused if the counters are gone. Before, the two populations looked identical — an agent given a budget an hour ago and one that had been spending for months both carried no marker — so losing the counters handed the second one its entire cap back. An agent that has a budget and has never spent still starts at zero, with nothing for an operator to do.
A call is never forwarded on an unconfirmed budget generation
The gateway records a new agent's accounting generation in the database before it forwards anything, and refuses the call if that write fails. The check existed but could not fire: the database client reports a failed statement by returning an error rather than raising one, so a timeout or a permission failure read as success. A refused call now also gives back the capacity it had reserved, since nothing was sent.
A demo call is refused on an unconfirmed generation, like any other
Demo calls run through the real budget machinery on purpose, so the budget and kill demos show what actually happens rather than a simulation. They were the one path that would still answer when the gateway could not record the agent's accounting generation, which left that agent's spend recoverable as fresh capacity after a loss. The demo now refuses the call and returns its reservation, exactly as a billed call does.
Cloud + self-hosted
Lost spend records now stop the gateway instead of reading as zero
PassControl keeps a running record of what each agent has spent. If part of that record goes missing, the gateway now refuses new calls for that agent until an operator restores it, rather than reading the gap as a zero balance and handing the budget back.
A missing counter is treated as missing
Losing one figure out of four used to look identical to having spent nothing, so an agent could quietly regain its whole budget with everything else intact. The gateway now recognises the difference and answers 503 — a fault an operator fixes, not a spending limit the agent should stop retrying against.
Finishing a call cannot restore a record that was lost
The check above only helps if nothing quietly undoes it. Recording what a call cost used to recreate a missing figure at that one call's size — so a request already in flight when the loss happened would hand the agent its budget back the moment it finished. A call in that position is now still completed and its cost still reported, but it no longer writes to the missing figures, and the agent stays refused until an operator restores them.
A cost you record by hand survives the repair
When records are lost, the way back is to tell PassControl what the unfinished calls cost and then rebuild the agent's balance. Those two steps used to disagree: the rebuild recomputed from the call log, and a call the gateway never managed to log was not in it, so the figure you had just supplied was dropped by the very step meant to restore it. Your figure is now kept where the rebuild reads it, and it is ignored for any call the log already covers, so nothing is counted twice.
A call that was refused a record is no longer recorded as free
When PassControl could not confirm what a finished call was charged, it wrote a zero into the field its spend totals read first — so a real provider call counted as costing nothing. It now leaves that field empty and falls back to what it actually observed, and where a figure you supplied by hand covers the same call, the larger of the two is what counts.
A figure you enter by hand can be corrected, and never overrides a measurement
Recording what an unfinished call cost is sometimes the only way to close it, and the amounts accepted are large enough to mistype by a factor of ten and be believed. That figure now applies only where PassControl has no measurement of its own, so it can never override what was actually observed — and resolving the same call again with the right numbers replaces it, rather than being ignored as a duplicate.
A call that cost nothing stays free
Spend totals used to prefer a recorded charge over what was actually measured, even when the charge said zero and the measurement did not — so a paid call could count as free. They now take whichever is higher, which is the same on every ordinary call and only differs on a damaged record. A figure entered by hand applies only where there is no record at all, so a genuinely free request, like listing available models, can never be charged for by mistake.
A record that arrives late is not a second charge
PassControl writes its record of a call in the background, so a record can land after you have already closed that call by hand and repaired the agent's balance. The nightly tally used to treat the late record as new spending and charge the same call twice. It now adds only the amount the record exceeds what you supplied, so nothing is counted twice and a call that cost more than you recorded is still topped up.
Nightly reconciliation cannot invent a balance
The reconcile job raises spend toward what the audit trail shows and never lowers it. It can no longer create a spend record that is absent, because the audit trail lags and is not a complete ledger — building a balance from it would understate what an agent had already spent.
An agent's first call is recorded before it is forwarded
The marker that lets PassControl detect a later loss of its own records is now written and confirmed before the first call goes out, rather than alongside it. If that write fails, nothing is forwarded.
A recovered agent starts a new generation
When an operator rebuilds an agent's counters, work that was already in flight can no longer settle against the rebuilt figures. Each reservation is bound to the generation it was opened in.
Cloud + self-hosted
A request that was sent can no longer be recorded as one that never left
Every attempt now claims a single, one-use permission to reach the provider in the instant before it is forwarded. That claim is what lets the gateway tell an attempt that provably never left from one that may already have been billed — and it is the difference between a hold you can safely write off and one you cannot.
The holds list now tells you which decisions are open
Each open hold reports may_have_dispatched. When it is true, the request may have reached the provider, and releasing the hold as never-sent is refused outright rather than quietly allowed. Your options are to record an amount, or to leave it open until you have the evidence to name one.
A refusal now says it is a refusal
Resolving a hold returns refused_dispatched alongside applied. Without it, a refused release looked exactly like a duplicate one — and an operator who read that as done would have left an open hold behind believing it closed.
Uncertainty is still not a refund
If the gateway cannot read whether an attempt already claimed its one send, it does not send. The reservation stays held rather than being returned, because handing it back would free capacity that another handler may be in the middle of spending.
Cloud + self-hosted
Scanner traffic no longer reaches the auth gate
Requests for things this product does not serve — /.env, /wp-admin, /xmlrpc.php and the rest of the usual wordlist — are now answered immediately with an empty 404, before the session check runs. Nothing was ever reachable at those paths; they simply used to cost a round trip to the auth service each, which an automated scanner could run up freely.
A 404 is also the truthful answer
These paths used to redirect to the login page, which made them indistinguishable from real pages that require an account. They are not pages at all, and now they say so.
Deliberately a closed list, not a clever rule
The failure worth avoiding is a pattern that accidentally matches a real page and hides it from everyone. The list is explicit, anything it does not recognise falls through to the normal behaviour, and a test walks every route the app actually serves to prove none of them matches. The public key document is exempted twice over, because a 404 there would stop every receipt this instance ever signed from verifying.
Self-hosted
The local Redis bridge no longer listens on the network
The local development stack published its Redis-over-REST bridge on every network interface, so running it on a shared network exposed an authenticated Redis — the kill switch, nonces and budget counters — to any other machine that could reach the port. It now binds to loopback only, and nothing about how you run the stack changes.
Why it mattered
The bridge authenticates with a fixed development token that is published in the repository, which is safe only while the bridge is reachable from the machine itself. Docker publishes a port on all interfaces unless told otherwise, and on Linux it writes its own firewall rules, so a host firewall was not reliably in the way.
Nothing to reconfigure
The port is unchanged and still moves with the port offset, so a second stack on one machine works exactly as before. The URL in the env template was already localhost and stays correct. Bring the stack up again to pick up the new binding.
Redis itself was never published, and still is not
Only the authenticated bridge was ever reachable; the Redis behind it has always been on an internal network. A regression test now pins both facts, because a publication line is a single character away from being undone and the failure is invisible from the machine doing the exposing.
Cloud + self-hosted
Statement totals now read in dollars
Both places that show a signed spend statement's total were converting micro-cents into cents and then labelling the result with a dollar sign, so every statement total appeared at one hundred times its real value. The stored and signed figures were always correct; only the display was wrong, and it is now correct on the public verifier and in the dashboard alike.
Nothing that was signed or recorded changes
A statement's cost claim is micro-cents, and it always was. The signature, the leaf digests and the chain are unaffected, and a statement issued before this fix still verifies. If you noted a total from a statement page, divide it by one hundred; the artifact itself needs no reissue.
One formatter, so the two surfaces cannot drift
The dashboard chain table used to carry its own private copy of the conversion, identical to the verifier's. Two copies is how one fix leaves the other surface wrong, so the table now renders through the same function the public page uses.
Cloud + self-hosted
A budget that cannot be talked into spending twice
Every attempt now holds its own reservation, with one record that says what it reserved and one place that decides what it cost. Retries, crashes, broken streams and the nightly reconcile can no longer create spending capacity between them, and where the answer is genuinely unknowable the call is charged rather than forgiven.
The nightly reconcile can only raise a counter, never lower one
It used to overwrite spend with a total that deliberately excludes the last minutes of activity, which quietly handed back everything settled inside that window. It now raises the counter toward the authoritative figure and stops. A lost settlement can still be recovered; a settled call can no longer be forgotten.
A retried request is charged once
Reservations are keyed on the attempt, so a request that reaches the gateway twice — a client retry, a transport error after the work was already done — settles once and returns the first answer to every later caller. The same guarantee covers the gateway's own retries against a provider.
A stream that breaks is not free
If a response stops before the provider reports what it used, nobody can say what was billed on the other side. That call is now charged at the greater of what was measured and what was reserved, and recorded as unconfirmed rather than as a completed call that cost nothing. The dashboard marks the measured figures unconfirmed and shows the amount actually charged beside them.
Lost counters are detected instead of guessed
If the gateway loses its own spend counters for an agent, it now says so and refuses, rather than starting again from a number it cannot trust. That refusal is a 503 and is not a budget denial — raising the cap will not clear it. An operator rebuilds the agent from the recorded calls, which is the only authority on what was spent.
Attempts nobody finished are visible, and only a person closes them
When a worker dies mid-call the reservation stays held, deliberately and indefinitely, because releasing it on a timer is how money that was really spent gets handed back. The open attempts are listed with what each is holding, what it was calling, and when it started, and an operator resolves each one after checking the provider. Nothing automatic ever closes one.
Recovery is written down
docs/budget-recovery.md covers what each refusal means, how to read the held attempts, what evidence actually settles whether a call was billed, and why a rebuild is safe to run more than once.
Run passcontrol settings, or just passcontrol, to search commands, inspect live fleet context and complete safe guided tasks without remembering every option. Every flat command still works exactly as it did when typed.
Searchable commands with the real command underneath
Press `/` to search labels, command lines, descriptions and groups. The selected command shows its exact flat command, current value, network use and effect before it runs, so the browser teaches the CLI rather than hiding it.
Safe tasks are guided; dangerous ones remain templates
Integration previews, logs, verification, suspension, resumption, the kill switch and key migration have guided prompts with default-safe confirmation. Rotation, revocation, reset, agent creation, calls and file-writing configuration remain explicit command templates and never run from an accidental menu selection.
Local first, live context second
The browser opens immediately with local configuration and key readiness, then refreshes account, gateway, fleet and kill-switch state with a short timeout. A slow or older gateway cannot hold the terminal hostage, and account email is shown live without being stored in menu history. A guided task whose gateway read fails reports the failure and returns you to the browser rather than ending the session.
The menu only offers what your machine can do
If PassControl is not checked out on this machine, the browser leaves the local-stack commands out rather than offering commands that would fail on their first line. Every one of them still runs when typed, and passcontrol --help still lists them, so a self-hosted deployment is a decision you make from the documentation rather than a button on a client install's first screen. Diagnostics moved to Status & tools, where they are useful either way.
A header you can read at a glance
The top of the browser is a short status table: gateway, provider, key custody, account, fleet and — only when it is armed — the kill switch, each with a colour that means something. Configuration paths, HTTP status codes and storage tiers moved to passcontrol status, where a detail belongs. Commands are grouped under quiet section headings, and the selected row is marked with a cursor rather than a filled bar, so red is left free for the things that are actually destructive.
Scripts are unaffected
The browser opens only when a person is at a terminal. Piped, redirected, or run from a script, passcontrol still prints the cockpit status it always printed, and passcontrol settings prints the full list of commands as text.
No new dependencies
The CLI still installs five packages, all pure JavaScript with no native modules. Arrow keys are read directly from the terminal rather than pulling in an interface library, because what you install to hold a credential should stay small enough to audit.
Cloud
Control API keys can read their own account identity
The authenticated control API now exposes the account email and whether the current key is read-only or write-capable, giving command-line clients enough identity context without exposing account metadata.
Bound to the authenticated key
`GET /api/control/v1/account` resolves the user behind the presented control key. It accepts no user ID, returns only email and key scope, and returns a temporary-unavailable error when Auth cannot verify the account.
Available through the SDK
`ControlClient.account.get()` returns the same minimal response. Older gateways remain compatible with the CLI: a 404 simply omits the account row while the rest of Settings continues to work.
No stored profile data
The CLI reads the email live for the current Settings screen. It does not cache it, write it into configuration or include it in session recents.
Cloud
The cookie notice now lists every cookie PassControl sets
The cookie and storage notice was missing the dashboard accent preference. It now lists all four items, so the table is a complete inventory rather than a partial one.
What was missing
Choosing an accent colour in Settings stores that choice in a cookie named pc_accent for a year. The notice described the authentication, password-recovery and time-zone items but not this one.
Nothing changed about what is stored
No new cookie was added and none was removed. PassControl still sets no advertising, cross-site tracking or behavioural-analytics cookie, and an anonymous visitor to the site receives no cookie at all.
Cloud
Passport agents without an expiry now appear in the operator queue
The Control Tower now gives older passport agents a quiet reminder to set a rotation deadline. Open the agent from `What needs attention` and choose an expiry on its page.
A next step, not a fault
The label says `Set a passport expiry`. It does not say the agent is broken or that its operator made a mistake: these passports were issued before a default expiry existed, and they remain valid until you choose one.
Real deadlines stay above hygiene
A passport that is already close to its expiry remains higher in the ranked queue. Missing an expiry is shown with a neutral tone and a lower score, so a fleet of older agents cannot bury the operational deadline that needs action first.
Only live passport credentials
Direct Agent Keys keep their separate optional-expiry lifecycle and never receive this reminder. Revoked and suspended passport agents are left out too, because setting a rotation deadline on a credential that cannot currently authenticate would be noise.
Technical details +
No migration and no credential change
The queue reads the existing `passport_pubkey` and `expires_at` fields. The existing agent-page control already writes the expiry, so this adds a route to the control operators already had rather than changing a passport, minting a visa or backfilling stored data.
Cloud
The operator queue tells blocking apart from housekeeping
`What needs attention` now carries three levels rather than two, and the summary card above it goes red only when something is actually blocked.
A quiet row for a quiet item
A row whose only entry is a next step you have not taken yet, such as setting a passport expiry, is shown neutrally. Suspensions, passed deadlines and exhausted budgets keep the alarming treatment they had. A queue where everything looks urgent is a queue you stop reading.
The card says what is in the queue
The `Needs attention` card used to carry a fixed subtitle that named `revoked` — a state the queue never contains, because revoked agents are left out of it — and omitted refusals, deadlines and inactivity, which it does contain. It is now written from whatever is actually there.
Technical details +
One severity rule, read by both surfaces
The row tone and the card tone are both derived from the reasons an agent was queued for, in one place, so the card and the list underneath it cannot describe the same fleet differently. The subtitle is generated from the reason kinds present rather than written by hand, which is what let the old one drift.
Cloud + self-hosted
Your fleet page now says where each agent keeps its passport key
An agent can tell the gateway whether its passport private key sits in a file or in the machine's credential store, and its page reports what it said. Nothing here was checked by anything, and the panel says so in as many words.
The claim, on the agent's own page
Under the passport's expiry and rotation there is now a `Passport key custody` panel: `Tier 0` for a file or environment variable, `Tier 1` for the OS credential store, and the moment the agent said so. The CLI sends it on the visa mint, so an agent already running `passcontrol key migrate` starts reporting it with no further setup.
It catches the case where tier 1 is not actually on
If an agent is configured for the credential store but could not read it and fell back to its file key, the panel says so in a warning colour. That is the state worth finding: the operator did the work, believes the key is protected, and it is not.
Silence is never read as the weakest tier
An agent that has said nothing shows `No custody claim recorded`, and the panel lists the reasons that happens — it has not minted a visa yet, it uses the SDK or an older CLI, or the record expired. Reporting silence as a file key would be a guess dressed up as a finding.
Technical details +
Declared, and only ever declared
The gateway cannot verify a key-custody claim at any tier: nothing about tier 1 changes a signature, and a hardware-backed key would need attestation to prove. So this is reported as a claim, there is no tick and no verified state anywhere on the panel, and nothing about it reaches a receipt, a visa or any policy decision. A receipt still proves only what the gateway enforced.
The claim travels inside the signed challenge
A passport ID is public, so an unsigned field would let anyone who knows one decide what an operator's dashboard reports about that agent. The declaration is part of the payload the passport key signs, and the gateway records it only after that signature verifies — so the claim is attributable to whoever holds the key. It is also optional: an older client, the SDK and the examples all mint without one and are unaffected.
The switch is on the agent's machine, not in the dashboard
There is no toggle here, deliberately. The server has never held the key and cannot move it, so a control that looked like it could would be worse than none. The panel names `passcontrol key status` and `passcontrol key migrate` instead, which run where the key actually is.
Cloud + self-hosted
Declared key custody across the whole fleet, and a line you can hold it to
The fleet table now carries a Key custody column, so you can see every agent's declared storage tier without opening them one at a time — and you can state what you expect of the workspace, which the dashboard marks agents against without ever enforcing it.
One column, the whole fleet
`Key custody (declared)` sits beside Last seen: `Tier 0 · file`, `Tier 1 · OS store`, `Not declared`, or a dash for a Direct Agent Key, which has no passport private key to keep anywhere. An agent that meant to be on the credential store and fell back to its file key is called out in the row, so the case worth finding is findable by scanning rather than by clicking.
State what you expect, and see who is not on it
Settings gains a Key custody section where you can say the workspace expects Tier 1. Agents whose declared tier is below it are marked in the fleet table and on their own page. Nothing is blocked: no call is refused, no receipt mentions it, and an agent below the line keeps working exactly as before. It is a line to hold people to, not a gate.
Neither silence nor a newer tier counts against you
An agent that has declared nothing is not marked as falling short — silence has several causes and none of them is a file key. Nor is an agent running a newer CLI that names a tier this instance has not heard of; that reads as unrecognised, and it is never quietly treated as the weakest one.
Technical details +
One Redis round trip for the page, not one per row
Declarations for every passport agent on the dashboard are fetched with a single MGET. If Redis is unreachable the whole column reads as `Not declared`, which is true — the instance has not heard a claim — and no part of the page fails because of it.
The expectation is compared by tier, not by name
An agent declaring a storage kind newer than your instance is not below an expectation of Tier 1, and comparing the two strings would have said it was. The comparison is ordinal, and when either side cannot be given a tier the answer is that there is no answer.
The expectation is server-write-only, and reads nothing else
It is a column on your own row, written only through the dashboard behind two-factor verification and recorded in the admin audit trail; the browser cannot write it directly. Nothing in the proxy, the visa mint, or a signed receipt reads it. On an instance that has not applied the migration the panel says so and offers no control, rather than inviting you to set something it could not save.
Cloud + self-hosted
Anyone can now check whether a passport was still valid when it signed
A signed, publicly fetchable list of passports that have stopped being valid, served next to the key set at /.well-known/passport-revocations. Fetch it once and every receipt this instance has ever issued becomes checkable offline.
The missing half of verification
Until now a stranger holding a receipt could prove we signed it, but not that the passport inside it was still good at that moment. The key set answers the first question; this answers the second. Together they mean a receipt stays checkable with no account, no callback, and after we are unreachable.
It says what it does not cover, inside the signature
The document names exactly what it enumerates — revoked passports and keys retired by rotation — and exactly what it does not: expiry, suspension, and kill-switch state. So a verifier can never read absence from the list as proof a passport was fine, because the list itself tells them it is not that kind of answer.
Your operational state stays yours
Suspending an agent is reversible and never appears here, and neither does the kill switch. Only permanent outcomes are published, which is what makes the list safe to cache — an entry is added and never withdrawn. Nobody polling it learns who paused what, or when.
Technical details +
A compact JWS, signed by the same key as receipts
Ed25519, with its own type so a revocation list can never be verified as a receipt or the reverse, and a signed timestamp so an old copy cannot be replayed to hide a newer entry. Verify it against /.well-known/jwks.json by key id, exactly as you verify a receipt. A self-hosted instance signs and serves its own.
An error, never an empty list
If the instance cannot sign or cannot read, the endpoint returns 503 rather than an empty document. An empty list is not the statement that we cannot answer — it is the statement that nothing has been revoked, and serving that during an outage would vouch for a passport we know to be dead.
Rotations from here onward
A retired key is recorded when the rotation happens, because the database stops holding it once the grace window closes. Rotations performed before this shipped cannot be listed, and the list is deliberately not treated as complete for that reason — which is what the coverage field is for.
Cloud + self-hosted
Verification now tells you about expiry and rotation, instead of guessing
A passport page used to say Valid about a passport that had expired, and No such passport about a key that was still working. Both answers are now correct, and the deadlines behind them are published so you can check a receipt against the moment it was signed.
An expired passport says so
The gateway has always refused an expired passport. The public page did not know the deadline existed, so it showed a green Valid badge for a key nothing would accept any more. It now reads Expired, and says plainly that an expiry is the ordinary end of a key's life rather than a sign anything went wrong.
A key you have just rotated away from still verifies — and says it was replaced
During the grace window after a rotation, the old key genuinely still works, and asking about it used to return No such passport. It now verifies, and the page says the key has been replaced and gives the date it stops working. The replacement key is not named: you asked about one key and you get an answer about that key.
The dates are published, not just applied
Both the expiry and the rotation deadline appear on the page and in the JSON. Status answers is this good now; a counterparty holding a receipt needs was it good then, and only the dates let them work that out for themselves.
Technical details +
One rule, and it is the gateway's own
The order — lifecycle status first, then expiry, then the rotation deadline — is the same order the gateway applies when deciding whether to mint. It is now written once and used by the verification page, the JSON API, and the public operator profile listing, with a test that drives the same passport through both the gateway and the public view and asserts they agree.
The operator profile listing was wrong in the same way
Published agents on an operator profile were shown as active whatever their expiry, from a separate query with its own separate vocabulary. It uses the same rule now, so clicking from a listing through to a passport page cannot show you two different answers about one agent.
A key claimed by two workspaces is refused, not guessed
A passport ID is a public key, so nothing stops someone registering a key another workspace has retired. The gateway already refuses to resolve that rather than attribute a signature to the wrong tenant. Verification now refuses it too and says so, instead of picking one — and it is told a collision exists without being told whose.
Self-hosted
The demo scripts now show the numbers they were describing
Two of the five demos in `docs/demo` printed a blank where the policy and the budget should have been. They read the gateway directly now, and the README says what each one actually needs first.
The scope demo shows the scope
`scope-demo.sh` opens by printing the calling agent's allowed models, so the refusal that follows reads as a policy decision rather than a typo. It was printing an em dash instead, every time, because it asked the CLI for a field the CLI does not return. It now reads the agent from the control plane and matches on the passport actually making the calls.
The budget demo shows the budget
`budget-demo.sh` reported `$0.000000 spent of no cap` before and after the refusal, which left the demo making its point with no evidence. Same cause, same fix. It also lost every per-call line to a shell redirection, so the run itself never appeared; those lines are back.
The README tells you something you can follow
It used to say to set the agent's cap in the dashboard and run the budget demo. That cannot work: a cap is a whole number of cents, so the smallest one is around 340 calls of the size the script makes, and the script tries eight. The README now says to spend the agent up near its cap first, or seed the spend directly if you have database access.
Technical details +
Why the scripts stopped using the CLI for this
`passcontrol agent list --json` reshapes each row into the columns of the human table it also prints, so `allowed_scopes`, `spent_microcents` and `budget_cents` are dropped on the way out and arrive undefined. The control-plane API returns all three. Both scripts also used to take the first agent in the list rather than the one holding the configured passport, which on a fleet of more than one would have printed another agent's authority beside this agent's refusal.
Self-hosted
Point it at your own model server
A provider credential can now be sent to an endpoint you run — vLLM, LiteLLM, Ollama, an internal gateway — instead of the provider's own host.
Your server, the same gate
Set a base URL on a stored credential and calls go there instead. Everything in front of it is unchanged: the kill switch, the scope check, the policy, the budget reservation and the signed receipt all still apply. What moves is the address, not the governance.
The protocol does not move with it
An OpenAI credential pointed at your server still speaks OpenAI; an Anthropic one still speaks Anthropic, headers and all. Your endpoint has to implement the API of the provider you attached it to — which is exactly why the OpenAI-compatible servers work, and why pointing an Anthropic credential at Ollama will not.
It is off until you turn it on
`PROVIDER_ENDPOINT_MODE` is unset by default and every custom endpoint is refused. Set it to `selfhost` and you can use any address you like, including plain HTTP, custom ports and private network addresses — which is what a local model server actually needs. Set it to a comma-separated host list instead and only those hosts are accepted, over HTTPS.
Those calls are counted, not priced
Tokens through a custom endpoint are recorded as usual and the cost is left blank rather than guessed. A model called `gpt-4o-mini` arriving from your own proxy may be marked up, routed somewhere else, or free — so putting a published retail price on it would be a number you could act on and we could not stand behind. A gap you can see beats a total that is quietly wrong.
Technical details +
Security
`selfhost` lets the gateway make requests to network locations you choose, including services inside your own network, and return their responses to the caller. That is the feature, and it is why it is opt-in: enable it only where endpoint configuration is controlled by someone you trust with your network, which on your own instance is you.
What the URL checks do and do not buy
In host-list mode the list is the control. The shape checks around it — HTTPS, the default port, no IP literals — are not a private-network defence and must not be read as one: a hostname you do not control can resolve to an internal address, and this runtime cannot resolve DNS to find out. What the checks genuinely prevent is narrower: credentials embedded in the URL, a scheme the gateway does not speak, a query string or fragment carrying state, control characters, and a path that climbs out of the base you set.
That last one is not theoretical. `http://host:8000/v1/../../admin` is resolved to `/admin` by the URL parser before any check can see the `..`, so the traversal is caught on the raw text you typed. What you see is what receives the credential.
Cloud + self-hosted
Find out whether proof-of-possession would break you, before it does
A stolen work-visa can now be made useless on its own — and there is a middle setting that tells you whether turning that on would refuse any of your real traffic.
A work-visa can stop being a bearer token
A visa is a token: whoever holds it can spend it. With proof required, every request must also carry a fresh signature from the agent's own private key, bound to that exact visa, method and path — so a visa captured in transit is worthless without the key, which never leaves the machine that generated it.
The middle setting is the point
Turning that on is a big lever: any client that does not sign stops working immediately, and today that is everything except the PassControl connector. So there is an `Observe` setting between off and required. Proofs are checked and recorded exactly as they would be under enforcement, and nothing is ever refused. Leave it on for a fortnight and the dashboard tells you how many of your real requests would have been admitted, and what the rest got wrong.
It says what it does not know
"4 of 6 recent requests carried a proof that verified" is a statement about the traffic in that window and nothing else — a client that has not called yet has not been tested, and the panel says so rather than letting a clean number read as a clean bill of health. An agent with no calls since you switched observing on shows as neither passing nor failing, because it is neither.
Observing never changes what a receipt claims
A call admitted while observing was authenticated by a bearer visa, so its receipt says bearer access — however well the proof verified. The strongest claim this product makes is the one it is most important not to make loosely.
Technical details +
Why observing costs what enforcing costs
The proof is verified and its single-use token is spent, exactly as under enforcement. Skipping that would make observing cheaper than the thing it is predicting, and would hide the one failure mode most worth knowing about before you enforce: a client that reuses a proof rather than signing a fresh one. A measurement that does not cost what the real thing costs does not predict it.
One deliberate difference: if the store that tracks single use is unavailable, enforcing refuses the call and observing simply records nothing. There is no authentication to fail while observing, so an infrastructure blip must not become a diagnostic that turns into a refusal.
Direct Agent Keys cannot be asked for a proof
A Direct Agent Key is a bearer credential by construction — it has no keypair to sign with, which is the whole reason it is documented as the lower-assurance path. So requiring proof on an agent that uses one is refused when you configure it, rather than becoming calls that fail forever with nothing on screen explaining why.
Self-hosted
The self-hosted build catches up
Everything the hosted gateway gained over the last week is now in the source you run: passport expiry, a second way to prove ownership, the Responses API, and receipts that name the rules they were judged against.
Passports expire, and renewing is not replacing
New and rotated passports carry a one-year deadline you extend in place — the key stays, the agent keeps running, nothing is redeployed. Passports created before you upgrade are untouched and still never expire on their own.
Prove ownership with a GitHub account
Alongside the domain check: create a public repository called `passcontrol-owner` holding the token your instance issues, and it reads it. Only that account can create a repository at that path. You can also record an EU VAT number or an LEI, which your instance looks up in the register and labels as asserted rather than proven.
The Responses API goes through the gate
Agents built on OpenAI's Responses API get the same kill switch, scope check, policy evaluation and budget reservation the chat endpoint always had.
Receipts name the rules they were judged against
A receipt now carries a fingerprint of the authorization rules in force at that moment. Receipts issued before you upgrade verify exactly as they did before.
`passcontrol setup` stops calling a working install broken
A cold first run compiles the dashboard, which takes longer than the old thirty-second budget allowed — so the first thing a new self-hoster saw was a failure message about a stack that was fine. The budget is now conditional on evidence: process gone means stop, port listening means keep waiting and say so.
Technical details +
Security
Every secret the gateway derives now comes from its own purpose-labelled subkey rather than from the visa signing secret directly. Nothing was exploitable — the uses never met — but one secret doing three jobs makes the blast radius of a rotation or a weakness larger than it needs to be.
The gateway notices one passport being used from two countries inside a single work-visa's lifetime. Running one passport across many containers is normal architecture and raises nothing.
`passcontrol login` can now put your agent's private key in your operating system's credential store instead of a file in your project directory.
Where the key lives is now a choice
A passport private key written to `.passcontrol` is a key that gets committed, copied into a container image, or picked up by anything that reads your project directory. It can now go into the OS credential store — Keychain, or the platform equivalent — so the file holds a pointer and not a secret.
It does nothing for a container, and says so
Every credential store in the world is a desktop feature. Most agents run somewhere with no session and nowhere safe to put a key, which is why the gateway also watches for one passport being used from two places at once — detection is the part that works for the agents that cannot store anything.
Cloud + self-hosted
A provider key no longer follows a redirect
When the gateway calls a provider on your behalf, it now refuses to be redirected. If an upstream answers with a redirect, the call fails with `upstream_redirect` instead of quietly repeating itself — credential and all — to whatever address the redirect named.
The allowlist decides where the call lands, not just where it points
If you point a credential at your own endpoint, the gateway checks that address against what the operator permits. Following a redirect would have moved the call somewhere that check never saw, one hop later. It now stops at the address you approved.
It was an Anthropic-shaped gap
Most providers authenticate with a bearer token, and every browser and server runtime already strips that header when a request crosses to another site. Anthropic uses `x-api-key`, which is not covered by that rule and travels with the redirected request. So the protection other providers were getting for free was never reaching Anthropic keys.
The same guard on key import
Adding a provider key runs one call to the provider to list its models, and that call carries the key you just pasted before it is stored. It refuses redirects too. If it gets one, model detection falls back to picking from the list by hand, which is what it already did for any other unhelpful answer.
Technical details +
What was and was not exposed
Reaching it needed custom provider endpoints turned on — `PROVIDER_ENDPOINT_MODE`, which is unset by default and refuses every custom endpoint — plus an upstream that answers with a redirect. PassControl Cloud does not set it, so hosted keys were not reachable this way. A self-hosted gateway pointed at an internal model server was the case worth closing.
What a redirect does now
The forwarding call sets `redirect: "manual"`, so a 3xx comes back as an ordinary response rather than being followed. The gateway answers 502 `upstream_redirect`, reserves no spend for it, and records it in the audit log. The redirect target is deliberately not repeated to the caller or written to the log: it can name an internal address, and a refused hop should not become a disclosure of its own.
Cloud + self-hosted
A database blip no longer sends your key to the wrong host
If you point a credential at your own endpoint, the gateway looks that address up on every call. When that lookup failed it used to fall back to the provider's own host and carry on. It now refuses the call instead, and says so.
A failed lookup is not an answer
The lookup returning nothing because a credential has no custom endpoint, and the lookup failing outright, used to be the same thing to the gateway. A key you set up for your own model server could be sent to the provider's public host on the strength of one transient database error.
It also stopped remembering the mistake
The result of that lookup is cached for a minute to keep the common case fast. A failure was being cached the same way, as though it were a real answer, so a single blip kept sending calls to the wrong host for the rest of the minute. Failures are no longer cached at all.
A new line in your audit log
A refused call is recorded as `endpoint_unavailable` and answers `502`. It reads as `NO ADDRESS` on the board and as a note on the receipt saying the destination could not be read. It means the lookup failed on our side — not that anything is wrong with your key or with the provider, and the call was never forwarded.
Technical details +
Why it refuses instead of carrying on
The kill switch fails open because a second control backs it up. Nothing backs up a destination: if the gateway cannot tell where a credential is meant to go, the only safe answer is not to send it. It refuses before the key is decrypted at all, so a call it will not forward never causes the key to be read out of the vault.
What it costs you
Almost nothing. This lookup only runs on deployments that turned custom endpoints on, and a database that cannot answer it generally cannot hand over the key either, so those calls were already failing. The case that changes is a cached key with a failed endpoint read — which is exactly the case worth refusing.
Cloud + self-hosted
A call nobody can price no longer says it cost nothing
When a call goes to your own endpoint, PassControl does not know what it cost — the server behind it may mark up, re-route, alias the model, or be free. It used to record that as `$0.00`. It now records it as unknown, and says so.
The receipt says which it is
A signed receipt for an unpriced call now carries a claim marking the cost as unknown, and the verifier page reads it as `Not priced` instead of `No charge recorded`. A receipt that swore a call cost zero when nobody could work out what it cost was the wrong thing to put a signature on.
Your spend total stops quietly counting them as free
Calls with no recorded cost are left out of the total rather than added as zero, and the figure now tells you how many were left out. Tokens are unaffected: those are counted and capped exactly as before, because token counts are real whatever the money is.
Nothing changes for a normal call
A call to the provider's own host is priced exactly as it always was, and its receipt is byte-for-byte what it would have been before this change. The new claim appears only on the calls it describes, so existing verifiers keep working and no receipt already issued is affected.
Technical details +
Why the cost field still says zero
The receipt keeps a numeric `cost` and adds an optional `unp` flag beside it, rather than dropping the field. Published verifiers type `cost` as required, so removing it would break them; adding an optional claim is the same mechanism the prompt-cache fields used, and it does not move the receipt version. The flag is inside the signature, so it cannot be stripped without invalidating the receipt.
A known limit, stated plainly
A spend cap in dollars cannot constrain calls whose cost is unknown — there is no honest number to add up. Token caps, scopes, policy rules, the kill switch and the audit trail all apply to these calls exactly as they do to any other. If you need a hard money limit on a custom endpoint, that limit has to live on the server you are pointing at.
A stalled credential store now says what it was waiting for
When passcontrol key migrate cannot reach your operating system's credential store, it now tells you whether the store stalled, refused, or was never there — and on macOS, whether the Keychain got as far as asking for a password.
A stall is no longer reported as a failure
A helper that never answers and a helper that is not installed used to produce the same sentence, because both look alike to the process that starts them. They are now separate: a stall says the store did not respond within ten seconds, and says so for a read and a logout as well as a write.
On macOS, the message points at the lock
If `security` stalled before asking for a password, your login Keychain is most likely locked — unlock it and run the command again. If it stalled after asking, it never accepted the key. Those need opposite things from you, and the message now says which one you have.
The reason reaches the command you actually typed
`passcontrol key migrate` used to print that the write failed and stop there. It now carries the store's own explanation, and still leaves your tier 0 file exactly where it was — the key is never removed from the file until it has been read back out of the store and compared.
Technical details +
One bit of the helper's output, and only one
The credential store deliberately never returns a helper's error text, because helpers sometimes repeat their own input in diagnostics and that input is a private key. That still holds. What crosses the boundary is a single true or false — did the output contain the fixed prompt string — computed in the one function that is allowed to see it, and a test plants a fake secret on that stream to prove nothing else escapes.
Cloud + self-hosted
The spend-statement format is documented and verifiable by anyone
A spend statement commits to every receipt a workspace issued in a window, linked to the window before it. The format is now written down in full — leaf and node hashing, the chain digest, and a test vector — so you can write your own verifier and check it against ours rather than taking our word for it.
A published format, not a described one
The document specifies the envelope, every claim, the Merkle construction and the verification order, including why an odd node is promoted rather than duplicated. It carries a recorded test vector: one receipt string and the leaf hash it must produce. If your implementation disagrees, nothing downstream can be right, and you find that out in one line.
Check one in your browser, with no account
/verify/statement takes a pasted statement and checks it against the public keys of whichever issuer signed it. Nothing is uploaded and nothing is published: you supply the document, and the only thing fetched is that issuer's key set. It works against a deployment we have never heard of.
And from the command line, or from code
`passcontrol verify statement <jws> --issuer <origin>` needs no account, no API key and no gateway. The SDK exports verifyStatement and verifyInclusion for the same job in code, and both refuse a version they do not understand rather than checking it wrongly.
The CLI now reports statement compatibility
Because the format is a published contract, the CLI's compatibility check includes it. A gateway that predates the format does not declare it, and the CLI says so rather than reporting a clean bill of health it cannot stand behind. If you upgrade the CLI before your gateway, expect to see that until the gateway follows.
Cloud
A daily signed statement over every receipt
Each night PassControl signs one small document per workspace that commits to every receipt issued that day, linked to the day before. A receipt already proved one call happened; this makes the record of all of them tamper-evident.
A record that cannot be quietly thinned
The statement carries a single fingerprint computed from every receipt in the day. Remove one call afterwards and that fingerprint no longer matches the document we already signed and gave you. Each night's statement also carries the fingerprint of the night before, so a whole day cannot be dropped from the sequence without the next one failing to line up.
Prove one call belongs, without revealing the rest
You can show an auditor that a specific receipt is covered by a specific day's statement, using a short proof that grows with the logarithm of your call volume — a few dozen bytes even for a busy day. It reveals nothing about the other calls in that window, so sharing a proof is not sharing your traffic.
It states what it does not know
A statement reports how many calls it covers and how many rows the day held — those differ if an instance has no signing key, or a receipt failed to sign — plus how many calls went to an endpoint nobody could price, and how many had no recorded cost at all. Those are never folded into the total as zero, because a call we could not price is not a free call.
Yours to share, or not
Statements are read from your own control plane and are not published anywhere. Anyone you hand one to can check it against the public key at /.well-known/jwks.json, with no account here and no contact with us.
Read the chain, download a statement
A new Statements page in the dashboard lists every night’s document: what it covered, what the day actually held, the total, and whether it links to the night before. Each one downloads as a single file you can hand to anyone.
The chain from the command line too
`passcontrol statements` lists every night’s document with what it covered, what the day held, and whether it links to the night before. Against a gateway that does not operate a chain it says so plainly rather than failing.
Check that one call is covered
Paste a call id and the page tells you whether that call’s receipt is one of the ones a statement committed to. A “no” is not an accusation — a call is only ever in the statement for the day it happened — and the page says so.
Technical details +
If the record stops matching, you are told which way
Asking for a proof re-derives the window and compares it against the commitment that was signed. A window that GREW — calls arriving after it was attested — is reported as the benign thing it is. A window whose calls have gone missing, or whose calls no longer match, is reported as a distinct finding with its own message, because those two mean the opposite thing and flattening them into one error would be discarding the alarm this feature exists to raise.
What the signature actually proves
A statement is an EdDSA compact JWS, typ passcontrol-statement+jws, signed by the same instance key as receipts and the revocation list — a distinct type, so a statement can never be verified as a receipt or the reverse. It commits to a Merkle root over the receipts in a half-open UTC day: leaf = sha256(0x00 || receipt), node = sha256(0x01 || left || right), with an odd node promoted rather than duplicated, so two different sets of calls cannot produce one root. Verifying a statement proves we committed to that set at that time and cannot change it afterwards; it is not an independent audit of the totals, which would require holding every receipt in the window.
Built after the fact, on purpose
Chaining each receipt to the previous one at call time was the obvious design and is the wrong one here: audit writes are best-effort, so a dropped write would leave a gap in the chain and the system would report tampering against itself. The commitment is instead computed nightly from the rows that actually exist. Receipt hashes are computed in the database and only the hashes cross to the gateway, so a busy day never ships its audit trail out of Postgres.
Setting up a new workspace from the CLI has been failing since 0.8.0. It works again, and the proof it shows you now waits for the receipt it checks.
Creating an agent from the CLI stopped working, and now does not
`passcontrol login` creates an agent that can reach the keyless demo provider, because a brand-new workspace has no provider key yet and that is the only call its first agent can legally make. The control plane refused that request as naming an unknown provider, so sign-in stopped with a validation error before it wrote your config. Anyone setting up for the first time on 0.8.0 hit this every time; anyone reusing an agent they already had never saw it. The fix is on our side rather than in the CLI, so an install you already have is repaired without updating anything.
The receipt check no longer races the receipt
A governed call is answered before its signed receipt is stored, so the call is never slowed down by its own paperwork. Sign-in read that receipt immediately and could beat the write — and then told you the deployment stores no receipts, on the one screen you read as proof that it does. It now waits briefly for the receipt to appear, and only for that: a rejected key or a rate limit still fails straight away rather than making you sit through a retry.
The Control Tower can name the demo provider
An agent created by signing in holds the keyless demo provider in its scope. The scope editor had no way to display it, so that row showed the first real provider in its list instead — naming a provider the agent could not call, and hiding one it could. Both the scope editor and the decision trace now offer it, so what you see is what the agent has.
You can trace the first call your agent can make
The decision trace refused to evaluate a demo call at all, which is backwards: the demo runs the full ordered check — kill switch, scope, endpoint, policy, budget — and it is the only call a brand-new agent can make, so it is the first one worth tracing. It now evaluates like any other.
The trace tells you what it assumed
A trace has no request body, so it projects a default output size. That is exactly right for a call that names no maximum and optimistic for a larger one, which meant an agent with room for 1,500 tokens could trace as allowed and then be refused for asking 2,000. The budget step now shows the room left alongside the size it projected, so an allow can be read for what it is, and you can enter the output size you actually send.
Technical details +
Fixed
Demo calls are refused when the gateway cannot record the agent's accounting generation, the same as any other call. They run through the real budget machinery on purpose, so being the one path that answered anyway left that agent's spend recoverable as fresh capacity after a loss.
The decision trace priced demo calls at zero, because it looked them up in a table that has no row for them. It would have called a demo call affordable when the gateway would have refused it. The rate now has one definition that the gateway charges and the trace projects.
Security
The keyless demo provider is legal in an agent's scope and nowhere else. It cannot be stored as a credential and cannot be a failover target, because there is no demo key and nothing to forward to.
Why the sign-in fix is on the gateway rather than in the CLI
The disagreement was between a request the CLI sends and a validator that refuses it, so either side could have been changed. Fixing the validator repairs every already-installed client the moment it deploys; fixing the CLI would have required everyone to update, and stranded anyone who did not. The seeded demo agent has always carried this scope in the database, so the validator guarding the front door was the piece out of step, not the request.
The asymmetry is now pinned from both sides in tests: the option lists, the validator, and the trace all read one generated set, and widening the credential path to match would fail.
Why the receipt retry only waits on one kind of failure
Signing and storing the audit row happen after the response, so the receipt id in `x-passcontrol-receipt-id` is a promise that the row will exist rather than a claim that it does. Only a 404 earns a retry — four short backoffs inside about two seconds. A 401 is a wrong key, a 429 is a limiter that retrying would only anger, and a 5xx is not something a sign-in should sit through. Exhausting the attempts falls through to the same message as before, which by then is a fair description.
Why the trace cannot just be pessimistic
The obvious alternative was to project the largest call the gateway would accept, which would make the panel conservative by construction. There is no such number: the proxy applies no ceiling to a requested output size and the pricing table carries no per-model output limit. So the size became something you can state, and the room left became something the panel always shows — a fact that does not depend on any estimate.
Cloud
Prove who your agents belong to
A passport can now be bound to a GitHub account you prove you control — and carry a company register number that anyone reading a receipt can go and check for themselves.
A second free way to prove it
Until now the only checkable owner claim was a domain. You can now claim a GitHub account: create a public repository called `passcontrol-owner` holding one file with the token we issue, and we read it. Only your account can create a repository at that path, which is the entire proof — no permissions to grant, no app to install, and nothing of ours in your account beyond one text file you can delete.
It sits beside the domain check as an equal
Not a weaker tier and not a stronger one. Both prove control of a public identifier, and neither proves who the person behind it is — so the page says "control of this domain" or "control of this GitHub account" rather than the word "verified" on its own. If a label ever reads as more than what was checked, the whole ladder is decoration.
A company number, and an honest label on it
You can add an EU VAT number or an LEI. We look it up in the register, record the legal name it returns, and show whether the entry is active. We do not claim it is yours — a company register is a public record anyone can quote, so there is nothing there to prove control of, and any product telling you otherwise is selling you a label. What it is worth is this: next to a proof you did pass, it gives a reader something they can go and check in the same public register we used.
Where it shows up
On the public verification page for every passport you have chosen to publish, and — for the proof, never the company claim — in every signed receipt. A receipt is handed to third parties, and an assertion we did not check does not belong in one.
Technical details +
Changed
Checking a claim is now one action for both kinds. Declare a domain or a GitHub account and press the same button; the dashboard shows the exact URL we will read, derived from what you entered rather than typed into the instructions.
Your data export now carries the company line. It is something you entered, so an export that quietly left it out was not the copy of your data it claimed to be.
Why the company number is not a tier
Two reasons, and the second is the one that decided it.
It cannot be proven for free. A domain and a GitHub account can be, because you publish a token somewhere only you control. A register has nowhere to put one — which is exactly the gap that paid business-verification services exist to fill.
And there is one owner record per workspace. So if the company number were a *kind* of claim, recording it would REPLACE your domain proof rather than sit alongside it: you would trade something proven for something unprovable and end up worse off with a better-sounding word on the screen. It is stored as its own separate fields on the same record, it can never move the evidence label in either direction, and both the dashboard and the public page say "asserted, not proven" in those words.
Why a repository and not a gist
A gist works and binds the account the same way, but its address needs the gist id — a second thing for you to copy across, and a second value we would be building a URL from. A fixed repository name means the address is derived from your username alone, exactly as the domain check derives its address from your hostname alone. One input, one thing to validate, and re-checking later needs nothing you have to keep.
We also read the file from GitHub's raw file host rather than its API. Unauthenticated, the API allows 60 requests an hour per address, and this runs from shared infrastructure — so a busy hour would have failed your verification for reasons that had nothing to do with you. One consequence worth stating: if GitHub itself cannot be reached, that costs your binding nothing. A domain that stops answering is your web server, and three failures in a row demote the claim; somebody else's outage is not evidence about you.
Cloud
Passports stop being immortal
New passports now carry a one-year deadline you can extend without replacing the key, and the gateway notices when one is being used from two countries at once.
A passport now has an expiry date
Every passport this product has ever issued lives forever unless somebody revokes it. Setting a deadline was possible, but nothing ever did it — so the careful thing cost more effort than doing nothing, which is not a real choice. New and rotated passports now get a one-year deadline. Passports created before this update are untouched and still never expire on their own; retiring one stays a decision you make, not something an update does to you.
Renewing is not replacing
You extend the deadline in place. The key stays the same, the agent keeps running, and nothing has to be redeployed — so keeping a passport current is a click rather than a migration. Replacing the key is still there when you want it, and still gives you a grace window where both keys work.
One passport, two countries, one live visa
The same passport running in five containers behind an autoscaler is normal architecture, not an incident, and it raises nothing here. Two different countries inside the lifetime of a single work-visa is a different claim about the world, and that now shows up on the agent. It needs no change to your agent at all — the gateway already sees everything it needs on the way past.
OpenAI's Responses API goes through the gate
Agents built on the Responses API had to fall back to the chat endpoint to be governed. They no longer do. The same kill switch, scope check, policy evaluation and budget reservation apply, and the same signed receipt comes back.
A receipt now names the rules it was judged against
A receipt already told you what a call did. It now also carries a fingerprint of the exact authorization rules the gateway had in force at that moment — the policy, the scopes, the caps. Check a receipt a year from now and it tells you the rules of the day it was issued, not the rules of today. Receipts issued before this change verify exactly as they did before.
Three more guides
The learning centre now covers securing an MCP server's tool access, putting spend controls on an agent, and revoking an agent's access when something goes wrong.
Technical details +
Fixed
Buttons and focus rings stayed green on a dashboard themed violet. Choosing a colour moved the background, the charts and the logo and left every control behind, which reads as a half-broken theme rather than a missed rule.
Security
Every secret the gateway derives is now taken from its own purpose-labelled subkey rather than from the visa signing secret directly. Nothing was exploitable — the three uses never met — but one secret doing three jobs means a rotation or a weakness in any one of them reaches the other two, and that is a blast radius worth not having.
Why renewal had to exist before expiry could
An expiry whose only remedy is generating a new key punishes the operator who sets one: every year, a key change, a redeploy, and a grace window to get wrong. That is how an expiry feature ends up switched off everywhere. So renewal extends `expires_at` and touches nothing else, while rotation stays what it always was — a new keypair, the previous one honoured for a grace period. Two operations, because they answer two different questions: "is this passport still current?" and "has this key been exposed?"
There is no database default behind the one-year deadline, on purpose. The column has existed since the rotation work shipped and was deliberately left without one, and a default would also catch the direct-key creation path, which is a different credential with different rules. The lifetime is one constant in application code instead, which is airtight because creating an agent row directly was revoked from the browser-facing role some time ago — every creation path in the product goes through the same function. An explicit "never" still means never, so the deliberate opt-out survives.
Why the detector ignores raw concurrency
Counting how many places a passport is used from would fire on an autoscaler on day one, get switched off in week one, and protect nothing from then on. Ranking the signal is the entire feature; the plumbing is trivial.
So: several addresses inside one country raise nothing at all — that is a fleet. Two countries overlapping inside a single live visa window is strong, because an autoscaler's replicas share an egress region and a copied key usually does not. A source that has never been seen before raises a medium signal only when every earlier source is older than a stability floor, which self-suppresses on a deployment whose addresses rotate. Every storage protection we could offer does nothing for a container, and containers are most of what this gateway sees — detection is the part that works for every agent, including the ones with nowhere safe to put a key.
How the policy fingerprint stays backward compatible
The receipt gains one optional claim and its version stays where it was. That is what lets a verifier written last month read a receipt issued today and simply not look at the new field, rather than rejecting it as a version it does not know. Verifiers that do read it get the revision of the *effective* rules — policy document, scopes, token and cost caps, and the posture taken when a policy cannot be read — rather than the policy document alone, because a call is authorized by all of them together.
Self-hosted
The self-hosted build stops being a copy of ours
If you run PassControl yourself, the source now ships with its own colour, its own icon and its own notices pages — and none of the machinery that exists only to run a hosted service.
Our brand is off your operators' screens
The company mark and wordmark are gone from the self-hosted build, the browser tab icon is a plain shield in the build's own accent rather than our green, and our social preview images and favicon no longer ship at all. Source-available software carrying our trademark on your team's operational UI is the part with actual teeth, and it took two goes to finish: the first pass removed the logo and left the colour behind.
Your notices, not our legal pages
The build used to carry our privacy policy, terms, subprocessor list, acceptable-use policy and cookie notice — documents describing a service you do not operate, sitting on the instance your users log into. They are gone. In their place is a notices section your deployment fills in: an operator notice, how data is handled on your instance, and your account recovery process.
Sign-up no longer assumes our invite system
The personal-invitation-link flow exists for a closed hosted beta and means nothing on your instance. Sign-up now uses the shared invite code you set, or opens fully — one environment variable, your call, and no page that reads an invitation token out of a URL fragment that will never contain one.
Who counts as an operator is a documented setting
The list of accounts allowed to see instance-wide operations is now an environment variable with an entry in `.env.example`, rather than something only the hosted deployment knew how to configure. Diagnostics have stopped reporting on hosted-only subsystems too, so an instance health check no longer describes components your deployment does not have.
No product cap
The monthly call ceiling exists to keep a free hosted beta affordable. Your gateway has no such ceiling, and it no longer carries the code for one — the limit is not disabled in your build, it is absent from it.
Technical details +
Changed
The documentation now leads with the hosted path, and keeps self-hosting as its own complete track further down rather than the first thing you read. Self-hosting got shorter to find and did not get shorter.
Why the colour was the last thing to go, and how it is kept out
Removing a logo is one file. The brand colour was a hundred occurrences across three stylesheets in two different notations — a hex literal and an `rgba()` triple that is the same colour written for borders, glows and gradients — so an earlier estimate of "about eleven" was wrong by an order of magnitude, and the guard written to check the work matched the two exact strings it knew about, reported the migration finished, and left six behind. It now flags any saturated yellow-green by geometry rather than by string, which is what found the last of them, on a hover state in a file nobody had opened. Everything downstream reads through a single accent token, so the self-hosted build's colour is one value rather than a stylesheet.
How the boundary is enforced rather than remembered
The public source is generated from ours by an allowlist: a file ships because it is listed, not because nobody remembered to remove it, which is the direction of failure you want on this. Pages that exist only for the hosted service are removed wholesale, and the build then fails if any file that did ship still references a module that did not — so a removed page cannot leave a dead link or a dead import behind on your instance. That check is why this release is a rebuild rather than a subtraction: several files came back in a form that makes sense on a deployment that is nobody's customer.
Cloud
Choose your dashboard's colour
The Control Tower can be green or violet, and the logo, the charts, the passport card and the controls all follow the one you pick.
Two colours, not a colour picker
You get the original green or a violet, and that is the whole choice. An arbitrary hex field on a screen whose job is to show you a spend graph and a kill switch is a way to end up with an unreadable warning, and the warning is the point of the screen.
No flash of the old colour
The colour is resolved before the page is sent, not applied after it arrives, so you never see the default paint itself and then change. That matters most on the slow first load, which is the one a new account gets.
The logo follows it too
A violet dashboard around a green logo looks broken rather than themed, so the mark moves with the accent. Each petal keeps its exact distance from the accent in hue, saturation and lightness — it reads as the same logo recoloured, not a different logo.
Technical details +
Why the choice is a cookie and not part of your account
A colour preference is not worth a change to the schema of a live database that holds credentials, and the write path it would add is exactly the kind of thing this product exists to make you audit. It is a cookie, which means it is per-browser — and the settings page says so rather than letting you discover it on your second machine.
The default accent emits no override at all, so for almost every request the page is byte-identical to what it was before this feature existed. The cookie's value is checked against the list of real accents before it can reach the page, including against the property names every JavaScript object already has, so nothing crafted can be smuggled through it.
Signing in now ends with your agent making a real governed call and handing you a receipt anyone can check — and you can sign out again.
You watch it work
Setup used to finish by telling you to go and try something. Now it tries it for you: the moment your machine is configured, PassControl makes one real governed call on your agent's behalf and shows you the signed receipt for it. The receipt is a small file that anyone can verify against our public key — no account, no access to your logs, no taking our word for it.
You can sign out
`passcontrol logout` revokes that machine's key and clears its credentials, leaving your provider and model settings alone. It asks before it does, because the passport it clears is a private key we have never held a copy of — once it's gone, it's gone.
Keys nobody uses now expire
Every machine you sign in on gets its own control key. Laptops get replaced and containers get thrown away, and those keys used to live forever. Now they carry a 90-day idle window that resets every time the key is used — so a machine you actually work on never expires, and one sitting in a drawer stops working.
This page
PassControl now has a public record of what changes. The practical version of each release comes first; the technical section is there when you want it.
Creating an agent stops handing you secrets to carry
`passcontrol agent create` used to end with two 43-character strings and an instruction to paste them into a file. It now points at signing in on the machine that will run the agent, which copies nothing at all — and `--write` saves the passport straight to your config instead of putting it on your screen.
Technical details +
Changed
`passcontrol try` has been removed. Signing in now does the same thing with your own agent instead of a shared demo one. Typing `try` tells you that rather than pretending the command never existed.
`passcontrol doctor --deep` runs the whole chain — sign a challenge, make a governed call, fetch its receipt, verify the signature — instead of stopping at "a visa can be minted".
The documentation now opens with the hosted path. It used to open with Docker and a local database, which is the harder of the two ways to use PassControl and not the one most people want.
Fixed
Signing in never offered to reuse an agent you already had, so every sign-in quietly created a new one. The list it read was always empty — we were reading the wrong level of the API response. If you signed in twice on 0.7.x you have a duplicate agent per sign-in; they are safe to revoke.
The test suite took two minutes because one test spent ninety seconds of real time waiting out a timeout. It now takes about twenty seconds, and that test got stricter rather than looser.
Security
Control keys can expire (migration 0041). Keys created before this update are unaffected and never expire on their own — retiring them stays a decision you make, not something an update does to you.
An expired key is refused with exactly the same answer as an unknown one. Telling an unauthenticated caller that a key *used* to exist would confirm they had guessed a real one.
How the proof is put together
The CLI signs a challenge with the passport it just wrote, receives a short-lived HS256 work-visa, and calls the keyless demo provider — which runs the full governance pipeline (kill switch, scope, policy, budget) and synthesizes the model response rather than resolving a Vault key or forwarding upstream. The response carries an `x-passcontrol-receipt-id`; the CLI fetches that call's row, takes the Ed25519 JWS receipt, and verifies it locally against the gateway's `/.well-known/jwks.json`. No part of this is new server surface — every leg already existed and was reachable.
Why the idle window rolls instead of capping
An absolute 90-day cap would retire a key that is in use every day, which breaks working pipelines on a schedule and buys nothing: a key someone uses constantly is a key they would notice losing. The threat is the key nobody is watching. So the deadline moves forward on every successful authentication, and only in the success path — a request that gets refused cannot keep a dead key alive for another window. Keys with no deadline are never given one by being used; the window is a property of how a key was minted, not something authentication decides. The column is not client-writable: migration 0012 grants `authenticated` update on `revoked_at` by column, so nothing in a browser can move the number.
Why `agent rotate` still shows you the secret
The private key is generated on your machine and discarded when the command exits — the server has never held it, and rotation mints a new key rather than re-revealing the old one. A default that neither printed nor stored the secret would create an agent whose passport was already lost. Rotation also refuses to write the file, because doing so mid-grace-window would destroy the only copy of the key that is still working.
Signing in could fail with instructions to change a setting you had never set. It now names the file the setting actually came from.
The fix
If signing in could not reach the gateway, it told you to unset an environment variable. For most people that variable was not set — the value came from a configuration file written months earlier. So you would run the command it suggested, nothing would change, and PassControl would look broken when it was merely configured. It now tells you which file the address came from, and what to edit.
Technical details +
Why it was wrong
The CLI copies values out of `.passcontrol` and the global profile into the process environment at startup, so by the time the error was written, "the shell said so" and "a file on disk said so" were indistinguishable. The CLI already tracked which keys had been injected from a file — that distinction exists so a checked-in config cannot pass itself off as an operator decision for security-relevant settings. This applies the same distinction to advice. It also takes the *last* matching config source rather than the first, because a project file overrides the global one, and naming a file whose value never took effect is the same failure with the operands swapped.
On a fresh install, `passcontrol login` tried to reach a gateway on localhost instead of PassControl Cloud, and failed for everyone who had not already set one up.
The fix
Signing in now defaults to Cloud. If you self-host, a gateway you have configured still wins — the point of signing in is to reach your gateway, not ours.
Technical details +
What went wrong
Every other command falls back to `http://localhost:3000`, which is right for them: they run after you have set something up, and a local stack is a reasonable guess. Signing in is the one command designed to run *before* any configuration exists, so it needed its own default and did not get one in the published build. Confirmed by unpacking the published 0.7.0 artifact rather than by reading the source, which was already correct — the package check counts files, it does not read them.
Setting up a machine took eight steps and two 43-character secrets copied out of a web page. Now it is one command and one click.
One command, one approval
`passcontrol login` shows you an eight-character code and opens the approval page. You type the code, press Approve, and your machine is configured. Nothing gets copied between a browser and a terminal — which is where this project's one real support incident came from: a passport secret pasted into a field expecting a provider key, rejected with no explanation.
The key still never leaves your machine
The keypair that proves your agent's identity is generated on your computer, and only the public half is ever sent. That has always been true and this did not change it. It is the whole product.
Technical details +
Security
You type the code yourself, on purpose. We will never send you an approval link with the code already filled in — if you receive one, it is not from us.
Why the code is typed and not pre-filled
An earlier design opened the approval page with the code in the URL, which is friendlier and is an account-takeover vector. In a device-authorization flow, the human carrying the code from the terminal to the browser *is* the channel binding — it is the only thing proving that the person approving is the person who asked. Pre-filling removes it: an attacker starts the flow on their own machine, sends you the link, and one click on your already-signed-in browser hands them a write-scoped key to your workspace. The published build is pinned by a test that fails if the approval URL ever grows a fragment or a query string.
How the credential is handed over
The browser never receives the API key and no callback URL is registered anywhere, which removes the open-redirect and code-interception surface a real OAuth loopback flow would add. The CLI polls for its own grant using a secret device code it alone holds; the browser leg only ever carries the short, low-value user code. The grant is sealed at rest and redeemed exactly once, atomically.
Google's Gemini models work through the gateway, and a wrong URL gets a real page.
Gemini support
Gemini is available through Google's OpenAI-compatible endpoint, so it works the same way every other provider does: point your agent at the gateway, and the real key is injected from the vault at call time.
A better 404
A mistyped address gets a page that helps instead of a blank one.
The connector stopped taking egress permissions from a file, and a Docker daemon that is merely starting up is no longer reported as stopped.
Tunnel destinations are yours to name
The connector can be told which hosts an agent may tunnel to. A `.passcontrol` file — the kind that travels with a cloned repository — was able to add destinations to a connector started with no flags, and say nothing about it. That list now comes from you, on the command line, or not at all.
Cold Docker is not broken Docker
Starting the local stack while Docker Desktop was still warming up reported the daemon as stopped, which sent people to restart something that was already on its way up.
The connector, and a second factor on every credential
Any tool that takes a base URL and an API key can now use passport identity — and creating or changing a credential in the dashboard requires your second factor.
A front door for tools that only speak API key
Most agent tools accept a base URL and a key and nothing else. The connector is a small local process that holds your passport, mints and refreshes visas, and answers on a stable address — so those tools get governed identity with a dummy key, and never hold a real one.
It refuses to intercept TLS, and says so
Configured as an HTTPS proxy, the connector refuses tunnels rather than terminating them. Governing a tunnel would mean installing a certificate authority on your machine that can impersonate any site to any program that trusts it. PassControl exists to get one provider secret off your machine; installing a broader one to save an environment variable is the wrong trade. It answers honestly with the address to use instead, rather than quietly tunnelling a call that looks governed and is not.
Second factor on credential changes
Minting an API key, adding a provider key, or rotating a passport now requires a second factor if you have one enrolled. Stopping a leaking credential deliberately does not — making it harder to stop a leak than to create one would be the wrong way round.
PassControl has been published since late July. These notes begin at 0.6.0 — earlier versions are in the repository's history.
Why the notes start here
Writing entries for versions we would have to reconstruct from commit messages would mean guessing at what shipped, and everything on this page is a claim about a security product. Rather than invent a history, we started keeping one. The full commit history is public.