Omni.UI behaviour (Omni UI v0.1.0)

Copy Markdown View Source

License

Agent chat UI for Elixir — a ready-made LiveView interface for exploring, prototyping, and experimenting with Omni Agent powered agents.

Omni.UI gives you two ways to work:

ApproachWhat you get
Omni.UI.AgentLiveA batteries-included LiveView — mount it in your router and you have a working agent chat with sessions, files, REPL, and web tools
use Omni.UIA macro that injects session streaming, state management, and event routing into your own LiveView — you bring the template and any custom behaviour

AgentLive is the fastest path to seeing Omni in action. When you need full control over layout, tools, or event handling, drop down to the macro and the Omni.UI.ChatUI / Omni.UI.CoreUI components.

Installation

Add Omni UI to your dependencies:

def deps do
  [
    {:omni_ui, "~> 0.1"}
  ]
end

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

Requirements

Omni UI uses colocated CSS and JavaScript (extracted at compile time by the :phoenix_live_view compiler). This requires:

  • Phoenix 1.8+
  • Phoenix LiveView 1.2+
  • Tailwind 4.2.3+

New Phoenix applications generated from 1.8.8 onwards are ready out of the box.

Assets

Omni UI ships its CSS and JavaScript as colocated assets — no static files to copy. Your application imports them from the phoenix-colocated build output.

In your CSS entry point, import the colocated stylesheet and add a @source directive so Tailwind can scan the component templates:

/* assets/css/app.css */
@import "phoenix-colocated/omni_ui/colocated.css";
@source "../../deps/omni_ui/lib";

In your JavaScript entry point, import the colocated hooks and spread them into your LiveSocket:

// assets/js/app.js
import {hooks as omniHooks} from "phoenix-colocated/omni_ui"

const liveSocket = new LiveSocket("/live", Socket, {
  hooks: {...omniHooks},
  // ...
})

Both require your bundler's module resolution to include Mix.Project.build_path(). For esbuild and Tailwind, this is configured via the NODE_PATH environment variable, which is the default for Phoenix 1.8+ applications.

The CSS defines OKLCH semantic colour tokens with light and dark variants. Override any token to match your application's palette.

Syntax highlighting

Omni UI uses mdex for Markdown rendering with syntax highlighting powered by lumis. Enable it in your application config:

# config/config.exs
config :mdex_native, syntax_highlighter: :lumis

Quick start with AgentLive

Configure Omni.UI.Sessions (the shipped session manager), add it to your supervision tree, and mount Omni.UI.AgentLive in your router:

# config/config.exs
config :mdex_native, syntax_highlighter: :lumis

config :omni_ui, Omni.UI.Sessions,
  store: {Omni.Session.Stores.FileSystem, base_dir: "priv/sessions"},
  title_generator: {:anthropic, "claude-haiku-4-5"}

config :omni_ui, Omni.UI.AgentLive,
  providers: [:anthropic],
  default_model: {:anthropic, "claude-sonnet-4-6"}

# application.ex
children = [
  # ... your other children
  Omni.UI.Sessions,
]

# router.ex
scope "/" do
  pipe_through :browser
  live "/", Omni.UI.AgentLive
end

forward "/omni_files", Omni.UI.Files.Plug

Start your server and open the browser — you have a working agent chat with streaming, branching, a files panel, an Elixir REPL, and web tools. See Omni.UI.AgentLive for configuration options.

Building a custom LiveView

use Omni.UI in your own LiveView for full control over the template, tools, and event handling. The macro injects handle_event/3 and handle_info/2 clauses that route session events and UI interactions automatically — your own handlers compose alongside them via defoverridable.

defmodule MyAppWeb.ChatLive do
  use Phoenix.LiveView
  use Omni.UI

  def render(assigns) do
    ~H"""
    <.chat_interface>
      <.turn_list stream={@streams.turns} tool_components={@tool_components} />
      <.turn :if={@current_turn} turn={@current_turn} tool_components={@tool_components} />
      <:editor>
        <.editor model={@model} />
      </:editor>
    </.chat_interface>
    """
  end

  def mount(_params, _session, socket) do
    {:ok, init_session(socket, model: {:anthropic, "claude-sonnet-4-6"})}
  end

  def handle_params(params, _uri, socket) do
    {:noreply, attach_session(socket, id: params["session_id"])}
  end
