Frameworks & rails
Two boundaries, deliberately separate. A framework adapter governs the tokens an agent burns thinking; a rail adapter governs what it pays out. Merging them would hurt the moment a second payment rail arrives.
| Governs | Direction of control | Contract | |
|---|---|---|---|
| Framework | the internal channel — tokens | the framework calls the adapter | RunGuard |
| Rail | the external channel — payouts | the agent calls the adapter | PaymentClient |
They differ in currency, in counterparty, in failure mode, and in that last column — which is the one people miss. When AP2 or a card rail arrives it needs the second contract and none of the first, and a merged interface would make that a rewrite rather than an addition.
The dependency arrow points one way
Three calls, and that is the whole surface
from tesoro import Governor
from tesoro.adapters.base import RunGuard
guard = RunGuard(Governor.load(), budget_usd="0.40")
allowed, why = guard.start(model="claude-sonnet-4", provider="anthropic")
if not allowed:
raise SystemExit(why) # refused before a single token is spent
for step in agent_loop():
if guard.should_stop(spent_so_far):
break # once per step
guard.finish(actual_cost_usd) # envelopes consume here
A framework adapter is nothing more than the code that arranges for a particular framework
to make these three calls. Which is why an agent on a framework with no adapter here can
still be fully governed by using RunGuard directly — it imports nothing, so it needs no
extra at all.
Four properties worth knowing
-
A governor of
Noneis valid and means ungoverned. Every call allows, so an agent written against the guard behaves exactly as it did before governance existed. That is what keeps this a layer; the alternative isif governor:scattered through an agent loop, and the branch that gets forgotten is always the one that mattered. -
A governor with no
budget_usdinvents no ceiling. Silence is not zero. A run with no declared budget is ungoverned on that axis, and refusing work nobody said was too expensive would be the layer overreaching. -
Two ceilings, and the tighter wins. The
budget_usdyou declared and whatever the policy pack says — including limits the guard knows nothing about, like a daily envelope crossed partway through a long run.stop_reasondistinguishesrun-budgetfrom a control name, because the two are adjusted in different places. - A refused run neither stops nor settles. The refusal is recorded as evidence, so both later calls check that authorisation actually approved — otherwise a refused run reports itself as stopped, and those are different facts with different remedies.
The four framework adapters
Each is an optional extra. Nobody pays for a dependency they do not use, and
the core imports no framework — checked by an AST walk, and
import tesoro pulls in no adapter at all, checked in a subprocess because an in-process
check would pass regardless once a test file had run its own imports.
tesoro[claude] — Claude Agent SDK
The one framework here that already ships a cost ceiling:
max_budget_usd stops a run when estimated LLM cost reaches it. So this adapter's job is
not to add a ceiling the SDK lacks — it is to make two ceilings agree, and to supply what a single
per-run number cannot express:
- a cumulative budget, so ten runs of $0.40 cannot quietly cost $4.00 against a $1.00 day;
- a decision recorded as evidence, attributable to the control that made it;
- a refusal before the run starts, rather than a stop partway through with tokens already spent.
adapter = ClaudeAgentAdapter(Governor.load(), budget_usd="0.40")
options = adapter.options_for({"model": "claude-sonnet-4"}) # carries max_budget_usd
options_for() returns the tighter of the two and never raises the
caller's own limit — a governance layer that widened a limit somebody set would be doing the one
thing no control in this system may do. stopped_by_sdk(result) tells the SDK's ceiling
apart from the layer's, because they are adjusted in different places and reporting one as the other
sends whoever is debugging to the wrong file.
tesoro[adk] — Google ADK
ADK caps max_llm_calls. A step ceiling is not a spend ceiling: one
long-context call can cost more than fifty short ones, so max_llm_calls=50 bounds
nothing about money. Here should_stop() is not a second opinion — nothing else in an ADK
run is watching money.
run_config_for() leaves max_llm_calls alone rather than deriving it from
the budget. The two bound different things: a step ceiling catches a runaway loop whose steps are
nearly free, which a spend ceiling notices only slowly; a spend ceiling catches one enormous call,
which a step ceiling never notices at all.
tesoro[langgraph] — LangGraph
Same situation: recursion_limit bounds a graph's shape and says nothing about
money. LangGraph has no “stop politely” return value the way ADK's callback does — a graph ends when
a node says so or when something raises — so the ceiling surfaces as
GovernedBudgetExceeded, carrying the attributed control. The run stopped is not
actionable; the daily envelope stopped it is.
Only on_llm_start is implemented, because that is the moment before
money is spent. A callback that fires after a call has already cost what it cost is a report, not a
control. Every other hook is a no-op so a new one in LangChain's moving callback surface cannot
break a governed run.
tesoro[crewai] — CrewAI
This is the one adapter whose framework surface is unverified. The asymmetry is worth being blunt about:
| Adapter | Governance behaviour | Framework hook names |
|---|---|---|
claude · adk · langgraph | tested | from SDKs that ran end to end in the proof-of-concept |
crewai | tested identically | documentation only — no precedent, SDK not installed |
So step_callback, max_iter and max_rpm are CrewAI's documented
names, not names observed in a run. A version that renames one breaks this adapter without breaking
a single test. If they are wrong, crew_kwargs() produces a dict CrewAI rejects
immediately — the loud kind of wrong, and the kind to prefer.
One note on max_rpm, because it looks like a cost control and is not: pacing at a rate
limit is unbounded in total. Sixty requests a minute, held all day, is eighty-six thousand requests.
The rail contract
x402 is the only rail today — HTTP 402, USDC, Base Sepolia. Keeping settlement behind one boundary
is what makes the layer rail-agnostic: nothing above adapters/ knows what a 402 is.
governed = gov.wrap(buyer) # the ONLY object holding the signer
The returned object is the only thing holding the signer, so the agent cannot pay without a
decision. A client that cannot pay is refused by name —
isinstance against a runtime protocol returns a bare False, which tells an
integrator nothing, so the error lists the missing members.
What is verified, and what is not
Verified here
All four adapters against fakes, with no SDK installed — the three calls, the tightening rules, channel separation, consumption on finish, refusal before start, attribution on every stop, and that no module imports its framework. 49 tests.
Not verified here
That the hook names and signatures still match the current SDKs. An SDK that moves a hook breaks the integration without breaking these tests. That is what the version pins are for, and why end-to-end runs live in the examples repo.
Testing against fakes is deliberate rather than lazy: a contract exercised only with a real SDK present is a contract nobody checks, and all four adapters would then be verified by their absence. But a suite that passes with nothing installed cannot tell you the SDK still looks the way it did.
Writing your own
class MyFrameworkAdapter:
channel = "internal"
def __init__(self, governor=None, *, budget_usd=None):
self.guard = RunGuard(governor, budget_usd=budget_usd)
def before_run(self, *, model=""):
return self.guard.start(model=model, provider="my-provider")
def should_stop(self, spent_usd):
return self.guard.should_stop(spent_usd)
def after_run(self, *, actual_cost_usd=None, success=True):
self.guard.finish(actual_cost_usd, success=success)
The remaining work in any adapter is always the framework-shaped part: where its hooks live, what they are called, and what a stop must return to end a run. That is the only thing the four shipped adapters do differently from each other.