WeaveScope

Guardrails

Guardrails validate, filter, or interrupt agent behavior at important execution boundaries. Use them to protect private data, require human approval for sensitive actions, enforce business rules, moderate content, and validate final answers before they leave your application.

BeamWeaver implements guardrails through middleware. A guardrail can run before the agent starts, before or after each model call, after the agent finishes, or around individual model and tool calls.

Guardrail Approaches

BeamWeaver supports both common guardrail styles:

Style Use for Tradeoff
Deterministic Regex checks, keyword policies, allowlists, quotas, schema checks, PII detection. Fast and predictable, but misses nuanced violations.
Model-based Safety classification, semantic policy checks, final answer review, moderation APIs. More flexible, but slower, costlier, and provider-dependent.

Deterministic guardrails should be your default for explicit rules. Add model-based checks where the policy needs semantic judgment.

Built-In Guardrails

PII Detection

BeamWeaver.Agent.Middleware.PII detects and handles personally identifiable information in message text. It can inspect user input, model output, and tool results.

defmodule MyApp.SafeSupportAgent do
  use BeamWeaver.Agent

  alias BeamWeaver.Agent.Middleware.PII

  model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")

  tools do
    tool MyApp.CustomerServiceTool
    tool MyApp.EmailTool
  end

  middleware do
    use PII, type: :email, strategy: :redact, apply_to_input: true
    use PII, type: :credit_card, strategy: :mask, apply_to_input: true

    use PII,
      type: :api_key,
      detector: ~S/sk-[A-Za-z0-9]{32}/,
      strategy: :block,
      apply_to_input: true
  end
end

Supported strategies:

Strategy Behavior
:redact Replace with [REDACTED_TYPE].
:mask Keep only a safe suffix or type-specific masked shape.
:hash Replace with a deterministic short hash marker.
:block Return a tagged :pii_detected error from the middleware boundary.

Built-in detector types are :email, :credit_card, :ip, :mac_address, and :url. Custom detectors are supported as regex strings or functions that return match maps with :text or :value, :start, and :end byte offsets.

defmodule MyApp.PII do
  def detect_ssn(content) do
    Regex.scan(~r/\d{3}-\d{2}-\d{4}/, content, return: :index)
    |> Enum.map(fn [{start, length}] ->
      %{text: binary_part(content, start, length), start: start, end: start + length}
    end)
  end
end

middleware do
  use BeamWeaver.Agent.Middleware.PII,
   type: :ssn,
   detector: &MyApp.PII.detect_ssn/1,
   strategy: :hash
end

Human-In-The-Loop

BeamWeaver.Agent.Middleware.HumanInTheLoop pauses before configured tool calls and returns an interrupt payload for your UI, CLI, or service layer to review. This is the preferred guardrail for high-impact operations such as sending messages, writing production data, making purchases, or deleting records.

defmodule MyApp.ReviewedAgent do
  use BeamWeaver.Agent

  alias BeamWeaver.Agent.Middleware.HumanInTheLoop

  model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")

  tools do
    tool MyApp.SearchTool
    tool MyApp.SendEmailTool
    tool MyApp.DeleteRecordTool
  end

  middleware do
    use HumanInTheLoop,
      interrupt_on: %{
        "send_email" => %{allowed_decisions: [:approve, :edit, :reject]},
        "delete_record" => true,
        "search" => false
      },
      tools: [MyApp.SearchTool, MyApp.SendEmailTool, MyApp.DeleteRecordTool]
  end
end

Human review requires a checkpointer because the graph must pause and resume from a persisted state.

alias BeamWeaver.Checkpoint.ETS, as: CheckpointETS
alias BeamWeaver.Core.Message

checkpointer = CheckpointETS.new()
config = %{"configurable" => %{"thread_id" => "review-thread-1"}}

case MyApp.ReviewedAgent.invoke(
       %{messages: [Message.user("Send the launch update to the team.")]},
       checkpointer: checkpointer,
       config: config
     ) do
  {:interrupted, interrupt} ->
    IO.inspect(interrupt.value.action_requests)

    MyApp.ReviewedAgent.resume(
      %{decisions: [%{type: :approve}]},
      checkpointer: checkpointer,
      config: config
    )

  other ->
    other
end

Decision types are :approve, :edit, :reject, and :respond. Use BeamWeaver.Agent.resume_review/3 when you want BeamWeaver to normalize decision structs or raw decision lists into the expected resume payload.

OpenAI Moderation

BeamWeaver.OpenAI.ModerationMiddleware is a provider-backed model-based guardrail for OpenAI moderation. It can check input, output, and tool results.

middleware do
  use BeamWeaver.OpenAI.ModerationMiddleware,
    check_input: true,
    check_output: true,
    check_tool_results: false,
    exit_behavior: :end
end

The :exit_behavior option controls what happens when content is flagged:

Behavior Result
:end Jump to the end with a violation message.
:replace Replace the flagged message content with a violation message.
:error Return a tagged :openai_moderation_violation error.

