Spend you can cap

Copy Markdown
Mix.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.cost

So a call that reads 400 tokens and writes 700 costs:

(400 * haiku.cost.input + 700 * haiku.cost.output) / 1_000_000

A 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})
  }
end

The 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's grant more, 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: data

The 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_approval interrupt for someone allowed ceiling.approve on acme/ceiling, as an owner is. Approving allows the ceiling's grant more to the whole org for that period.
  • :deny - refuses the call, as here.
  • :continue - lets it run, so work always carries on. It still writes wardrik.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