From openui-forge
Builds generative UI apps with React frontend and Elixir Phoenix backend, streaming OpenAI API responses as SSE via Plug.Conn.send_chunked/2 and Req.
How this skill is triggered — by the user, by Claude, or both
Slash command
/openui-forge:openui-forge-elixirThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
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.
OPENAI_API_KEY environment variable setnpm install @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang lucide-react zod
priv/:npx @openuidev/cli generate ./src/lib/library.ts --out backend/priv/system-prompt.txt
mix deps.get && mix phx.server on :4000, frontend on :3000backend/mix.exs (deps)defp deps do
[
{:phoenix, "~> 1.8.0"},
{:bandit, "~> 1.0"}, # HTTP server (Phoenix 1.8 default)
{:jason, "~> 1.4"}, # JSON encode/decode
{:req, "~> 0.6"}, # HTTP client with streaming :into (v0.6.2+)
{:cors_plug, "~> 3.0"} # explicit-origin CORS
]
end
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.
# lib/openui_backend.ex
defmodule OpenuiBackend do
@key {__MODULE__, :system_prompt}
def load_system_prompt! do
path = Application.app_dir(:openui_backend, "priv/system-prompt.txt")
case File.read(path) do
{:ok, contents} ->
:persistent_term.put(@key, contents)
:ok
{:error, reason} ->
raise "failed to read #{path}: #{:file.format_error(reason)}"
end
end
def system_prompt, do: :persistent_term.get(@key)
end
# lib/openui_backend/application.ex — inside start/2, before the children list
def start(_type, _args) do
OpenuiBackend.load_system_prompt!()
children = [
OpenuiBackendWeb.Endpoint
# ...
]
Supervisor.start_link(children, strategy: :one_for_one, name: OpenuiBackend.Supervisor)
end
lib/openui_backend_web/controllers/chat_controller.exdefmodule OpenuiBackendWeb.ChatController do
use OpenuiBackendWeb, :controller
require Logger
@openai_chat_path "/chat/completions"
# POST /api/chat
def create(conn, %{"messages" => messages}) when is_list(messages) do
case System.get_env("OPENAI_API_KEY") do
key when is_binary(key) and key != "" -> stream_chat(conn, messages, key)
_ -> send_error(conn, 500, "OPENAI_API_KEY not set")
end
end
def create(conn, _params), do: send_error(conn, 400, "messages must be a non-empty array")
defp stream_chat(conn, messages, api_key) do
base_url = System.get_env("OPENAI_BASE_URL") || "https://api.openai.com/v1"
model = System.get_env("OPENAI_MODEL") || "gpt-5.5"
# Prepend the server-side system prompt; never trust the client to send it.
system_message = %{"role" => "system", "content" => OpenuiBackend.system_prompt()}
payload = %{"model" => model, "stream" => true, "messages" => [system_message | messages]}
# Open the SSE response before the upstream call so the first byte flushes
# to the browser as soon as OpenAI starts emitting tokens.
conn =
conn
|> put_resp_content_type("text/event-stream")
|> put_resp_header("cache-control", "no-cache")
|> put_resp_header("x-accel-buffering", "no")
|> send_chunked(200)
result =
Req.post(
url: base_url <> @openai_chat_path,
json: payload,
auth: {:bearer, api_key},
receive_timeout: :infinity,
# `:into` turns the response body into a stream: Req hands each upstream
# SSE chunk to this 2-arity collector as {:data, data}. We forward it
# verbatim with chunk/2 and halt the moment the client disconnects,
# mirroring the Enum.reduce_while halt-on-{:error,_} pattern. Plug.Conn
# is immutable, so we thread the latest conn through resp.private.
into: fn {:data, data}, {req, resp} ->
out_conn = resp.private[:openui_conn] || conn
case Plug.Conn.chunk(out_conn, data) do
{:ok, out_conn} ->
{:cont, {req, Req.Response.put_private(resp, :openui_conn, out_conn)}}
{:error, reason} ->
Logger.debug("client disconnected mid-stream: #{inspect(reason)}")
{:halt, {req, resp}}
end
end
)
case result do
{:ok, %Req.Response{status: status} = resp} when status in 200..299 ->
# OpenAI already sends a terminating `data: [DONE]` line (forwarded verbatim).
resp.private[:openui_conn] || conn
{:ok, %Req.Response{status: status}} ->
Logger.error("OpenAI returned HTTP #{status}")
push_error_frame(conn, "OpenAI returned HTTP #{status}")
{:error, exception} ->
Logger.error("OpenAI request failed: #{Exception.message(exception)}")
push_error_frame(conn, "OpenAI request unreachable")
end
end
defp push_error_frame(conn, message) do
payload = Jason.encode!(%{error: message})
_ = Plug.Conn.chunk(conn, "data: #{payload}\n\ndata: [DONE]\n\n")
conn
end
defp send_error(conn, status, message) do
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(%{error: message}))
end
end
lib/openui_backend_web/router.ex (scope)defmodule OpenuiBackendWeb.Router do
use OpenuiBackendWeb, :router
pipeline :api do
plug :accepts, ["json"]
end
scope "/api", OpenuiBackendWeb do
pipe_through :api
post "/chat", ChatController, :create
end
end
CORS lives in the endpoint, before the router, locked to the frontend origin (never a wildcard for the credentialed case):
# lib/openui_backend_web/endpoint.ex — before `plug OpenuiBackendWeb.Router`
plug CORSPlug,
origin: [System.get_env("FRONTEND_ORIGIN") || "http://localhost:3000"],
methods: ["POST", "OPTIONS"]
app/chat/page.tsx"use client";
import { FullScreen } from "@openuidev/react-ui";
import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
import {
openAIAdapter,
openAIMessageFormat,
} from "@openuidev/react-headless";
export default function ChatPage() {
return (
<FullScreen
componentLibrary={openuiChatLibrary}
streamProtocol={openAIAdapter()}
messageFormat={openAIMessageFormat}
apiUrl="http://localhost:4000/api/chat"
/>
);
}
The Phoenix backend proxies OpenAI's native SSE stream verbatim: Req's
:intocollector receives each upstream{:data, data}chunk and re-emits it withPlug.Conn.chunk/2(incremental flush, no buffering), so the browser seesdata: {chunk}\n\nlines and the terminatingdata: [DONE]. Pair it withopenAIAdapter()on the frontend.openAIReadableStreamAdapter()is for NDJSON (nodata:prefix) and will silently produce no output here.
req_llm(built on Req + Finch) is a higher-level alternative that handles provider SSE adaptation if you later want typed multi-provider support. This skill keeps plainReqas the dependency-light default.
npx @openuidev/cli generate ./src/lib/library.ts --out backend/priv/system-prompt.txt
priv/system-prompt.txt exists in the Phoenix backendOpenuiBackend.load_system_prompt!/0 is called in Application.start/2 before the endpointOPENAI_API_KEY is set in environment (honor OPENAI_BASE_URL for OpenAI-compatible providers)*):into passthrough, chunk/2 per chunk)apiUrl points to http://localhost:4000/api/chatstreamProtocol={openAIAdapter()} and openAIMessageFormatcomponentLibrary={openuiChatLibrary} prop passed to FullScreen@openuidev/react-ui/components.css)| Error | Cause | Fix |
|---|---|---|
| CORS blocked | Origin mismatch | Set FRONTEND_ORIGIN and confirm CORSPlug runs before the router |
:persistent_term key not found | Prompt not loaded at boot | Call OpenuiBackend.load_system_prompt!/0 in Application.start/2 |
(File.Error) could not read priv/system-prompt.txt | File missing | Run the CLI generate command into priv/ |
500 OPENAI_API_KEY not set | Env var missing | Export OPENAI_API_KEY before mix phx.server |
| Empty response | Wrong frontend adapter | Backend emits SSE; use openAIAdapter(), not openAIReadableStreamAdapter() |
| Stream stalls / cut at ~60s | Cowboy idle timeout | On Bandit (default) no cap; on Cowboy set protocol_options: [idle_timeout: :infinity] |
function send_chunked/2 undefined | Plug.Conn not imported | Use use OpenuiBackendWeb, :controller (imports Plug.Conn) or call Plug.Conn.send_chunked/2 |
npx claudepluginhub othmanadi/openui-forgeBuilds generative UI apps using a React frontend with a Rust Axum backend, handling async SSE streaming to OpenAI-compatible NDJSON.
Provides Elixir expertise for OTP patterns, supervision trees, Phoenix LiveView, Ecto, and concurrent/distributed systems with BEAM best practices.
Guides Phoenix web app development including LiveView, contexts, channels, and production runtime configuration.