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:
| Approach | What you get |
|---|---|
Omni.UI.AgentLive | A batteries-included LiveView — mount it in your router and you have a working agent chat with sessions, files, REPL, and web tools |
use Omni.UI | A 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"}
]
endOmni 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: :lumisQuick 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.PlugStart 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
endThree functions drive the session lifecycle:
init_session/2— called once inmount/3. Sets up all Omni.UI assigns (model, tools, streams, etc.) but does not create a session —:sessionisnilafter this call.attach_session/2— called inhandle_params/3. When the URL contains a session id, opens that session from the store and subscribes the LiveView. When the id isnil(e.g. the user navigates to/), resets to a blank state with no session.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 newRendering 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: socketSessions
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:
| Token | Role |
|---|---|
--color-omni-bg, --color-omni-bg-1 ... -bg-2 | Background surfaces |
--color-omni-text, --color-omni-text-1 ... -text-4 | Text hierarchy |
--color-omni-border-1 ... -border-3 | Border weights |
--color-omni-accent-1 ... -accent-2 | Accent / 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:turnsand:notificationsstreams. Initialised byinit_session/2inmount/3; mutated byattach_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
@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
@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
@spec ensure_session(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
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.
@spec init_session( Phoenix.LiveView.Socket.t(), keyword() ) :: Phoenix.LiveView.Socket.t()
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 (defaultOmni.UI.Sessions). Must be running under the application supervision tree with a configured store.:agent_module— module thatusesOmni.Agent(defaultnil, meaning the stockOmni.Agent). Use this to bake in tools, system prompt, or other defaults via the agent'sinit/1callback.: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}whereoptsis a keyword list. The only supported option iscomponent: (assigns -> rendered)— a 1-arity function component that replaces the default content block rendering for that tool's uses.
- a bare
:tool_components— map oftool_name => (assigns -> rendered)for tools that aren't constructed by the consumer (typically tools added by an:agent_module'sinit/1callback). Merged with components extracted from:toolsentries; 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/1which returns per-tool values matched to the built-in omni_tools defaults. Seetool_timeout/1for 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
@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 (default5000)
Example
Omni.UI.notify(:warning, "Couldn't auto-generate a title.")
Omni.UI.notify(:error, "Save failed", timeout: 10_000)
@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_000Consumers who pass a custom :tool_timeout to init_session/2 bypass
this function entirely.
@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_componentsassign
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.