README.md

# Omni Agent

**Stateful LLM agents for Elixir** — persistent, branching conversations, tool approval, steering, and multi-session management. Built on [Omni](https://github.com/aaronrussell/omni).

## Features

- 🧠 **Stateful agents** — lifecycle callbacks for tool approval, prompt steering, and custom turn control
- 🌳 **Branching conversations** — a message tree you can regenerate, edit, or switch between alternate replies
- 💾 **Pluggable persistence** — a `Store` behaviour with a filesystem reference adapter; bring your own for Postgres, S3, or anywhere else
- 🎛️ **Multi-session supervisor** — `use Omni.Session.Manager` for registry-backed multi-session apps with cross-session pub/sub
- 📡 **Streaming events** — text, thinking, and tool deltas plus lifecycle events arrive as process messages (LiveView-friendly)
- 🔁 **Resumable** — sessions persist model, opts, title, and full history; reopen by id and continue, or fork a new branch

## Installation

Add Omni Agent to your dependencies:

```elixir
def deps do
  [
    {:omni_agent, "~> 0.3"}
  ]
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).

## The layers

Each layer is a standalone building block. Pick the one that matches the
scope of what you're building — you can stop at any level.

| Module | What is it |
| --- | --- |
| `Omni.Session.Manager` | many sessions — supervision, registry, live feed |
| `Omni.Session` | persistent conversation — branching, regen, navigation |
| `Omni.Agent` | stateful conversation — tools, callbacks, events |
| `Omni` | stateless LLM API — stream_text, tools, structs |

## Agents

An agent is a GenServer that owns a single conversation. You send prompts
in; streaming events come back as process messages.

### Quick conversation

```elixir
{:ok, agent} = Omni.Agent.start_link(
  model: {:anthropic, "claude-sonnet-4-6"},
  subscribe: true
)
:ok = Omni.Agent.prompt(agent, "Hello!")

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

### Custom agents

Define a module with `use Omni.Agent` to customise behaviour through
lifecycle callbacks. All callbacks are optional with sensible defaults.
`init/1` receives the fully-resolved `%State{}` — bake in defaults
(system prompt, tools) or read per-invocation input from `state.private`:

```elixir
defmodule GreeterAgent do
  use Omni.Agent

  @impl Omni.Agent
  def init(state) do
    system = "You are a helpful assistant. The user's name is #{state.private.user}."
    {:ok, %{state | system: system}}
  end
end

{:ok, agent} = GreeterAgent.start_link(
  model: {:anthropic, "claude-sonnet-4-6"},
  private: %{user: "Alice"}
)
```

### Tool approval

Pause on any tool use, inspect it, decide:

```elixir
defmodule SafeAgent do
  use Omni.Agent

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

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

# Subscribers receive {:agent, pid, :pause, {:requires_approval, %ToolUse{}}}.
# Resume when the decision is made:
Omni.Agent.resume(agent, :execute)              # approve
Omni.Agent.resume(agent, {:reject, "Denied"})   # reject with error result
Omni.Agent.resume(agent, {:result, my_result})  # provide a result directly
```

### Autonomous agents

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

```elixir
defmodule Researcher do
  use Omni.Agent

  @impl Omni.Agent
  def init(state) do
    {:ok, %{state |
      system: "Research using your tools, then call task_complete.",
      tools: [SearchTool.new(), FetchTool.new(), completion_tool()]
    }}
  end

  @impl Omni.Agent
  def handle_turn(response, state) do
    if calls_completion?(response) do
      {:stop, state}
    else
      {:continue, "Keep going. Call task_complete when done.", state}
    end
  end

  defp completion_tool 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 calls_completion?(response) do
    Enum.any?(response.messages, fn message ->
      Enum.any?(message.content, &match?(%Omni.Content.ToolUse{name: "task_complete"}, &1))
    end)
  end
end
```

## Sessions

A session wraps an agent with conversation identity, a branching message
tree, and pluggable storage. Every turn is committed to the tree and
persisted through a store adapter. Reopening by id restores everything.

### Start and persist

```elixir
store = {Omni.Session.Store.FileSystem, base_path: "priv/sessions"}

{:ok, session} = Omni.Session.start_link(
  agent: [model: {:anthropic, "claude-sonnet-4-6"}],
  store: store,
  subscribe: true
)

:ok = Omni.Session.prompt(session, "Name three mountains.")
```

Session events mirror the agent's, re-tagged as `{:session, pid, ...}`,
plus tree and store events:

```elixir
{:session, ^session, :text_delta, %{delta: text}}
{:session, ^session, :turn,       {:stop, response}}
{:session, ^session, :tree,       %{tree: tree, new_nodes: ids}}
{:session, ^session, :store,      {:saved, :tree}}
```

### Resume later

```elixir
id = Omni.Session.get_snapshot(session).id
Omni.Session.stop(session)

# Later, in a new process or after a restart:
{:ok, session} = Omni.Session.start_link(
  load: id,
  agent: [model: {:anthropic, "claude-sonnet-4-6"}],
  store: store
)
```

On load, persisted model, system prompt, opts, title, and the full
message tree are restored. Tools are supplied fresh each boot — function
refs aren't persisted.

### Branching and navigation

The message tree supports multiple children per node. Three operations
cover the common edit-and-regenerate UX:

```elixir
# Regenerate the reply to a user message — fresh assistant response for
# the same prompt:
Omni.Session.branch(session, user_node_id)

# Edit the next user message — new user + new turn as a child of the
# target assistant:
Omni.Session.branch(session, assistant_node_id, "Try it this way instead.")

# Switch between existing branches by moving the active path:
Omni.Session.navigate(session, node_id)
```

All three are idle-only. Use `Omni.Session.get_tree/1` to inspect the
tree, and `Omni.Session.Tree.children/2` / `siblings/2` to find
alternatives at any node.

## Multi-session apps

For apps that manage many concurrent conversations, `use
Omni.Session.Manager` in your own module and drop it into your
supervision tree:

```elixir
defmodule MyApp.Sessions do
  use Omni.Session.Manager
end

# application.ex
children = [
  {MyApp.Sessions,
     store: {Omni.Session.Store.FileSystem, base_path: "priv/sessions"}}
]
```

Manage sessions by id:

```elixir
# Start fresh — auto-generated id, caller auto-subscribed as controller
{:ok, pid} = MyApp.Sessions.create(
  agent: [model: {:anthropic, "claude-sonnet-4-6"}]
)

# Load existing session
{:ok, pid, _}  = MyApp.Sessions.open("abc-123")

# Stop the process, keep the store
:ok = MyApp.Sessions.close("abc-123")

# Stop the process and delete the store entry
:ok = MyApp.Sessions.delete("abc-123")

# Index views
{:ok, summaries} = MyApp.Sessions.list(limit: 50)   # store-backed
open             = MyApp.Sessions.list_open()       # in-memory projection
```

Subscribe to a live cross-session feed for dashboards and session lists:

```elixir
{:ok, running} = MyApp.Sessions.subscribe()

receive do
  {:manager, MyApp.Sessions, :opened, %{id: id, title: t, status: s}} -> ...
  {:manager, MyApp.Sessions, :status, %{id: id, status: s}}           -> ...
  {:manager, MyApp.Sessions, :title,  %{id: id, title: t}}            -> ...
  {:manager, MyApp.Sessions, :closed, %{id: id}}                      -> ...
end
```

## LiveView

Session events map cleanly to `handle_info/2`. `open/3` auto-subscribes
the caller as a controller, so the LiveView receives `{:session, ...}`
events without an explicit subscribe call:

```elixir
def mount(%{"id" => id}, _params, socket) do
  {:ok, pid, _} = MyApp.Sessions.open(id)
  snapshot = Omni.Session.get_snapshot(pid)

  {:ok, assign(socket,
    session: pid,
    messages: Omni.Session.Tree.messages(snapshot.tree)
  )}
end

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

def handle_info({:session, _pid, :text_delta, %{delta: text}}, socket) do
  {:noreply, handle_streaming_text(socket, text)}
end

def handle_info({:session, _pid, :turn, {_, response}}, socket) do
  {:noreply, handle_new_messages(socket, response.messages)}
end
```

## Documentation

Full API reference 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/).