end

Three functions drive the session lifecycle:

  1. init_session/2 — called once in mount/3. Sets up all Omni.UI assigns (model, tools, streams, etc.) but does not create a session — :session is nil after this call.
  2. attach_session/2 — called in handle_params/3. When the URL contains a session id, opens that session from the store and subscribes the LiveView. When the id is nil (e.g. the user navigates to /), resets to a blank state with no session.
  3. ensure_session/1 — called automatically by the macro when the user sends their first message. If a session is already attached, this is a no-op. Otherwise it creates a new session on the fly. This lazy-creation avoids piling up empty draft sessions every time someone refreshes the page.

A typical lifecycle looks like:

mount/3            init_session (config only, no session)
handle_params/3    attach_session(id: nil)     # blank page
user sends "hi"    ensure_session              # session created now
                   prompt sent to session
user clicks a      handle_params/3
  session link     attach_session(id: "abc")   # detach old, attach new

Rendering with components

use Omni.UI imports all components from Omni.UI.ChatUI (chat pipeline — chat_interface, editor, turn_list, turn, user_message, assistant_message, content_block, markdown) and Omni.UI.CoreUI (shared primitives — expandable, select, version_nav, notifications). Compose them freely in your template.

The session_event callback

After each session event is processed by the macro's default handlers, your module's session_event/3 callback is called with the already-updated socket. Use it to layer on custom behaviour — analytics, side effects, additional assigns:

@impl Omni.UI
def session_event(:turn, {:stop, response}, socket) do
  MyApp.Analytics.track(response.usage)
  socket
end

def session_event(_event, _data, socket), do: socket

Sessions

Conversations are managed by an Omni.Session.Manager. Omni.UI ships Omni.UI.Sessions as the default — configure a store, add it to your supervision tree, and attach_session/2 uses it automatically. For multi-tenant apps or custom isolation, define your own manager module and pass it via the :manager option to init_session/2. See Omni.UI.Sessions for configuration details.

Sessions are created lazily on the first prompt (via ensure_session/1), so refreshing the page without sending a message doesn't pile up untouched drafts. The macro handles subscription, snapshot reconstruction for mid-stream joins, and event routing — the LiveView is a subscriber, not the owner. Persistence, branching, and idle shutdown are all Omni.Session concerns.

Custom agents and tools

By default, AgentLive uses Omni.UI.Agent which wires in the Files, REPL, WebFetch, and WebSearch tools. When building your own LiveView, pass a custom Omni.Agent module via :agent_module to init_session/2, or pass tools directly via :tools:

init_session(socket,
  model: {:anthropic, "claude-sonnet-4-6"},
  agent_module: MyApp.Agent,
  tools: [my_tool, {another_tool, component: &MyAppWeb.ToolUI.render/1}]
)

Tools can be paired with a component function that replaces the default content-block rendering for that tool's uses. Alternatively, pass a :tool_components map for tools added by the agent module's init/1 callback. See init_session/2 for the full options reference.

Theming

Omni UI's shipped CSS defines semantic colour tokens using OKLCH values, with automatic light/dark variants:

TokenRole
--color-omni-bg, --color-omni-bg-1 ... -bg-2Background surfaces
--color-omni-text, --color-omni-text-1 ... -text-4Text hierarchy
--color-omni-border-1 ... -border-3Border weights
--color-omni-accent-1 ... -accent-2Accent / interactive

Override any token in your own CSS to match your application's palette. All components reference these tokens through Tailwind classes (bg-omni-bg, text-omni-text-1, etc.), so a single override propagates everywhere.

Configuration

Application-level config is namespaced under :omni_ui:

# Tool execution timeouts (ms)
config :omni_ui,
  tool_timeouts: %{"repl" => 120_000},
  default_tool_timeout: 15_000

# Session manager
config :omni_ui, Omni.UI.Sessions,
  store: {Omni.Session.Stores.FileSystem, base_dir: "priv/sessions"},
  title_generator: {:anthropic, "claude-haiku-4-5"}

