$npx -y skills add OthmanAdi/openui-forge --skill openui-forge-elixirOpenUI generative UI with Elixir Phoenix backend. Chunked SSE streaming via Plug.Conn and Req.
| 1 | # OpenUI Forge — Elixir |
| 2 | |
| 3 | Build generative UI apps with a React frontend + Elixir Phoenix backend. Streams OpenAI API responses directly to the browser as SSE using `Plug.Conn.send_chunked/2` and the Req HTTP client's streaming `:into` collector. |
| 4 | |
| 5 | ## Activation Triggers |
| 6 | |
| 7 | - "openui elixir", "openui phoenix", "openui elixir backend" |
| 8 | - "generative ui elixir", "elixir streaming ui backend", "phoenix sse openui" |
| 9 | |
| 10 | ## Prerequisites |
| 11 | |
| 12 | - Node.js >= 22 (24 LTS recommended) + React >= 18.3.1 (19+ recommended) (frontend) |
| 13 | - Elixir >= 1.15 with Erlang/OTP 25+ (Phoenix 1.8 baseline) |
| 14 | - Phoenix ~> 1.8 (current stable; 1.8.8 as of June 2026) |
| 15 | - `OPENAI_API_KEY` environment variable set |
| 16 | |
| 17 | ## Quick Start |
| 18 | |
| 19 | 1. Create the React frontend and install OpenUI deps: |
| 20 | ```bash |
| 21 | npm install @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang lucide-react zod |
| 22 | ``` |
| 23 | 2. Generate the system prompt into the Phoenix app's `priv/`: |
| 24 | ```bash |
| 25 | npx @openuidev/cli generate ./src/lib/library.ts --out backend/priv/system-prompt.txt |
| 26 | ``` |
| 27 | 3. Create the Phoenix backend (see Full Code below) |
| 28 | 4. Run: `mix deps.get && mix phx.server` on `:4000`, frontend on `:3000` |
| 29 | |
| 30 | ## Full Code |
| 31 | |
| 32 | ### Backend: `backend/mix.exs` (deps) |
| 33 | |
| 34 | ```elixir |
| 35 | defp deps do |
| 36 | [ |
| 37 | {:phoenix, "~> 1.8.0"}, |
| 38 | {:bandit, "~> 1.0"}, # HTTP server (Phoenix 1.8 default) |
| 39 | {:jason, "~> 1.4"}, # JSON encode/decode |
| 40 | {:req, "~> 0.6"}, # HTTP client with streaming :into (v0.6.2+) |
| 41 | {:cors_plug, "~> 3.0"} # explicit-origin CORS |
| 42 | ] |
| 43 | end |
| 44 | ``` |
| 45 | |
| 46 | ### Backend: load the system prompt once at startup |
| 47 | |
| 48 | Read `priv/system-prompt.txt` a single time and cache it in `:persistent_term`. Call `OpenuiBackend.load_system_prompt!/0` from your `Application.start/2` callback **before** the endpoint child starts. |
| 49 | |
| 50 | ```elixir |
| 51 | # lib/openui_backend.ex |
| 52 | defmodule OpenuiBackend do |
| 53 | @key {__MODULE__, :system_prompt} |
| 54 | |
| 55 | def load_system_prompt! do |
| 56 | path = Application.app_dir(:openui_backend, "priv/system-prompt.txt") |
| 57 | |
| 58 | case File.read(path) do |
| 59 | {:ok, contents} -> |
| 60 | :persistent_term.put(@key, contents) |
| 61 | :ok |
| 62 | |
| 63 | {:error, reason} -> |
| 64 | raise "failed to read #{path}: #{:file.format_error(reason)}" |
| 65 | end |
| 66 | end |
| 67 | |
| 68 | def system_prompt, do: :persistent_term.get(@key) |
| 69 | end |
| 70 | ``` |
| 71 | |
| 72 | ```elixir |
| 73 | # lib/openui_backend/application.ex — inside start/2, before the children list |
| 74 | def start(_type, _args) do |
| 75 | OpenuiBackend.load_system_prompt!() |
| 76 | |
| 77 | children = [ |
| 78 | OpenuiBackendWeb.Endpoint |
| 79 | # ... |
| 80 | ] |
| 81 | |
| 82 | Supervisor.start_link(children, strategy: :one_for_one, name: OpenuiBackend.Supervisor) |
| 83 | end |
| 84 | ``` |
| 85 | |
| 86 | ### Backend: `lib/openui_backend_web/controllers/chat_controller.ex` |
| 87 | |
| 88 | ```elixir |
| 89 | defmodule OpenuiBackendWeb.ChatController do |
| 90 | use OpenuiBackendWeb, :controller |
| 91 | |
| 92 | require Logger |
| 93 | |
| 94 | @openai_chat_path "/chat/completions" |
| 95 | |
| 96 | # POST /api/chat |
| 97 | def create(conn, %{"messages" => messages}) when is_list(messages) do |
| 98 | case System.get_env("OPENAI_API_KEY") do |
| 99 | key when is_binary(key) and key != "" -> stream_chat(conn, messages, key) |
| 100 | _ -> send_error(conn, 500, "OPENAI_API_KEY not set") |
| 101 | end |
| 102 | end |
| 103 | |
| 104 | def create(conn, _params), do: send_error(conn, 400, "messages must be a non-empty array") |
| 105 | |
| 106 | defp stream_chat(conn, messages, api_key) do |
| 107 | base_url = System.get_env("OPENAI_BASE_URL") || "https://api.openai.com/v1" |
| 108 | model = System.get_env("OPENAI_MODEL") || "gpt-5.5" |
| 109 | |
| 110 | # Prepend the server-side system prompt; never trust the client to send it. |
| 111 | system_message = %{"role" => "system", "content" => OpenuiBackend.system_prompt()} |
| 112 | payload = %{"model" => model, "stream" => true, "messages" => [system_message | messages]} |
| 113 | |
| 114 | # Open the SSE response before the upstream call so the first byte flushes |
| 115 | # to the browser as soon as OpenAI starts emitting tokens. |
| 116 | conn = |
| 117 | conn |
| 118 | |> put_resp_content_type("text/event-stream") |
| 119 | |> put_resp_header("cache-control", "no-cache") |
| 120 | |> put_resp_header("x-accel-buffering", "no") |
| 121 | |> send_chunked(200) |
| 122 | |
| 123 | result = |
| 124 | Req.post( |
| 125 | url: base_url <> @openai_chat_path, |
| 126 | json: payload, |
| 127 | auth: {:bearer, api_key}, |
| 128 | receive_timeout: :infinity, |
| 129 | # `:into` turns the response body into a stream: Req hands each upstream |
| 130 | # SSE chunk to this 2-arity collector as {:data, data}. We forward it |
| 131 | # verbatim with chunk/2 and halt the moment the client disconnects, |
| 132 | # mirroring the Enum.reduce_while halt-on-{:error,_} pattern. Plug.Conn |
| 133 | # is immutable, so we thread the latest conn through resp.private. |
| 134 | into: fn {:data, data}, {req, resp} -> |
| 135 | out_conn = resp.private[:openui_conn] || conn |
| 136 | |
| 137 | case Plug.Conn.chunk(out_conn, data) do |
| 138 | {:ok, out_conn} -> |
| 139 | {:cont, {req, Req.Response.put_private(resp, :openui_conn, out_conn)}} |
| 140 | |
| 141 | {:error, reason} -> |