Omni Agent

Stateful LLM agents for Elixir. Multi-turn conversations with lifecycle callbacks, tool approval, and steering. Built on Omni.

Features

Installation

Add Omni Agent to your dependencies:

def deps do
  [
    {:omni_agent, "~> 0.2"}
  ]
end

Omni Agent depends on omni, which provides the LLM API layer. Configure your provider API keys as described in the Omni README.

Quick start

Simple conversation

Start an agent and send a prompt — events arrive as process messages:

{:ok, agent} = Omni.Agent.start_link(model: {:anthropic, "claude-sonnet-4-5-20250514"})
:ok = Omni.Agent.prompt(agent, "Hello!")

receive do
  {:agent, ^agent, :text_delta, %{delta: text}} -> IO.write(text)
  {:agent, ^agent, :stop, response} -> IO.puts("\nDone!")
end

Custom agent with callbacks

Define a module with use Omni.Agent to customize behaviour. All callbacks are optional with sensible defaults:

defmodule MyAgent do
  use Omni.Agent

  @impl Omni.Agent
  def init(opts) do
    {:ok, %{user: opts[:user]}}
  end

  @impl Omni.Agent
  def handle_turn(%{stop_reason: :length}, state) do
    {:continue, "Continue where you left off.", state}
  end

  def handle_turn(_response, state) do
    {:stop, state}
  end
end

{:ok, agent} = MyAgent.start_link(
  model: {:anthropic, "claude-sonnet-4-5-20250514"},
  system: "You are a helpful assistant.",
  user: :current_user
)

Override start_link/1 to bake in defaults — standard GenServer pattern:

defmodule ResearchAgent do
  use Omni.Agent

  def start_link(opts \\ []) do
    defaults = [
      model: {:anthropic, "claude-sonnet-4-5-20250514"},
      system: "You are a research assistant.",
      tools: [SearchTool.new(), FetchTool.new()]
    ]
    super(Keyword.merge(defaults, opts))
  end

  @impl Omni.Agent
  def handle_turn(%{stop_reason: :stop}, state) do
    {:continue, "Continue working. Call task_complete when finished.", state}
  end

  def handle_turn(_response, state), do: {:stop, state}
end

Tool approval

Control which tools execute with handle_tool_use/2. Pause for human approval, reject, or provide results directly:

defmodule SafeAgent do
  use Omni.Agent

  @impl Omni.Agent
  def handle_tool_use(%{name: "delete_" <> _} = tool_use, state) do
    {:pause, :requires_approval, state}
  end

  def handle_tool_use(_tool_use, state) do
    {:execute, state}
  end
end

# The listener receives {:agent, pid, :pause, {:requires_approval, %ToolUse{}}}
# Then the caller decides:
Omni.Agent.resume(agent, :execute)            # approve
Omni.Agent.resume(agent, {:reject, "Denied"}) # reject

Autonomous agents

The difference between a chatbot and an autonomous agent is entirely in the callbacks. Define a completion tool and loop until the model calls it:

defmodule ResearchAgent do
  use Omni.Agent

  def start_link(opts \\ []) do
    defaults = [
      model: {:anthropic, "claude-sonnet-4-5-20250514"},
      system: "You are a research assistant. Use your tools to research, " <>
              "then call task_complete with your findings.",
      tools: [SearchTool.new(), FetchTool.new(), task_complete()]
    ]
    super(Keyword.merge(defaults, opts))
  end

  @impl Omni.Agent
  def handle_turn(%{stop_reason: :length}, state) do
    {:continue, "Continue where you left off.", state}
  end

  def handle_turn(response, state) do
    if completion_tool_called?(response) do
      {:stop, state}
    else
      {:continue, "Continue working. Call task_complete when finished.", state}
    end
  end

  defp task_complete do
    Omni.tool(
      name: "task_complete",
      description: "Call when the task is fully complete.",
      input_schema: Omni.Schema.object(
        %{result: Omni.Schema.string(description: "Summary of what was accomplished")},
        required: [:result]
      ),
      handler: fn _input -> "OK" end
    )
  end

  defp completion_tool_called?(response) do
    Enum.any?(response.messages, fn message ->
      Enum.any?(message.content, fn
        %Omni.Content.ToolUse{name: "task_complete"} -> true
        _ -> false
      end)
    end)
  end
end

LiveView integration

Agent events map naturally to handle_info/2:

def handle_event("submit", %{"prompt" => text}, socket) do
  :ok = Omni.Agent.prompt(socket.assigns.agent, text)
  {:noreply, socket}
end

def handle_info({:agent, _pid, :text_delta, %{delta: text}}, socket) do
  {:noreply, stream_insert(socket, :chunks, %{text: text})}
end

def handle_info({:agent, _pid, :stop, _response}, socket) do
  {:noreply, assign(socket, :status, :complete)}
end

Documentation

Full API documentation is available on HexDocs.

License

This package is open source and released under the Apache-2 License.

© Copyright 2026 Push Code Ltd.