Per-session configuration (model, tools, thinking, system prompt) is set via init_session/2 options and can be updated at runtime with update_session/2.

State ownership

Two sets of assigns live on a LiveView using Omni.UI:

  • Omni.UI-owned: session lifecycle (:session, :session_id, :title, :tree, :current_turn), agent config (:manager, :agent_module, :model, :thinking, :system, :tools, :tool_timeout, :tool_components), UI state (:usage, :url_synced, :notification_ids), and the :turns and :notifications streams. Initialised by init_session/2 in mount/3; mutated by attach_session/2, ensure_session/1, update_session/2, and the macro-injected handlers.
  • Consumer-owned: anything else — UI-driven state (:model_options, view toggles, etc.), application data, custom event handlers, routing.

The rule: if mount/3 is setting an Omni.UI-owned assign directly, reach for init_session/2 instead.

Summary

Callbacks

Called after Omni.UI's default handling for each session event.

Functions

Connects the LiveView to a session, loading its tree, title, and usage into the socket assigns.

Ensures socket.assigns.session is set, creating a fresh session via the configured Manager if it is nil.

Initialises every Omni.UI-owned assign and stream on the socket.

Pushes a notification to the calling LiveView's toaster.

Returns the agent-level tool timeout (in ms) for the given tool name.

Updates session/agent configuration on a running system.

Callbacks

session_event(event, data, t)

@callback session_event(event :: atom(), data :: term(), Phoenix.LiveView.Socket.t()) ::
  Phoenix.LiveView.Socket.t()

Called after Omni.UI's default handling for each session event.

The LiveView receives events from the Omni.Session it is subscribed to. These include agent lifecycle events (:turn, :text_delta, :tool_result, :error, etc.) and session-level events (:tree, :store, :title). Omni.UI handles each event first — updating streams, assigns, and UI state — then calls this callback with the already-updated socket so the consumer can layer on additional logic.

Must return the socket (possibly with additional assign mutations).

Functions

attach_session(socket, opts)

@spec attach_session(
  Phoenix.LiveView.Socket.t(),
  keyword()
) :: Phoenix.LiveView.Socket.t()

Connects the LiveView to a session, loading its tree, title, and usage into the socket assigns.

Call from handle_params/3 after init_session/2 has set the defaults in mount/3. The function opens the session via the configured Manager, atomically subscribes-with-snapshot, and populates the session-state assigns. Raises if the id isn't found in the store (wrap in try/rescue to handle gracefully).

If an existing session is already attached, it is detached first (releasing its :controller hold so it can idle-shutdown). Idempotent for the same :id — re-entering with the currently-attached id is a no-op, so push_patch to the same URL doesn't churn the subscription.

When :id is nil or omitted, the socket is reset to the blank state with no session attached. This is the normal path when the user navigates to a URL with no session id (e.g. /). The session will be created lazily by ensure_session/1 when the user sends their first message.

Reads agent configuration (:manager, :model, :thinking, :system, :tools, :tool_timeout) from the assigns set by init_session/2.

Example

def handle_params(params, _uri, socket) do
  if connected?(socket) do
    try do
      {:noreply, attach_session(socket, id: params["session_id"])}
    rescue
      _ -> {:noreply, push_navigate(socket, to: "/")}
    end
  else
    {:noreply, socket}
  end
end

ensure_session(socket)

Ensures socket.assigns.session is set, creating a fresh session via the configured Manager if it is nil.

Used by the macro's :new_message handler to lazily create the session on first prompt — so a user opening / and refreshing doesn't spawn untouched draft sessions.

Reads agent configuration from the assigns populated by init_session/2 (:manager, :model, :thinking, :system, :tools, :tool_timeout). Subscribes the calling LiveView as :controller atomically with the snapshot.

Returns the socket unchanged when a session is already attached.

init_session(socket, opts)

Initialises every Omni.UI-owned assign and stream on the socket.

Call this once from mount/3. Sets the agent-config assigns (:manager, :model, :thinking, :system, :tools, :tool_timeout, :tool_components), the session-state assigns (:session, :session_id, :title, :tree, :current_turn, :usage, :url_synced), the notification list (:notification_ids), and initialises the :turns and :notifications streams. The session itself is nil after this call — it's attached either by attach_session/2 (when handle_params/3 receives a session_id) or by ensure_session/1 (lazily, on the first :new_message).