Custom Guardrails

Use custom middleware when the built-in guardrails do not encode your policy. BeamWeaver does not expose Python decorators such as @before_agent; instead, implement the BeamWeaver.Agent.Middleware behaviour.

Before-Agent Guardrails

Use before_agent for request-level checks that should run once before any model or tool work happens.

defmodule MyApp.ContentFilterMiddleware do
  @behaviour BeamWeaver.Agent.Middleware

  alias BeamWeaver.Core.Message

  defstruct banned_keywords: []

  def new(opts \\ []) do
    words =
      opts
      |> Keyword.get(:banned_keywords, [])
      |> Enum.map(&String.downcase/1)

    %__MODULE__{banned_keywords: words}
  end

  def name(_middleware), do: :content_filter

  def can_jump_to(_middleware, :before_agent), do: [:end]
  def can_jump_to(_middleware, _hook), do: []

  def before_agent(%__MODULE__{banned_keywords: words}, state, _runtime) do
    content =
      state
      |> Map.get(:messages, [])
      |> Enum.find(&match?(%Message{role: :user}, &1))
      |> case do
        nil -> ""
        message -> Message.text(message) |> String.downcase()
      end

    if Enum.any?(words, &String.contains?(content, &1)) do
      %{
        messages: [
          Message.assistant(
            "I cannot process that request. Please rephrase it."
          )
        ],
        jump_to: :end
      }
    else
      nil
    end
  end
end

Attach it like any other middleware:

middleware do
  use MyApp.ContentFilterMiddleware,
    banned_keywords: ["credential theft", "exploit production"]
end

After-Agent Guardrails

Use after_agent to validate the final answer before returning it. This is a good place for model-based safety checks or final business-rule validation.

defmodule MyApp.SafetyGuardrailMiddleware do
  @behaviour BeamWeaver.Agent.Middleware

  alias BeamWeaver.Core.ChatModel
  alias BeamWeaver.Core.Message
  alias BeamWeaver.Graph.Overwrite

  defstruct [:safety_model]

  def new(opts), do: %__MODULE__{safety_model: Keyword.fetch!(opts, :model)}

  def name(_middleware), do: :safety_guardrail

  def can_jump_to(_middleware, :after_agent), do: [:end]
  def can_jump_to(_middleware, _hook), do: []

  def after_agent(%__MODULE__{safety_model: model}, state, _runtime) do
    messages = Map.get(state, :messages, [])
    last_index = last_assistant_index(messages)

    with index when is_integer(index) <- last_index,
         %Message{} = last <- Enum.at(messages, index),
         prompt <- safety_prompt(Message.text(last)),
         {:ok, review} <- ChatModel.invoke(model, [Message.user(prompt)]) do
      if String.contains?(String.upcase(Message.text(review)), "UNSAFE") do
        replacement = Message.assistant("I cannot provide that response.")

        %{
          messages: messages |> List.replace_at(index, replacement) |> Overwrite.new(),
          jump_to: :end
        }
      else
        nil
      end
    else
      nil -> nil
      {:error, error} -> {:error, error}
    end
  end

  defp safety_prompt(response) do
    """
    Evaluate whether this response is safe and policy-compliant.
    Reply with only SAFE or UNSAFE.

    Response:
    #{response}
    """
  end

  defp last_assistant_index(messages) do
    messages
    |> Enum.with_index()
    |> Enum.reverse()
    |> Enum.find_value(fn
      {%Message{role: :assistant}, index} -> index
      _other -> nil
    end)
  end
end

Combining Guardrails

Guardrails are layered by ordering middleware in the agent definition. Put cheap deterministic checks early, then review or model-based checks around the operations that need them.

middleware do
  use MyApp.ContentFilterMiddleware,
    banned_keywords: ["credential theft", "exploit production"]

  use BeamWeaver.Agent.Middleware.PII,
    type: :email,
    strategy: :redact,
    apply_to_input: true,
    apply_to_output: true

  use BeamWeaver.Agent.Middleware.HumanInTheLoop,
    interrupt_on: %{"send_email" => true},
    tools: [MyApp.SendEmailTool]

  use MyApp.SafetyGuardrailMiddleware,
    model: BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini")
end

Order matters:

  • before_agent and before_model hooks run in middleware order.

  • after_model and after_agent hooks run in reverse order.

  • Wrap hooks nest, with earlier middleware wrapping later middleware.

Testing Guardrails

Test deterministic guardrails directly with ExUnit, then run integration tests through an agent. Use fake or replay models for model-based guardrails so tests do not depend on live provider calls. For human review, assert that invocation returns {:interrupted, interrupt} and that resume decisions produce the expected tool messages or final state.

Use BeamWeaver.Agent.stream_events/3 when your UI needs to observe guardrail activity in real time. Interrupts, model calls, tool calls, errors, graph updates, and completion all appear as typed stream envelopes.