Action boundaries
A boundary is a limit you set before work starts, and a record of who let the agent go further. Agents run for long stretches now, and the moment where you would have asked "should this continue?" is gone. A boundary puts it back, at the points that can actually stop an agent.
# ~/.tokenops/config.yaml
boundaries:
- name: session-spend
action: continue # the agent's next tool call
when: { session_cost_usd: ">= 20" }
require: ask # allow | ask | approver:<role> | deny
- name: big-subagents
action: subagent_model # the model a subagent is about to run on
when: { model_tier: "> sonnet" }
require: approver:admin
- name: plan-floor
action: continue
when: { plan_left_pct: "< 5", window: week }
require: deny
- name: budget-floor
action: spend # the next tool call, judged by money
when: { budget_used_pct: ">= 90", budget: monthly }
require: ask
- name: no-opus-routes
action: model_route # the router moving a request to another model
when: { model_tier: ">= opus" }
require: ask
- name: team-upload
action: share # figures leaving the machine
when: { destination: "== team" }
require: askThen wire the pause point and watch what the rules would do:
tokenops hooks install --boundary # Claude Code
tokenops hooks install --boundary --client opencode
tokenops boundaries statusThe headroom pause
tokenops init writes one boundary on every new config, so the pause before a plan runs out is there from the first session:
boundaries:
- name: headroom-pause
action: continue # the agent's next tool call
when: { plan_left_pct: "<= 10" } # the tightest plan window, 10% left
require: ask
stage: shadowWith no window, plan_left_pct reads the tightest window TokenOps knows — the 5-hour or the weekly one, whichever has less left. 10% left leaves the agent room to finish its step, commit and hand over. In shadow it asks nothing: it records each session it would have paused, and the glance's findings (tokenops glance, GET /api/findings, the menu bar) say "The headroom pause would have paused 3 sessions this week". When that looks right:
tokenops boundaries promote headroom-pauseor press Promote pause on that finding in the menu bar, which says what the rule will do and how to undo it before anything changes (POST /api/findings/act). Demoting stays on the CLI.
From then on it asks at Claude Code's prompt when someone is there, and otherwise refuses with the id tokenops approve takes; Codex and Cursor are told through the glance. Init also installs the pause point (the boundary hook) in Claude Code and opencode, because a rule without one records nothing, and the findings say so if it is missing.
A config written before init did this is not changed behind your back. The findings suggest the pause when a plan is watched and none is set; tokenops boundaries headroom-pause adds it in shadow and installs the pause point. A continue rule of your own on plan_left_pct, whatever its name, counts as a headroom pause and is left alone. Change the threshold by editing plan_left_pct in config.yaml.
The loop breaker
An agent that is stuck repeats itself: the same test run, the same file read, the same pair of calls, with nothing changing in between. tokenops init writes a second boundary for that, also in shadow:
boundaries:
- name: loop-breaker
action: continue # the agent's next tool call
when: { loop_calls: ">= 4" } # 4 calls round a loop, no file changed
require: ask
stage: shadowloop_calls counts the session's latest tool calls that went round a loop with no file changed in between:
- the same call again: the same tool with the same input, four times in a row by default;
- a short cycle: the same two or three calls, in the same order, at least three times (test, read, test, read, test, read). Twice is ordinary work and is not counted.
An Edit, Write or other file-changing call it has not seen before is progress and starts the count again; the same edit made again is not progress, since it fails or changes nothing. Repetition alone is a poor sign of a loop; repetition with no progress is a much better one (IBM, Unsupervised Cycle Detection in Agentic Applications, arXiv 2511.10650).
In shadow it asks nothing: the findings say "The loop breaker would have paused 2 sessions this week", with a Promote action, and tokenops boundaries status how often it matched. Promoted, the next call in a loop asks at Claude Code's prompt, or, unattended, is refused, with what repeated and a nudge for the agent: change approach, or ask the user.
tokenops boundaries loop-breaker # add it to an older config, in shadow
tokenops boundaries promote loop-breakerWhat it keeps, and what it cannot see:
- No input is kept. The pause point reads each call's tool name and input, hashes the input (ignoring Bash's
description, which an agent rewords on every retry) and keeps the tool name and the hash, under~/.tokenops/boundaries/calls/, the newest 64 calls per session; a session's log is removed once it has been idle for a day. Decisions name the tools that repeated, never their input. - Claude Code only. opencode's hook does not send the tool's input, so
loop_callsis never read there, and Codex and Cursor have no hook;boundaries statusshows the rule as not evaluated on them. - Shell edits are not seen. A file changed through a shell command (
sed -i, a code generator) is not progress to it, and polling the same command while waiting (CI checks, a deploy) looks like a loop. That is what shadow is for: watch what it would have paused before promoting. - One answer per session. As with every
askrule, a yes at the prompt holds for the rest of the session, so a later loop in the same session is not asked about again.
Not yet: stopping a run that has cost more than similar successful runs usually do with no progress (EET, arXiv 2601.05777). The pause point does not know a session's kind of task, and the history it would compare with lives in the event store, which it never opens.
Rules
| Key | Takes |
|---|---|
name | lower-case letters, digits, - and _; unique |
action | continue, spend, subagent_model, model_route or share (below) |
when | conditions, all of which must hold (below) |
require | allow, ask, approver:<role> or deny |
stage | shadow (the default) or enforce |
| Action | What it is about to do | Decided by |
|---|---|---|
continue | the agent's next tool call | the pause point |
spend | the agent's next tool call, judged by money | the pause point |
subagent_model | the model a subagent is about to run on | route-guard's Agent hook |
model_route | the router moving a request to another model | the router, in the proxy |
share | figures leaving the machine: a team upload, an OTel export | the uploader and the exporter |
Conditions read only figures TokenOps already holds; no prompt text is read to evaluate a rule. The one input read is a tool call's, for loop_calls, and only its hash is kept. Each needs a comparator: <, <=, >, >= or ==. An action takes only the conditions it can be judged by where it is decided:
| Condition | Compares | Actions | Read from |
|---|---|---|---|
session_cost_usd | the session's API-equivalent cost so far | continue, spend, subagent_model | the coach hook's running total, updated at each turn's end |
plan_left_pct with window: 5h | week | the share of the plan window left; with no window, the tightest | continue, subagent_model, model_route | in Claude Code, the windows it reports to TokenOps' status line (if fresher than 30 minutes); otherwise the daemon's figures |
budget_used_pct with budget: <name> | the share of a configured budget used in its window; with no name, the fullest | spend | the daemon's figures |
model_tier | haiku < sonnet < opus | subagent_model, model_route | the subagent's model (or the session's when it inherits); the model the router would move to |
destination | == team or == otel | share | where the figures are going |
loop_calls | >= or > a whole number from 2 to 64: the latest calls that went round a loop with no file changed (the loop breaker) | continue | the tool calls Claude Code's hook reports, as names and input hashes |
workflow_id, agent_id | == with * wildcards, e.g. "== claude-code:*my-repo*" | all but share | the session's attribution, or the request's |
A figure TokenOps cannot read never satisfies a condition: a boundary that cannot see its figure lets the action through. In particular, session_cost_usd needs the coach hook (tokenops hooks install --coach).
The daemon's figures. Plan headroom outside Claude Code and budget use need the event store, which the pause point never opens. So when a rule compares one of them, the daemon measures them every minute into ~/.tokenops/boundaries/figures.json, and the pause point reads that one file while it is under five minutes old. With the daemon stopped, those rules see no figure. In opencode, which does not say which provider a session uses, plan_left_pct reads the tightest plan TokenOps watches.
A config with an unknown action, condition or requirement is refused when it loads, with the rule named.
What a rule requires
require sits on the same ladder as every other TokenOps authority: allow is automatic, ask and approver:<role> need approval, deny refuses. When several enforced rules match, the strictest wins — deny, then a named approver, then ask, then allow — so a boundary can only tighten.
| Rule | Someone at the keyboard (Claude Code) | Nobody at the keyboard, or opencode |
|---|---|---|
ask | Claude Code asks; yes holds for the rest of the session | refused, with the rule, what was crossed and tokenops approve <id> |
approver:<role> | as ask | as ask, naming the role |
deny | refused | refused |
The refusal is written for the agent to act on: it says what was crossed, tells it to stop rather than retry, and names the command you run to let it go on.
approver:<role> in your own config is answered on this machine, like ask. In your organisation's rules it goes to that role: see Organisation boundaries. Routing the request to someone holding the role needs the team plane, which comes later; boundaries status says so on the rule.
Inside TokenOps: routes and figures
model_route and share are decided inside TokenOps, so no agent reads their refusal, and nobody is at a prompt. ask holds the action until you approve it; deny holds it until the rule changes.
- A held route leaves the request on the model it asked for. The request itself is never failed or delayed. Each route (provider, from and to model) is its own decision, so an approval lets that route through: once, or for 24 hours with
--session. The router asks only when it would actually apply a route, never while observing. - Held figures are not sent: the hourly team upload (and
tokenops team sync), the OTel metric push, and the OTel event stream, whose events are dropped while held, not queued; the event store keeps every one. Each destination is its own decision. Uploads resend their last days whole, so an upload held for a while fills the gap once it goes.
Shadow first
Every rule starts in shadow. A shadow rule is evaluated on every call, counted, and recorded once per session as a decision that "would have asked" or "would have refused" — and changes nothing. Watch it for a while:
tokenops boundaries statussession-spend continue → ask [shadow]
would have fired 41 times in 3 sessions, last 2026-10-12 16:02
claude-code shadow — records what it would have done; promote to enforce
opencode hook not installed — tokenops hooks install --boundary --client opencode
codex advice only — its hooks cannot refuse a call, so the rule is not enforced there
cursor advice only — its hooks cannot refuse a call, so the rule is not enforced there
note: in shadow; `tokenops boundaries promote session-spend` makes it bindA rule that would interrupt constantly shows up here before it does. When it looks right, promote it:
tokenops boundaries promote session-spendPromotion writes stage: enforce beside the rule in config.yaml, so a config shipped to several machines carries whether its rules bind. Nothing promotes a rule for you.
Answering
tokenops approve bnd-1a2b3c4d5e6f # the next step
tokenops approve bnd-1a2b3c4d5e6f --session # the rest of the session
tokenops approve bnd-1a2b3c4d5e6f --confirm # show what waits, then ask y/N
tokenops deny bnd-1a2b3c4d5e6fThe menu bar lists what waits under Waiting on you. Deny answers there, in one click, and never asks for a signature. With a presence key enrolled, Approve shows the Touch ID, Apple Watch or password dialog and answers with the signed approval, with no terminal; the panel then says what happened ("Approved session-spend…, signed with presence key pk-…", or why not: the dialog was cancelled, the screen is locked, the key is from another Mac). Without a key, Approve… opens a terminal running tokenops approve <id> --confirm, so the approval is still you, at a terminal, typing yes.
Only a person should approve: tokenops approve refuses to run without a terminal, the MCP tool can only deny, and the daemon API approves only with a presence signature (below). The agent a boundary stopped can call tools and run commands, so an approval through any of those would be the agent approving itself. How far that holds is below.
An answer applies to the session it was asked in, and is honoured for at most a day. Answering at Claude Code's prompt counts too: TokenOps reads the answer back and records it.
Claude Code has guardrails of its own, and TokenOps reads their verdicts rather than asking over them. Claude Code puts a boundary's ask in its own permission prompt, so the person sees one question per call. But Claude Code settles the strictest answer first: when its auto mode classifier, a permission rule, a plugin or a PreToolUse hook stops the call, no prompt appears. TokenOps reads that from the call's result in the transcript. It does not count the stop as your yes. It records Claude Code's verdict as evidence on the boundary's decision (kind: vendor_verdict, with the session and call, when the result was written, and what stopped it), and asks at the next call. That next prompt is the first time you are asked, not the second. A hook that refuses with its own free-text reason reads like a call that ran and failed, so TokenOps treats it that way.
Every decision names its actor — the client, session, agent and local user — and, once answered, its approval: who, how (cli, mcp, api or prompt), when, and under which rule.
tokenops explain bnd-1a2b3c4d5e6fWhat approval guarantees
Approval is a guardrail against an agent that is wrong or misled, not a security boundary against a process that wants to get through. It holds against the agent's own tools; it does not hold against everything that runs as your user.
It guarantees:
- An agent cannot approve through TokenOps' MCP tools:
tokenops_boundariescan only deny, and the settings tools (tokenops_configure,tokenops_routing,tokenops_coach,tokenops_experiment) apply a change only after you confirm it at your client (MCP server). - The daemon's decision endpoint (
POST /api/boundaries/decisions) approves only with a valid presence signature, which takes a person at the Mac; without one it can only deny. tokenops approverefuses to run when its standard input is not a terminal, so an agent's plain shell call fails.- Every answer is recorded with who gave it and how (
cli,mcp,api,promptorteam), andtokenops explain <id>shows it.
It does not guarantee that the approval came from you. Anything running as your user can act as you:
- The terminal check is that standard input is a terminal, nothing more. A command can get one for itself:
script -q /dev/null tokenops approve <id>passes it, and so wouldexpect,tmuxor any pseudo-terminal. - The daemon's API token is a file in
~/.tokenopsthat your user can read. With it, a process can call the settings endpoints directly, such asPOST /api/modeorPOST /api/routing/rules. - The boundary ledger and
config.yamlare files your user can write.
TokenOps does not try to tell those apart from you. A check that looks for a "real" terminal (a controlling terminal, its session, the parent processes) cannot separate a pseudo-terminal an agent opened from the one you typed into, and a check that seems to would promise more than it delivers.
Recommended setup:
- Do not give agents an unrestricted shell for TokenOps. In Claude Code, add permission rules that ask before
Bash(tokenops approve:*),Bash(tokenops mode:*),Bash(tokenops routing:*),Bash(tokenops boundaries:*)andBash(script:*), and before anycurlto the daemon's address; other harnesses have equivalents. - Answer boundaries yourself: at Claude Code's prompt, in the menu bar, or with
tokenops approve <id> --confirmin your own terminal. - For a guarantee that holds against a determined process, use the vendor's organisation controls or a gateway (see below).
Presence-checked approval
On a Mac with a Secure Enclave you can make an approval prove that a person at the machine gave it (ADR 0015). It is opt-in.
tokenops boundaries presence enroll # create this Mac's key: one dialog
tokenops boundaries presence enforce # boundary_approval: presence
tokenops boundaries presence statusenroll creates a key in the Secure Enclave that signs only after Touch ID, a paired Apple Watch or your login password, and has it sign once to prove it works. From then on tokenops approve (and tokenops routing decide <key> approve) shows the dialog and signs the approval: the decision, its rule and session, the scope, a fresh challenge for that raising of the decision and the figures you were shown. The pause point checks the signature against the enrolled key. A deny is never signed and never asks.
enforce writes the setting to config.yaml:
boundary_approval: presence # or presence-strict, or channel (the default)| Setting | What approves |
|---|---|
channel (default) | your terminal and Claude Code's prompt, as above. With a key enrolled, approvals are signed and checked in shadow: nothing is refused, and tokenops explain <id> says what enforcement would have done |
presence | only an approval signed with the key, or one given at Claude Code's own permission prompt (labelled harness-attested) |
presence-strict | only a signed approval. Claude Code's prompt does not approve, so the pause point no longer asks there: it refuses with the tokenops approve command |
With presence enforced:
script -q /dev/null tokenops approve <id>still gets a terminal, but not a signature: nobody touched the sensor, so the approval is refused.- An answer file written by hand is not honoured. It is removed, recorded on the decision as refused (
unsigned_answer_ignored,presence: refusedwith the reason intokenops explain <id>), counted intokenops boundaries statusand on the glance, and the agent's refusal says why. POST /api/boundaries/decisionsandPOST /api/routing/decisionstake an approval only with a validpresence_proof; the router honours a model upgrade approval only when its signature verifies. The MCP tools still only deny, and can no longer approve a model upgrade.- A replaced key file (not signed by the old key) holds every approval for 15 minutes and is announced on the glance, in the menu bar and in
boundaries status("approval key replaced at …").tokenops boundaries presence rotatemoves to a new key with the old key's signature (two dialogs) and holds nothing;enroll --replaceis for a new Mac or a lost key. - With no key enrolled, approvals are refused with the enrol command; deny, shadow rules and the team plane work as before.
The dialog names what you approve. It reads "tokenops-presence is trying to approve TokenOps boundary "session-spend" (continue) in claude-code session 1a2b3c4d: …, for the next step only". A paired Apple Watch is offered every dialog and shows the same text: a double press on the wrist approves even while you reach for Cancel on screen. Approve only a dialog you started, and read what it names. Every signed approval is recorded with the key that signed it ("approved in person: signed with presence key pk-…"), so one you did not intend shows up in tokenops explain <id>; tokenops deny <id> takes it back for the rest of the step or session.
Where it cannot ask. The dialog needs the logged-in user's screen. Over SSH, from a launchd job or in CI, and while the screen is locked, the helper refuses at once instead of waiting, and the approval is refused. Headless approval is exactly what this removes.
What it still does not guarantee. A process running as you can edit config.yaml (set boundary_approval: channel, demote or delete the rule) until rules can be sealed in a later release, and can replace TokenOps' binaries or the key file; a replaced key is held and announced, not prevented. The settings endpoints (/api/mode, /api/routing/rules, /api/budgets, /api/coach) are not behind presence. Linux and Macs without a Secure Enclave keep the channel guarantees above.
The helper, tokenops-presence, ships beside tokenops in the macOS release and inside TokenOps.app; the CLI and daemon stay pure Go.
In the menu bar. With a key enrolled, the menu bar approves inline. It asks the daemon for the statement that approves the decision (POST /api/boundaries/statements): the exact bytes the key signs and the dialog's text, both built by the daemon, never by the app. The helper inside TokenOps.app signs those bytes, showing the dialog, and the menu bar posts the daemon's proof back with only the signature added; the daemon checks it against the enrolled key as it checks tokenops approve. A cancelled dialog, a locked screen or a key from another Mac answers nothing, and the panel says which. With no key enrolled, a held key, or a TokenOps.app without the helper, Approve… opens the terminal as before. Handing out a statement grants nothing: it is worthless without the key's signature, and only the dialog can give one.
Where a boundary is enforced
| Client | continue, spend | subagent_model |
|---|---|---|
| Claude Code | enforced (--boundary hook, every tool) | enforced (route-guard's Agent hook) |
| opencode | enforced (plugin, tool.execute.before) | advice only |
| Codex, Cursor | advice, through the glance | advice only |
model_route is enforced by the router for every request through the TokenOps proxy, and share by the uploader and the exporter; each is idle where it is not in use (no routing configured, no team joined, no collector). boundaries status reports all of this per rule, so nothing claims enforcement it lacks.
Advice. Codex's and Cursor's hooks cannot refuse a call. Instead, an enforced continue or spend rule that holds on plan headroom or a budget is listed on the glance — tokenops_glance and GET /api/glance, under boundaries — with what the agent should do: stop and tell you, or stop and ask. A rule that needs a session's cost, or a workflow or agent, cannot be judged there and is left out rather than guessed.
Organisation boundaries
When this machine has joined a team, its owners and admins can set boundaries for every member, in the web view (Boundaries) or with tokenops team admin boundaries --set org.yaml, written exactly as config.yaml's section is. The daemon fetches them every five minutes.
- They only tighten. Organisation rules sit beside yours, named
org:<name>, and the strictest matching rule wins. You can add boundaries but not loosen theirs; their stage (shadow or enforce) is the organisation's to set, soboundaries promoteanddemoterefuse them. - They are signed. The server signs the document; this machine pins the key on the first fetch and checks the signature every time it reads the rules, the copy on disk included. Rules edited on disk are not rules, and a document under another key is refused (
tokenops team statussays so). Leaving the team forgets them. approver:<role>goes to the role. When an organisation rule naming an approver holds an action, the agent is refused (even with someone at the keyboard, who may not hold the role) and the daemon sends an approval request to the team plane: the rule, the action, the client and the figures that crossed it — numbers and names from fixed lists, never a prompt, a path or a name you typed (a test fails the build if one could be added). Everyone in the organisation with that role or above is e-mailed and answers in the web view (Approvals), or, as an owner or admin, withtokenops team admin approve <id>; the answer reaches this machine within a minute and is recorded with who gave it. Until then the action stays refused. You can deny it here; you cannot approve it here.- Nobody approves themselves through a token.
team admin approverefuses a request you made: an agent on your machine could hold your administrator credential. Another member with the role answers it, or you do, in the web view signed in with your account (a session opened from a sign-in link does not count: a machine's token can make one).
Local rules naming an approver are not sent anywhere: only your organisation's rules leave this machine as requests.
What a boundary is not
- Not a hard control. Removing the hook removes the boundary. A hard guarantee belongs at the vendor's organisation controls or a gateway.
- Not a tool permission system. Boundaries cover resource actions — continuing, spending, which model runs, whether figures leave — not which commands an agent may run. That is the harness's and CI's job.
- Never in the agent's way when broken. An evaluation that fails or takes over two seconds lets the call through, and the failure is recorded as a decision you can see.
The pause point answers from a few small files, never the event store: about 0.1 ms per call when nothing is crossed and 0.7 ms when a rule is, against a 5 ms budget the test suite enforces. Its decisions are written to ~/.tokenops/boundaries/decisions.jsonl, which the daemon ingests into the event store every minute.
See ADR 0014 for the design.