A person in the loop
Copy MarkdownMix.install([{:wardrik, "~> 0.1.0-rc.1"}, {:kino, "~> 0.19"}])What you'll build
An agent asks to pay two large bills and waits. You approve one with a note and deny the other with a reason, in two forms. The agent carries on with what you decided.
Setup
A books capability gives agents a pay_bill tool. It only pretends to pay:
defmodule MyApp.Books do
@behaviour WardrikCore.Capability
@impl true
def tools(_context, _opts) do
[
WardrikCore.Tool.new!(
name: "pay_bill",
description: "Pays a vendor's bill, in dollars.",
schema: [
vendor: [type: :string, required: true],
amount: [type: :integer, required: true]
],
run: fn %{vendor: vendor, amount: amount}, _context ->
{:ok, "Paid #{vendor} $#{amount}."}
end
)
]
end
end
{:ok, _} = Wardrik.start_link(name: MyApp.AgentRuntime, capabilities: [books: MyApp.Books])
scope =
Wardrik.as(MyApp.AgentRuntime, "acme/@maria", roles: [:owner], resources: ["acme/@maria/*"])The agent calls a model. Add ANTHROPIC_API_KEY in the Secrets panel first. A run costs about a
cent.
System.put_env("ANTHROPIC_API_KEY", System.fetch_env!("LB_ANTHROPIC_API_KEY"))
{:ok, _agent} =
Wardrik.Agent.define(scope, "acme/@maria/books",
model: "anthropic:claude-haiku-4-5",
instructions:
"You pay Maria's bills. Pay every bill she names, each with its own pay_bill call.",
tools: ["books"],
policy: [
ask: %{"pay_bill(amount >= 1000)" => "A bill this size needs a person."},
budget: [usd: 0.10, per: :day]
]
)An inbox for waiting calls
Wardrik.Interrupts.list/2 returns the calls waiting on a person, across every agent the scope can
see. The inbox shows a form for each. A form approves its call, with a note, or denies it, with a
reason:
defmodule MyApp.Inbox do
def render(frame, scope, arguments) do
{:ok, waiting} = Wardrik.Interrupts.list(scope)
cards =
case waiting do
[] -> [Kino.Markdown.new("_Nothing waits on you._")]
waiting -> Enum.map(waiting, &card(scope, &1, arguments[&1.tool_call_id]))
end
Kino.Frame.render(frame, Kino.Layout.grid(cards))
end
def decide(scope, interrupt, %{decision: :approve, text: note}),
do: Wardrik.Agent.approve(scope, interrupt.agent_id, interrupt.id, said(:note, note))
def decide(scope, interrupt, %{decision: :deny, text: reason}),
do: Wardrik.Agent.deny(scope, interrupt.agent_id, interrupt.id, said(:reason, reason))
defp card(scope, interrupt, arguments) do
form =
Kino.Control.form(
[
decision: Kino.Input.select("Decision", approve: "Approve", deny: "Deny"),
text: Kino.Input.text("Note or reason")
],
submit: "Decide"
)
outcome = Kino.Frame.new()
Kino.listen(form, fn %{data: data} ->
Kino.Frame.render(outcome, Kino.Text.new(inspect(decide(scope, interrupt, data))))
end)
Kino.Layout.grid([
Kino.Markdown.new("""
**#{interrupt.name}** `#{JSON.encode!(arguments)}` on `#{interrupt.agent_id}`
#{interrupt.message}
"""),
form,
outcome
])
end
defp said(_key, ""), do: []
defp said(key, text), do: [{key, text}]
endFollow the agent
Wardrik.Events.stream_list/2 follows the agent's events. Each time the agent stops to wait, the
inbox is drawn again. The log shows what the agent does:
inbox = Kino.Frame.new()
log = Kino.Frame.new()
{:ok, cursor} = Wardrik.Events.head(scope)
{:ok, events} = Wardrik.Events.stream_list(scope, after: cursor, resources: ["acme/@maria/*"])
Kino.listen(events, %{}, fn {event, _cursor}, arguments ->
data = event.data
call = get_in(data, ["tool", "call"])
line =
case event.type do
"wardrik.run.interrupted" ->
MyApp.Inbox.render(inbox, scope, arguments)
"Waits on you for #{length(data["interrupts"])} call(s)."
"wardrik.interrupt.resolved" ->
"You #{data["decision"]} `#{data["interrupt"]["id"]}`. #{data["note"] || data["reason"]}"
"wardrik.tool_call.completed" ->
"`#{data["tool"]["name"]}` #{data["status"]}: #{inspect(call["result"] || data["error"]["message"])}"
"wardrik.message.completed" ->
for %{"type" => "text", "content" => text} <- data["message"]["parts"], into: "", do: text
"wardrik.run.completed" ->
MyApp.Inbox.render(inbox, scope, arguments)
"Run completed."
_other ->
""
end
if line != "", do: Kino.Frame.append(log, Kino.Markdown.new(line))
arguments =
if event.type == "wardrik.tool_call.started",
do: Map.put(arguments, call["id"], call["arguments"]),
else: arguments
{:cont, arguments}
end)Ask for two payments
Kino.Layout.grid([inbox, log], columns: 2) |> Kino.render()
Wardrik.Agent.reply(
scope,
"acme/@maria/books",
"Pay Initech's $9400 bill and Globex's $2100 bill."
)The run stops and returns {:waiting, interrupts}. The inbox above now holds a form for each call.
- Approve Initech with the note "Pay it from the ops account."
- Deny Globex with the reason "Not before the 1st."
Watch the log:
- An approval runs the call as the model wrote it. The note reaches the model beside the result.
- A denial reaches the model as the call's error, with your reason.
- Each call is decided on its own, in any order. An approved call runs straight away. The model carries on once no call waits.
- Each decision is recorded as the scope's principal's, here
acme/@maria.
A model sometimes asks for one bill at a time. Then the second form appears once you've decided the first.
In code
The forms call Wardrik.Agent.approve/4 and Wardrik.Agent.deny/4. To decide several calls in
one go, Wardrik.Agent.decide/3 takes a map of call IDs to :approve, {:approve, note} or
{:deny, reason}:
Wardrik.Agent.decide(scope, "acme/@maria/books", %{
initech.id => {:approve, "Pay it from the ops account."},
globex.id => {:deny, "Not before the 1st."}
})Deciding a call the agent no longer waits on returns a conflict error.
Where next
- Approvals guide - every way to decide a call.
- Events guide - following agents into your app.
- Rules the model can't rewrite - the policies that make an agent ask.
- Spend you can cap - budgets that ask before spending more.