Spend you can cap
Copy MarkdownMix.install([{:wardrik, "~> 0.1.0-rc.1"}, {:kino, "~> 0.19"}])What you'll build
Two agents share acme's ceiling of two cents a day. The writer also has a budget of its own, one cent a day. You'll watch their spend climb in a live table. The writer stops to ask for more, and the ceiling refuses the editor outright.
The whole notebook spends about three cents of model calls.
What a call costs
Wardrik prices each model call from the model catalog, in dollars per million tokens:
{:ok, haiku} = ReqLLM.model("anthropic:claude-haiku-4-5")
haiku.costSo a call that reads 400 tokens and writes 700 costs:
(400 * haiku.cost.input + 700 * haiku.cost.output) / 1_000_000A budget in dollars refuses a model the catalog has no price for, since it can't count it. Give
one a price with Wardrik's :prices option.
A budget on paper
A budget caps what one agent spends on its model. WardrikPolicy.Budget.check/2 shows what it
makes of what was spent so far, with no model involved:
asks = WardrikPolicy.new!(budget: [usd: 0.01, per: :day]).budget
denies = WardrikPolicy.new!(budget: [usd: 0.01, per: :day, on_exhausted: :deny]).budget
for usd <- [0.004, 0.009, 0.012] do
%{
spent: usd,
asks: WardrikPolicy.Budget.check(asks, %{usd: usd}),
denies: WardrikPolicy.Budget.check(denies, %{usd: usd})
}
endThe call that crosses the limit runs. The next one does what on_exhausted says:
:ask- the default. The call waits for a person. Approving allows the budget'sgrantmore, another limit's worth unless set.:deny- the call is refused.
Messages round to the cent.
Setup
An org's ceilings come from your Wardrik.Orgs module. Here acme's agents share two cents a day,
and the ceiling refuses calls past it:
defmodule MyApp.Orgs do
@behaviour Wardrik.Orgs
@impl true
def settings("acme", _config) do
{:ok,
%Wardrik.OrgSettings{
ceilings: %{"plan" => [usd: 0.02, per: :day, on_exhausted: :deny]}
}}
end
def settings(org, _config),
do: {:error, Wardrik.Error.new(:not_found, "There's no org #{org} here.")}
end
{:ok, _} = Wardrik.start_link(name: MyApp.AgentRuntime, orgs: {MyApp.Orgs, []})
scope =
Wardrik.as(MyApp.AgentRuntime, "acme/@maria",
roles: [:owner],
resources: ["acme/@maria/*", "acme/ceiling"]
)acme/ceiling lets the scope read the org's ceilings and approve calls past them.
Two agents
The agents call a model. Add ANTHROPIC_API_KEY in the Secrets panel first.
System.put_env("ANTHROPIC_API_KEY", System.fetch_env!("LB_ANTHROPIC_API_KEY"))
{:ok, _} =
Wardrik.Agent.define(scope, "acme/@maria/writer",
model: "anthropic:claude-haiku-4-5",
instructions: "You write short stories about office life.",
policy: [budget: [usd: 0.01, per: :day]]
)
{:ok, _} =
Wardrik.Agent.define(scope, "acme/@maria/editor",
model: "anthropic:claude-haiku-4-5",
instructions: "You write short memos about office life."
)The editor has no budget of its own. The ceiling holds it all the same.
Live spend
Wardrik.Agents.list/2 returns what each agent spent today. Wardrik.Ceilings.list/2 returns
what the org spent against each ceiling. Both tables refresh every second:
dollars = fn usd -> Float.round(usd / 1, 4) end
agent_rows = fn ->
{:ok, agents} = Wardrik.Agents.list(scope)
for agent <- agents do
%{
agent: agent.id,
status: agent.status,
tokens: agent.spent_today["tokens"],
usd: dollars.(agent.spent_today["usd"])
}
end
end
ceiling_rows = fn ->
{:ok, ceilings} = Wardrik.Ceilings.list(scope, owner: "acme")
for spend <- ceilings do
%{
ceiling: spend.ceiling,
limit_usd: spend.limit.usd,
spent_usd: dollars.(spend.spent.usd),
spent_tokens: spend.spent.tokens,
on_exhausted: spend.on_exhausted,
starts_over: spend.starts_over
}
end
end
agents_table =
Kino.DataTable.new(agent_rows.(), name: "Agents today", keys: [:agent, :status, :tokens, :usd])
ceilings_table =
Kino.DataTable.new(ceiling_rows.(),
name: "acme's ceilings",
keys: [:ceiling, :limit_usd, :spent_usd, :spent_tokens, :on_exhausted, :starts_over]
)
Kino.listen(1_000, fn _tick ->
Kino.DataTable.update(agents_table, agent_rows.())
Kino.DataTable.update(ceilings_table, ceiling_rows.())
end)
Kino.Layout.grid([agents_table, ceilings_table])The writer's budget asks
Ask the writer for stories until a reply doesn't come back. Keep an eye on the tables above:
Enum.reduce_while(1..10, nil, fn n, _ ->
case Wardrik.Agent.reply(scope, "acme/@maria/writer", "Write story #{n}, in about 300 words.") do
{:ok, _reply} -> {:cont, n}
stopped -> {:halt, stopped}
end
end)The writer crossed its cent, so its next model call waits on a :model_approval interrupt. The
writer is :parked in the table. To let it finish the story, allow another cent:
allow = Kino.Control.button("Allow the writer one cent more")
outcome = Kino.Frame.new()
Kino.listen(allow, fn _click ->
result = Wardrik.Agent.approve(scope, "acme/@maria/writer", "model")
Kino.Frame.render(outcome, Kino.Text.new(inspect(result)))
end)
Kino.Layout.grid([allow, outcome])The org's ceiling refuses
The editor has no budget, but it spends from acme's ceiling with the writer. Ask it for memos:
Enum.reduce_while(1..10, nil, fn n, _ ->
case Wardrik.Agent.reply(scope, "acme/@maria/editor", "Write memo #{n}, in about 300 words.") do
{:ok, _reply} -> {:cont, n}
stopped -> {:halt, stopped}
end
end)Once acme's agents spent the two cents, the next model call of any of them is refused. The run
fails with the ceiling's message. Wardrik also writes wardrik.ceiling.reached to the agent's log:
{:ok, page} = Wardrik.Agent.events(scope, "acme/@maria/editor")
for %{type: "wardrik.ceiling.reached", data: data} <- page.events, do: dataThe ceiling holds every agent of the org. Click the writer's button now, and its run fails the same way.
A ceiling's on_exhausted is one of:
:ask- the default. Waits on a:ceiling_approvalinterrupt for someone allowedceiling.approveonacme/ceiling, as an owner is. Approving allows the ceiling'sgrantmore to the whole org for that period.:deny- refuses the call, as here.:continue- lets it run, so work always carries on. It still writeswardrik.ceiling.reached.
A ceiling takes what a budget takes, except per: :turn. Its period starts over on the day in the
starts_over column.
Where next
- Orgs guide - org settings, defaults and ceilings.
- Policies guide - budgets beside the other rules.
- Rules the model can't rewrite - a policy playground.
- A person in the loop - approve or deny waiting calls in a form.