# LiveRender
**Generative UI for Phoenix LiveView.**
AI generates a UI spec → LiveView resolves it to function components server-side → pushes only HTML diffs over the WebSocket.
Inspired by Vercel's [json-render](https://github.com/vercel-labs/json-render), built idiomatically for the BEAM.
## Why LiveRender?
- **Guardrailed** — AI can only use components you register in a catalog
- **Server-side** — specs stay on the server; no JSON runtime shipped to the client
- **Streaming** — stable elements freeze with `phx-update="ignore"`, only the active node re-renders
- **Progressive** — patch mode builds the UI element-by-element as the LLM streams
- **Multi-format** — JSON patches, JSON objects, or [OpenUI Lang](#openui-lang) (~50% fewer tokens)
- **Batteries included** — 18 built-in components ready to use
- **Bring your own LLM** — works with ReqLLM, Jido, or any client that produces a spec map
## Quick Start
### 1. Define a catalog
```elixir
defmodule MyApp.AI.Catalog do
use LiveRender.Catalog
component LiveRender.Components.Card
component LiveRender.Components.Metric
component LiveRender.Components.Button
end
```
`system_prompt/1` generates an LLM prompt describing every registered component — props, types, descriptions. `json_schema/0` returns a JSON Schema for structured output.
### 2. Render a spec
```heex
<LiveRender.render
spec={@spec}
catalog={MyApp.AI.Catalog}
streaming={@streaming?}
/>
```
**That's it.** AI generates the spec, LiveRender renders it safely through your catalog.
### 3. Connect an LLM
With [ReqLLM](https://hex.pm/packages/req_llm):
```elixir
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
use LiveRender.Live
def mount(_params, _session, socket), do: {:ok, init_live_render(socket)}
def handle_event("generate", %{"prompt" => prompt}, socket) do
LiveRender.Generate.stream_spec("anthropic:claude-haiku-4-5", prompt,
catalog: MyApp.AI.Catalog,
pid: self()
)
{:noreply, start_live_render(socket)}
end
end
```
`use LiveRender.Live` injects `handle_info` clauses for `:text_chunk`, `:spec`, `:done`, and `:error` messages.
Or with [Jido](https://hex.pm/packages/jido_ai) for full ReAct agent loops with tool calling — see the [example app](example/).
Or bring your own client — anything that produces a spec map works.
## Installation
```elixir
def deps do
[
{:live_render, "~> 0.3"}
]
end
```
Optional dependencies unlock extra features:
| Dependency | Unlocks |
|---|---|
| `{:req_llm, "~> 1.6"}` | `LiveRender.Generate` — streaming/one-shot spec generation |
| `{:nimble_options, "~> 1.0"}` | Keyword list schema validation with defaults and coercion |
| `{:json_spec, "~> 1.1"}` | Elixir typespec-style schemas that compile to JSON Schema |
## Formats
LiveRender supports multiple spec formats through the `LiveRender.Format` behaviour. Each format defines how the LLM encodes UI specs, how to parse responses, and how to handle streaming.
| Format | Module | Token cost | Best for |
|---|---|---|---|
| **JSON Patch** | `LiveRender.Format.JSONPatch` | High | Progressive streaming — UI appears element-by-element |
| **JSON Object** | `LiveRender.Format.JSONObject` | High | Simple one-shot generation |
| **OpenUI Lang** | `LiveRender.Format.OpenUILang` | **~50% less** | Token-sensitive workloads, fast models |
Pass `:format` to `system_prompt/1` or `stream_spec/3`:
```elixir
# In catalog prompts
MyApp.AI.Catalog.system_prompt(format: LiveRender.Format.OpenUILang)
# In Generate
LiveRender.Generate.stream_spec(model, prompt,
catalog: MyApp.AI.Catalog,
pid: self(),
format: LiveRender.Format.OpenUILang
)
```
The legacy `mode: :patch` / `mode: :object` options still work and map to the corresponding format modules.
### JSON Patch (default)
The LLM outputs RFC 6902 JSONL patches inside a `` ```spec `` fence. Each line adds or modifies a part of the spec, so the UI fills in progressively:
```spec
{"op":"add","path":"/root","value":"main"}
{"op":"add","path":"/elements/main","value":{"type":"stack","props":{},"children":["m1"]}}
{"op":"add","path":"/elements/m1","value":{"type":"metric","props":{"label":"Users","value":"1,234"},"children":[]}}
```
Supports `add`, `replace`, `remove`, and the `-` array append operator for streaming table rows.
### JSON Object
The LLM outputs a single JSON object:
```spec
{
"root": "card-1",
"elements": {
"card-1": { "type": "card", "props": { "title": "Stats" }, "children": ["m1"] },
"m1": { "type": "metric", "props": { "label": "Users", "value": "1,234" }, "children": [] }
}
}
```
### OpenUI Lang
A compact line-oriented DSL that uses ~50% fewer tokens than JSON. The LLM outputs positional component calls:
```spec
root = Stack([heading, grid])
heading = Heading("Weather Dashboard")
grid = Grid([nyCard, londonCard], 2)
nyCard = Card([nyTemp, nyWind], "New York")
nyTemp = Metric("Temperature", "72°F")
nyWind = Metric("Wind", "8 mph")
londonCard = Card([londonTemp], "London")
londonTemp = Metric("Temperature", "15°C")
```
**Syntax:**
| Construct | Example |
|---|---|
| Assignment | `id = Expression` |
| Component | `TypeName(arg1, arg2, ...)` |
| String | `"text"` |
| Number | `42`, `3.14`, `-1` |
| Boolean | `true` / `false` |
| Null | `null` |
| Array | `[a, b, c]` |
| Object | `{key: value}` |
| Reference | `identifier` (refers to another assignment) |
Arguments are positional, mapped to props by the component's schema key order. The prompt auto-generates signatures with type hints so the LLM knows valid values:
```
- Heading(text, level?: "h1"|"h2"|"h3"|"h4") — Section heading
- Card(children, title?, description?, variant?: "default"|"bordered"|"shadow") — A card container
- Metric(label, value, detail?, trend?: "up"|"down"|"neutral") — Single metric display
```
OpenUI Lang compiles to the same spec map as JSON formats — the renderer doesn't care which format produced the spec.
### Custom Formats
Implement the `LiveRender.Format` behaviour to add your own:
```elixir
defmodule MyApp.Format.YAML do
@behaviour LiveRender.Format
@impl true
def prompt(component_map, actions, opts), do: "..."
@impl true
def parse(text, opts), do: {:ok, %{}}
@impl true
def stream_init(opts), do: %{}
@impl true
def stream_push(state, chunk), do: {state, []}
@impl true
def stream_flush(state), do: {state, []}
end
```
## Custom Components
```elixir
defmodule MyApp.AI.Components.PriceCard do
use LiveRender.Component,
name: "price_card",
description: "Displays a price with currency formatting",
schema: [
label: [type: :string, required: true, doc: "Item name"],
price: [type: :float, required: true, doc: "Price value"],
currency: [type: {:in, [:usd, :eur, :gbp]}, default: :usd, doc: "Currency"]
]
use Phoenix.Component
def render(assigns) do
~H"""
<div class="rounded-lg border border-border p-4">
<span class="text-sm text-muted-foreground"><%= @label %></span>
<span class="text-2xl font-bold"><%= symbol(@currency) %><%= :erlang.float_to_binary(@price, decimals: 2) %></span>
</div>
"""
end
defp symbol(:usd), do: "$"
defp symbol(:eur), do: "€"
defp symbol(:gbp), do: "£"
end
```
Register it:
```elixir
defmodule MyApp.AI.Catalog do
use LiveRender.Catalog
component MyApp.AI.Components.PriceCard
component LiveRender.Components.Card
component LiveRender.Components.Stack
action :add_to_cart, description: "Add an item to the shopping cart"
end
```
Schemas support [NimbleOptions](https://hex.pm/packages/nimble_options) keyword lists, [JSONSpec](https://hex.pm/packages/json_spec) maps, or raw JSON Schema.
## Data Binding
Any prop value can be an expression resolved against the spec's `state`:
```json
{ "$state": "/user/name" }
{ "$cond": { "$state": "/loggedIn" }, "$then": "Welcome!", "$else": "Sign in" }
{ "$template": "Hello, ${/user/name}!" }
{ "$concat": ["Humidity: ", { "$state": "/humidity" }, "%"] }
```
## Styling
Built-in components use CSS variable-based classes compatible with [shadcn/ui](https://ui.shadcn.com/) theming (`text-muted-foreground`, `bg-card`, `border-border`, `bg-primary`, etc.). Define these variables in your app's CSS to control colors in both light and dark mode.
## Hooks
The Tabs component requires a `LiveRenderTabs` hook. Register it in your `app.js`:
```javascript
const LiveRenderTabs = {
mounted() {
this.el.addEventListener("lr:tab-change", () => {
const active = this.el.dataset.active;
this.el.querySelectorAll("[data-tab-value]").forEach(btn => {
btn.dataset.state = btn.dataset.tabValue === active ? "active" : "inactive";
});
this.el.querySelectorAll("[data-tab-content]").forEach(panel => {
panel.dataset.state = panel.dataset.tabContent === active ? "active" : "inactive";
});
});
this.el.dispatchEvent(new Event("lr:tab-change"));
}
};
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { LiveRenderTabs }
});
```
## Built-in Components
| Category | Components |
|---|---|
| **Layout** | Stack, Card, Grid, Separator |
| **Typography** | Heading, Text |
| **Data** | Metric, Badge, Table, Link |
| **Interactive** | Button, Tabs, TabContent |
| **Rich** | Callout, Timeline, Accordion, Progress, Alert |
Use them directly with `LiveRender.StandardCatalog`, or pick individual ones for your own catalog.
## Tools
With ReqLLM:
```elixir
LiveRender.Generate.stream_spec(model, prompt,
catalog: MyApp.AI.Catalog,
pid: self(),
tools: [
LiveRender.Tool.new!(
name: "get_weather",
description: "Get current weather",
parameter_schema: [location: [type: :string, required: true, doc: "City"]],
callback: &MyApp.Weather.fetch/1
)
]
)
```
With Jido, define tools as `Jido.Action` modules for a full ReAct agent loop.
## Example App
The [`example/`](example/) directory contains a chat application:
- Tools: weather, crypto, GitHub, Hacker News
- Jido ReAct agent with streaming
- Configurable format (OpenUI Lang by default in dev)
- PhoenixStreamdown for streaming markdown with word-level animations
- OpenRouter and Anthropic support
```bash
cd example
cp .env.example .env # add your API key (Anthropic or OpenRouter)
mix setup
mix phx.server
```
## Development
```bash
mix ci # compile, format, credo, dialyzer, ex_dna, test
```
## License
MIT — see [LICENSE](LICENSE).