Options

  • :model (required) — %Omni.Model{} struct or {provider_id, model_id} tuple
  • :manager — Manager module (default Omni.UI.Sessions). Must be running under the application supervision tree with a configured store.
  • :agent_module — module that uses Omni.Agent (default nil, meaning the stock Omni.Agent). Use this to bake in tools, system prompt, or other defaults via the agent's init/1 callback.
  • :thinking — thinking mode: false | :low | :medium | :high | :max (default: false)

  • :system — system prompt string (default: nil)
  • :tools — list of tool entries (default: []). Each entry is either:
    • a bare %Omni.Tool{} struct (rendered with the default content block), or
    • {%Omni.Tool{}, opts} where opts is a keyword list. The only supported option is component: (assigns -> rendered) — a 1-arity function component that replaces the default content block rendering for that tool's uses.
  • :tool_components — map of tool_name => (assigns -> rendered) for tools that aren't constructed by the consumer (typically tools added by an :agent_module's init/1 callback). Merged with components extracted from :tools entries; this map wins on key conflicts.
  • :tool_timeout — tool execution timeout. Either an integer in ms (applied to all tools) or a 1-arity function receiving the tool name and returning a timeout. When omitted, defaults to &Omni.UI.tool_timeout/1 which returns per-tool values matched to the built-in omni_tools defaults. See tool_timeout/1 for override options via application config.

Example

def mount(_params, _session, socket) do
  {:ok,
   socket
   |> assign(:model_options, my_model_options())
   |> Omni.UI.init_session(model: {:anthropic, "claude-sonnet-4-5"})}
end

notify(level, message, opts \\ [])

@spec notify(Omni.UI.Notification.level(), String.t(), keyword()) :: :ok

Pushes a notification to the calling LiveView's toaster.

Must be called from within the LiveView process (including from child LiveComponents, whose self() is the parent LiveView). The LiveView must be using use Omni.UI — the macro injects the handle_info clauses that receive the message, and attach_session/2 initialises the stream.

If the consumer does not render <.notifications> in their template, the notification is still accepted and auto-dismissed but is not visible.

Levels

  • :info — neutral informational message
  • :success — confirmation of a completed action
  • :warning — something went wrong but was handled
  • :error — something failed

Options

  • :timeout — ms until auto-dismiss (default 5000)

Example

Omni.UI.notify(:warning, "Couldn't auto-generate a title.")
Omni.UI.notify(:error, "Save failed", timeout: 10_000)

tool_timeout(tool_name)

@spec tool_timeout(String.t()) :: pos_integer()

Returns the agent-level tool timeout (in ms) for the given tool name.

Used as the default tool_timeout function passed to Omni.Agent via build_agent_opts/6. Each built-in tool's default is its own internal timeout plus a 5 s buffer, so the tool can timeout gracefully before the agent kills the task.

Built-in defaults: repl 65 000, bash 35 000, web_fetch 20 000. All other tools fall back to 10 000.

Override per-tool or the fallback via application config:

config :omni_ui,
  tool_timeouts: %{"repl" => 120_000},
  default_tool_timeout: 15_000

Consumers who pass a custom :tool_timeout to init_session/2 bypass this function entirely.

update_session(socket, opts)

@spec update_session(
  Phoenix.LiveView.Socket.t(),
  keyword()
) :: Phoenix.LiveView.Socket.t()

Updates session/agent configuration on a running system.

Accepts any subset of options. For each provided option, updates the appropriate combination of socket assign and session state.

Options

  • :model — updates both socket assign and the session's agent model
  • :thinking — updates both socket assign and the session's agent opts
  • :system — updates the session's agent system prompt (not surfaced in UI)
  • :tools — updates the session's agent tools and the :tool_components assign

Example

Omni.UI.update_session(socket, model: {:anthropic, "claude-opus-4-20250514"})

When called before a session has been attached (e.g. the user picks a model on the blank / page before sending the first prompt), updates the assigns only — the value is then passed to Omni.Session at ensure_session/1 time.