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, built idiomatically for the BEAM.

Why LiveRender?

Quick Start

1. Define a catalog

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

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

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 for full ReAct agent loops with tool calling — see the example app.

Or bring your own client — anything that produces a spec map works.

Installation

def deps do
  [
    {:live_render, "~> 0.5"}
  ]
end

Optional dependencies unlock extra features:

Dependency Unlocks
{:req_llm, "~> 1.6"}LiveRender.Generate — streaming/one-shot spec generation
{:yaml_elixir, "~> 2.12"}LiveRender.Format.YAML — YAML wire format
{: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 PatchLiveRender.Format.JSONPatch High Progressive streaming — UI appears element-by-element
JSON ObjectLiveRender.Format.JSONObject High Simple one-shot generation
YAMLLiveRender.Format.YAML~30% less Natural for LLMs, progressive streaming, fewer syntax errors
OpenUI LangLiveRender.Format.OpenUILang~50% less Token-sensitive workloads, fast models
A2UILiveRender.Format.A2UI High Interop with A2UI agents and transports

Pass :format to system_prompt/1 or stream_spec/3:

# 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.

A2UI

Google's A2UI protocol — a JSONL stream of envelope messages (createSurface, updateComponents, updateDataModel, deleteSurface). Use this format to consume UI from A2UI-speaking agents over A2A, AG UI, MCP, or any other transport.

```spec
{"version":"v0.10","createSurface":{"surfaceId":"main","catalogId":"basic"}}
{"version":"v0.10","updateComponents":{"surfaceId":"main","components":[{"id":"root","component":"Stack","children":["heading","metric1"]},{"id":"heading","component":"Heading","text":"Dashboard"},{"id":"metric1","component":"Metric","label":"Users","value":{"path":"/users/count"}}]}}
{"version":"v0.10","updateDataModel":{"surfaceId":"main","path":"/users","value":{"count":"1,234"}}}
```

A2UI data bindings ({"path": "/..."}) are automatically converted to LiveRender's {"$state": "/..."} expressions. Component names use PascalCase and are mapped to catalog entries via snake_case conversion.

YAML

YAML is more natural for LLMs — no quotes on keys, no commas, no braces — and produces fewer syntax errors during streaming. The parser incrementally re-parses on each newline and emits progressive updates:

```yaml
root: main
elements:
  main:
    type: stack
    props:
      direction: vertical
    children:
      - heading
      - metric-1
  heading:
    type: heading
    props:
      text: Weather Dashboard
    children: []
  metric-1:
    type: metric
    props:
      label: Temperature
      value: "72°F"
    children: []
```

Accepts both ```spec and ```yaml fences. Requires {:yaml_elixir, "~> 2.12"}.

Edit Mode (Merge)

All JSON-based formats and YAML support multi-turn editing. Pass :current_spec and the LLM outputs only changed keys, deep-merged via RFC 7396:

LiveRender.Generate.stream_spec(model, "change the title to Welcome",
  catalog: MyApp.AI.Catalog,
  pid: self(),
  format: LiveRender.Format.YAML,
  current_spec: existing_spec
)

For YAML, the LLM outputs a partial spec:

```yaml
elements:
  heading:
    props:
      text: Welcome
```

For JSONPatch, it outputs a merge line with __lr_edit:

```spec
{"__lr_edit":true,"elements":{"heading":{"props":{"text":"Welcome"}}}}
```

Unmentioned keys are preserved. Set a key to null/nil to delete it.

Custom Formats

Implement the LiveRender.Format behaviour to add your own:

defmodule MyApp.Format.Custom 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

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:

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 keyword lists, JSONSpec maps, or raw JSON Schema.

Data Binding

Any prop value can be an expression resolved against the spec's state:

{ "$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 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:

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:

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/ directory contains a chat application:

cd example
cp .env.example .env  # add your API key (Anthropic or OpenRouter)
mix setup
mix phx.server

Development

mix ci  # compile, format, credo, dialyzer, ex_dna, test

License

MIT — see LICENSE.