Skip to content

dannote/live_render

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

33 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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?

  • 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, YAML, or 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

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 Patch LiveRender.Format.JSONPatch High Progressive streaming — UI appears element-by-element
JSON Object LiveRender.Format.JSONObject High Simple one-shot generation
YAML LiveRender.Format.YAML ~30% less Natural for LLMs, progressive streaming, fewer syntax errors
OpenUI Lang LiveRender.Format.OpenUILang ~50% less Token-sensitive workloads, fast models
A2UI LiveRender.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:

  • Tools: weather, crypto, GitHub, Hacker News
  • Jido ReAct agent with streaming
  • Runtime format selector (JSONL, JSON, YAML, OpenUI Lang)
  • PhoenixStreamdown for streaming markdown with word-level animations
  • OpenRouter and Anthropic support
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.

About

Server-driven generative UI for Phoenix LiveView

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors