Give agents tools

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

Each 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.text

A 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