See everything an agent did

Copy Markdown
Mix.install([{:wardrik, "~> 0.1.0-rc.1"}, {:kino, "~> 0.19"}])

What you'll build

By the end, you watch an agent's events arrive as it works, read its run back step by step, see who changed what, and have your own module react when a run ends.

Setup

Give the agent one tool, so its run has a step to show:

defmodule MyApp.Notes do
  @behaviour WardrikCore.Capability

  @impl true
  def tools(_context, _opts) do
    [
      WardrikCore.Tool.new!(
        name: "list_notes",
        description: "Lists Maria's notes.",
        run: fn _args, _context -> {:ok, ["Call Initech about the Q3 invoice.", "Book Berlin trip."]} end,
        annotations: [read_only: true]
      )
    ]
  end
end

{:ok, _} = Wardrik.start_link(name: MyApp.AgentRuntime, capabilities: [notes: MyApp.Notes])

scope = Wardrik.as(MyApp.AgentRuntime, "acme/@maria", roles: [:owner], resources: ["acme/@maria/*"])

{:ok, agent} =
  Wardrik.Agent.define(scope, "acme/@maria/assistant",
    model: "anthropic:claude-haiku-4-5",
    instructions: "You keep Maria's notes. Be brief.",
    tools: ["notes"],
    policy: [budget: [usd: 0.25, per: :day]]
  )

React to events in your own module

A listener hands your module each event it follows, with the cursor past it. This one posts a line to a frame each time a run ends:

defmodule MyApp.RunAlerts do
  use Wardrik.Listener,
    wardrik: MyApp.AgentRuntime,
    types: ["wardrik.run.completed", "wardrik.run.failed"]

  @impl true
  def handle(%Wardrik.Event{} = event, listener) do
    line = "**#{event.type}** for `#{event.subject}` at #{Calendar.strftime(event.time, "%H:%M:%S")}"
    Kino.Frame.append(listener.options[:frame], Kino.Markdown.new(line))
    :ok
  end
end

alerts = Kino.Frame.new() |> Kino.render()
{:ok, _} = Kino.start_child({MyApp.RunAlerts, frame: alerts})

Kino.nothing()

The frame stays empty until a run ends. Options you start it with, such as frame:, reach handle/2 in listener.options.

In your app, save listener.cursor with your work and return it from cursor/1. The listener then carries on from it after a restart. See Events and the audit.

Watch events as they're written

Take the cursor where the log stands. Then follow every event after it into a table:

{:ok, cursor} = Wardrik.Events.head(scope)

live = Kino.Frame.new() |> Kino.render()

row = fn event ->
  %{
    time: Calendar.strftime(event.time, "%H:%M:%S.%f"),
    type: event.type,
    subject: event.subject
  }
end

Kino.start_child(
  {Task,
   fn ->
     {:ok, events} = Wardrik.Events.stream_list(scope, after: cursor, resources: ["acme/@maria/*"])

     events
     |> Stream.reject(fn {event, _cursor} -> String.ends_with?(event.type, ".delta") end)
     |> Enum.reduce([], fn {event, _cursor}, rows ->
       rows = rows ++ [row.(event)]
       Kino.Frame.render(live, Kino.DataTable.new(rows, keys: [:time, :type, :subject]))
       rows
     end)
   end}
)

Kino.nothing()

The stream never ends. It leaves out message.delta events, one per chunk of text, to keep the table short.

Now ask the agent something. This calls a model, so add your Anthropic key as ANTHROPIC_API_KEY in the Secrets panel (the key icon on the left). It's two small calls, under a cent.

System.put_env("ANTHROPIC_API_KEY", System.fetch_env!("LB_ANTHROPIC_API_KEY"))

Watch the table above fill:

{:ok, reply} = Wardrik.Agent.reply(scope, agent, "What's on my notes?")
reply.text

The run starts, calls the model, calls list_notes, calls the model again, and completes. The listener's frame shows wardrik.run.completed.

Read a run back

The timeline is the conversation as a person reads it. Each run lists its steps:

{:ok, timeline} = Wardrik.Agent.timeline(scope, agent)

steps =
  for %{"kind" => "run"} = run <- timeline.items, step <- run["steps"] do
    %{
      run: String.slice(run["id"], 0, 12),
      step: step["type"],
      detail: step["name"] || step["text"],
      status: step["status"]
    }
  end

Kino.DataTable.new(steps, keys: [:run, :step, :detail, :status], name: "Steps")

Wardrik.Agent.runs/3 sums each run up:

{:ok, runs} = Wardrik.Agent.runs(scope, agent)

Kino.DataTable.new(
  Enum.map(runs.runs, &Map.from_struct/1),
  keys: [:status, :model, :duration_ms, :tool_calls, :input_tokens, :output_tokens, :cost_micro_usd],
  name: "Runs"
)

Read the log back

Every event is kept. Read them back a page at a time, after a cursor or from the start:

{:ok, page} = Wardrik.Agent.events(scope, agent, limit: 100)

page.events
|> Enum.frequencies_by(& &1.type)
|> Enum.map(fn {type, count} -> %{type: type, count: count} end)
|> Kino.DataTable.new(keys: [:type, :count], name: "Events by type")

Each event is a CloudEvent. Its data has the shape the protocol gives its type. Pass page.next as after: to read the next page.

Who changed what

Agents' events say what agents did. The audit says what people did: defining, deciding, granting. Change Maria's agent, then read the audit. It needs a grant on deployment:

{:ok, _agent} =
  Wardrik.Agent.define(scope, "acme/@maria/assistant",
    model: "anthropic:claude-haiku-4-5",
    instructions: "You keep Maria's notes. Answer in bullet points.",
    tools: ["notes"],
    policy: [budget: [usd: 0.25, per: :day]]
  )

operator = Wardrik.grant(scope, roles: [:owner], resources: ["deployment"])

{:ok, audit} = Wardrik.Audit.list(operator, limit: 50)

audit.events
|> Enum.map(fn event ->
  %{
    time: Calendar.strftime(event.time, "%H:%M:%S"),
    type: event.type,
    actor: event.data["actor"]["id"],
    subject: event.subject
  }
end)
|> Kino.DataTable.new(keys: [:time, :type, :actor, :subject], name: "Audit")

Where next