Give agents tools
Copy MarkdownMix.install([{:wardrik, "~> 0.1.0-rc.1"}, {:kino, "~> 0.19"}])What you'll build
By the end, an agent looks up orders on its own, asks you before it refunds one, and tells you what each paid lookup cost. The tools say what they do. The policy reads that, not the prompt.
Write the tools
A capability is a module that returns tools. This one keeps orders in an ETS table:
:ets.new(:orders, [:named_table, :public])
:ets.insert(:orders, [{"A-1", "shipped"}, {"A-2", "delivered"}])
defmodule MyApp.Orders do
@behaviour WardrikCore.Capability
alias WardrikCore.Tool
@impl true
def tools(_context, _opts) do
[
Tool.new!(
name: "order_status",
description: "Says where an order is.",
schema: [order_id: [type: :string, required: true]],
run: fn %{order_id: id}, _context -> {:ok, status(id)} end,
annotations: [read_only: true, idempotent: true]
),
Tool.new!(
name: "refund_order",
description: "Refunds an order in full.",
schema: [order_id: [type: :string, required: true]],
run: fn %{order_id: id}, _context ->
:ets.insert(:orders, {id, "refunded"})
{:ok, "Refunded #{id}."}
end,
annotations: [destructive: true]
),
Tool.new!(
name: "track_parcel",
description: "Asks the carrier where an order's parcel is.",
schema: [order_id: [type: :string, required: true]],
run: fn %{order_id: id}, context ->
# A paid carrier API: report what the call cost.
{:ok, "#{id} is at the Leeds depot.", WardrikCore.Context.measure(context, :usd, 0.002)}
end,
annotations: [read_only: true, open_world: true]
)
]
end
def status(id) do
case :ets.lookup(:orders, id) do
[{^id, status}] -> status
[] -> "unknown"
end
end
endEach tool carries annotations:
read_only- it changes nothing.idempotent- a call is safe to repeat. Wardrik repeats one lost in a crash.destructive- what it changes can't be undone.open_world- it reaches outside your systems.
track_parcel returns a third element: the context, with what the call cost. Wardrik records it
on the call, and the agent's budget counts it.
Defaults by annotation
A policy's default decides each call no rule names. It can depend on the annotations:
policy = WardrikPolicy.new!(default: [read_only: :allow, destructive: :ask, else: :ask])
decisions =
for tool <- MyApp.Orders.tools(nil, []) do
%{
tool: tool.name,
annotations: inspect(Map.keys(tool.annotations)),
decision: inspect(WardrikPolicy.decide(policy, tool.name, annotations: tool.annotations))
}
end
Kino.DataTable.new(decisions, keys: [:tool, :annotations, :decision])Reads run. The refund waits for a person. A new tool you forget to annotate falls to else, and
asks too. A rule that names a tool wins over the default.
Give them to an agent
Start Wardrik with the capability, under the name orders, and define an agent that uses it:
{:ok, _} =
Wardrik.start_link(
name: MyApp.AgentRuntime,
capabilities: [orders: MyApp.Orders],
mcp: [
deepwiki: [transport: {:streamable_http, url: "https://mcp.deepwiki.com/mcp"}, gated: false]
]
)
scope = Wardrik.as(MyApp.AgentRuntime, "acme/@maria", roles: [:owner], resources: ["acme/@maria/*"])
{:ok, agent} =
Wardrik.Agent.define(scope, "acme/@maria/support",
model: "anthropic:claude-haiku-4-5",
instructions: "You answer questions about Acme's orders. Be brief.",
tools: ["orders"],
policy: [
default: %{read_only: :allow, destructive: :ask, else: :ask},
budget: [usd: 0.50, per: :day]
]
)The deepwiki server is for the last section.
Ask it something
From here on, the agent calls a model. Add your Anthropic key as ANTHROPIC_API_KEY in the
Secrets panel (the key icon on the left). The rest of the notebook costs about two cents.
System.put_env("ANTHROPIC_API_KEY", System.fetch_env!("LB_ANTHROPIC_API_KEY"))The answer streams into the frame as the model writes it:
frame = Kino.Frame.new() |> Kino.render()
scope
|> Wardrik.Agent.stream(agent, "Where's order A-1, and where's its parcel?")
|> Enum.reduce("", fn chunk, text ->
text = text <> chunk
Kino.Frame.render(frame, Kino.Markdown.new(text))
text
end)
Kino.nothing()Both tools are read-only, so both ran without asking.
Ask it to change something
A refund is destructive. The run stops and returns the call it waits on:
{:waiting, [refund]} = Wardrik.Agent.reply(scope, agent, "Refund order A-2, please.")
Map.take(refund, [:name, :message])Nothing changed yet:
MyApp.Orders.status("A-2")Approve it. The agent carries on, and the refund runs:
{:ok, _decisions} = Wardrik.Agent.approve(scope, agent, refund.id)
{:ok, reply} = Wardrik.Agent.reply(scope, agent, "What's A-2's status now?")
reply.textA message sent while the agent runs waits its turn, so this reply comes after the refund.
MyApp.Orders.status("A-2")See the approvals notebook to decide calls with buttons.
What it cost
Each tool call is in the agent's log. track_parcel reported its own cost:
{:ok, page} = Wardrik.Agent.events(scope, agent, types: ["wardrik.tool_call.completed"])
page.events
|> Enum.map(fn event ->
%{
tool: event.data["tool"]["name"],
status: event.data["status"],
cost_usd: (event.data["cost"]["micro_usd"] || 0) / 1_000_000
}
end)
|> Kino.DataTable.new(keys: [:tool, :status, :cost_usd], name: "Tool calls")Each run, with its tokens and what it cost in all:
{:ok, runs} = Wardrik.Agent.runs(scope, agent)
runs.runs
|> Enum.map(
&%{
status: &1.status,
tool_calls: &1.tool_calls,
input_tokens: &1.input_tokens,
output_tokens: &1.output_tokens,
cost_usd: &1.cost_micro_usd / 1_000_000
}
)
|> Kino.DataTable.new(
keys: [:status, :tool_calls, :input_tokens, :output_tokens, :cost_usd],
name: "Runs"
)Tools from an MCP server
DeepWiki runs a public MCP server that needs no sign-in. Wardrik started
with it as deepwiki, so a definition names its tools as deepwiki/*:
{:ok, wiki} =
Wardrik.Agent.define(scope, "acme/@maria/researcher",
model: "anthropic:claude-haiku-4-5",
instructions: "You answer questions about open source projects. Be brief.",
tools: ["deepwiki/*"],
policy: [budget: [usd: 0.50, per: :day]]
)
Wardrik.Agent.definition(scope, wiki)The last element lists the servers it names that aren't reachable yet. [] means every one is.
DeepWiki doesn't mark its tools read-only, so Wardrik would treat them as able to change things,
and each call would wait for a person. Its tools only read, so Wardrik started it with
gated: false.
frame = Kino.Frame.new() |> Kino.render()
scope
|> Wardrik.Agent.stream(wiki, "In one paragraph: how does elixir-lang/elixir implement Task.async_stream?")
|> Enum.reduce("", fn chunk, text ->
text = text <> chunk
Kino.Frame.render(frame, Kino.Markdown.new(text))
text
end)
Kino.nothing()An MCP server's answers come from outside. From then on, any call that can change something waits
for a person, whatever allow says. Here that would be a refund, had this agent the orders
tools. See Policies.
Where next
- Define agents: every key of a definition.
- Policies: rules, defaults and budgets.
- MCP servers: servers that need a sign-in, and those an org adds as it runs.
- Approvals notebook: approve and deny waiting calls with buttons.
- Events notebook: see everything an agent did.