Middleware

Copy Markdown

Setup

Paste this into a fresh IEx session. The model calls need an Anthropic key. The agent gets the order tool from Tools:

Mix.install([{:wardrik_core, "~> 0.1.0-rc.1"}])
System.put_env("ANTHROPIC_API_KEY", "sk-ant-...")

children = [
  {WardrikCore.Backends.MemoryStore, name: :agents},
  {WardrikCore, backend: {WardrikCore.Backends.MemoryStore, name: :agents}}
]

{:ok, _} = Supervisor.start_link(children, strategy: :rest_for_one)

defmodule MyApp.Orders do
  @behaviour WardrikCore.Capability

  @impl true
  def tools(_context, orders) do
    [
      WardrikCore.Tool.new!(
        name: "order_status",
        description: "Say where an order is.",
        schema: [order_id: [type: :string, required: true]],
        run: fn %{order_id: id}, _context -> {:ok, Map.get(orders, id, "unknown")} end
      )
    ]
  end
end

Replace "sk-ant-..." with your key.

Wrapping every call

Middleware wraps each turn, model call and tool call. It reads the step from context.step, then calls next to carry on or answers the step itself. This one refuses the shell:

defmodule MyApp.NoShell do
  @behaviour WardrikCore.Middleware

  @impl true
  def call(%{step: {:tool_call, %{name: "shell"}}} = context, _next, _opts),
    do: {:ok, %{context | response: {:error, "No shell."}}}

  def call(context, next, _opts), do: next.(context)
end

The refusal becomes the call's answer. The model sees it as an error, and carries on.

Use middleware when every call needs the same treatment: policy, tracing, retries, a system prompt. Like capabilities, it takes options as {MyApp.NoShell, opts}.

Waiting for a person

Middleware can park a call until a person answers. This one asks before any tool runs:

defmodule MyApp.Approve do
  @behaviour WardrikCore.Middleware

  @impl true
  def call(%{step: {:tool_call, call}} = context, next, _opts) do
    case answer(context, call) do
      :approve -> next.(context)
      {:deny, reason} -> {:ok, %{context | response: {:error, reason}}}
      nil -> {:park, :approval, context}
    end
  end

  def call(context, next, _opts), do: next.(context)

  defp answer(%{resume: %{input: answers}}, call), do: Map.get(answers, call.id)
  defp answer(_context, _call), do: nil
end

{:ok, agent} =
  WardrikCore.start_agent(
    agent_id: "support",
    model: "anthropic:claude-haiku-4-5",
    capabilities: [{MyApp.Orders, %{"1001" => "shipped"}}],
    middleware: [MyApp.NoShell, MyApp.Approve]
  )

:ok = WardrikCore.ask(agent, "Where is order 1001?")

The first middleware in the list runs outermost. The turn waits until you resume it with an answer for each call:

{:tool_calls, [{call, {:park, :approval}}]} = WardrikCore.parked(agent)
#=> {:tool_calls, [{%WardrikCore.ToolCall{name: "order_status", ...}, {:park, :approval}}]}

:ok = WardrikCore.resume(agent, %{call.id => :approve}, %{by: "user:maria"})

The metadata, here who answered, appears on the resumed turn's [:wardrik_core, :turn, :start] event.

Private state

Middleware can keep state with the agent, such as a count or what it spent. Store it with WardrikCore.Context.put_private/3, under your own module as the key. It's saved with the agent, so it survives restarts.

Asking a model

When middleware needs a model's judgement on a call, WardrikCore.evaluate/4 asks an evaluation model. WardrikCore.generate/1 calls the agent's own model.