README.md

# Omni Agent

**Stateful LLM agents for Elixir.**
Multi-turn conversations with lifecycle callbacks, tool approval, and steering.
*Built on [Omni](https://github.com/aaronrussell/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:

```elixir
def deps do
  [
    {:omni_agent, "~> 0.1"}
  ]
end
```

Omni Agent depends on `omni`, which provides the LLM API layer. Configure your
provider API keys as described in the [Omni README](https://github.com/aaronrussell/omni#installation).

## Quick start

### Simple conversation

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

```elixir
{: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, :done, 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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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`:

```elixir
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, :done, _response}, socket) do
  {:noreply, assign(socket, :status, :complete)}
end
```

## Documentation

Full API documentation is available on [HexDocs](https://hexdocs.pm/omni_agent).

## License

This package is open source and released under the [Apache-2 License](https://github.com/aaronrussell/omni_agent/blob/main/LICENSE).

© Copyright 2026 [Push Code Ltd](https://www.pushcode.com/).