Omni Agent
Stateful LLM agents for Elixir. Multi-turn conversations with lifecycle callbacks, tool approval, and steering. Built on Omni.
Features
- Supervised process — a GenServer that owns the conversation, so callers don't thread state
- Lifecycle callbacks — control continuation, tool approval, error handling, and cleanup
- Tool approval — pause on any tool use, inspect it, approve or reject, then resume
- Prompt steering — send a new prompt while running to redirect the agent at the next turn boundary
- Streaming events — text deltas, tool results, and lifecycle events delivered as process messages
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!")
endCustom 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}
endTool 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"}) # rejectAutonomous 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
endLiveView 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)}
endDocumentation
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.