| name | openui-forge-ruby |
| description | OpenUI generative UI with a Ruby on Rails backend. SSE streaming via ActionController::Live, forwarding the OpenAI API stream with Net::HTTP. |
| version | 1.2.0 |
| author | OthmanAdi |
OpenUI Forge — Ruby
Build generative UI apps with a React frontend + Ruby on Rails backend. Streams OpenAI API responses directly via ActionController::Live, forwarding OpenAI's native SSE with Net::HTTP.
Activation Triggers
- "openui ruby", "openui rails", "openui ruby backend"
- "generative ui ruby", "rails streaming ui backend"
Prerequisites
- Node.js >= 22 (24 LTS recommended) + React >= 18.3.1 (19+ recommended) (frontend)
- Ruby >= 3.2 + Rails 8.1.x (backend; run on Puma —
ActionController::Live needs a threaded server, not WEBrick)
OPENAI_API_KEY environment variable set
Quick Start
- Create the React frontend and install OpenUI deps:
npm install @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang lucide-react zod
- Generate the system prompt into the Rails app:
npx @openuidev/cli generate ./src/lib/library.ts --out config/system-prompt.txt
- Create the Rails backend (see Full Code below)
- Run:
bin/rails server -p 3001 on :3001, frontend on :3000
Full Code
Backend: Gemfile
source "https://rubygems.org"
gem "rails", "~> 8.1"
gem "puma", ">= 6.0"
gem "dotenv-rails", groups: [:development, :test]
Backend: app/controllers/chat_controller.rb
require "net/http"
require "json"
require "uri"
class ChatController < ApplicationController
include ActionController::Live
skip_forgery_protection
before_action :set_cors_headers
before_action :handle_preflight, only: :create
OPENAI_BASE_URL = ENV.fetch("OPENAI_BASE_URL", "https://api.openai.com/v1").freeze
OPENAI_MODEL = ENV.fetch("OPENAI_MODEL", "gpt-5.5").freeze
SYSTEM_PROMPT = Rails.root.join("config", "system-prompt.txt").read.freeze
def create
api_key = ENV["OPENAI_API_KEY"]
if api_key.nil? || api_key.empty?
render(json: { error: "OPENAI_API_KEY not set" }, status: :internal_server_error)
return
end
body = JSON.parse(request.body.read) rescue {}
incoming = body["messages"]
unless incoming.is_a?(Array) && !incoming.empty?
render(json: { error: "messages must be a non-empty array" }, status: :bad_request)
return
end
messages = [{ "role" => "system", "content" => SYSTEM_PROMPT }]
incoming.each do |m|
next unless m.is_a?(Hash)
messages << { "role" => m["role"].to_s, "content" => m["content"].to_s }
end
response.headers["Content-Type"] = "text/event-stream"
response.headers["Cache-Control"] = "no-cache"
response.headers["Last-Modified"] = Time.now.httpdate
response.headers["X-Accel-Buffering"] = "no"
uri = URI.parse("#{OPENAI_BASE_URL}/chat/completions")
payload = JSON.generate(
model: OPENAI_MODEL,
stream: true,
messages: messages,
)
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: nil) do |http|
upstream = Net::HTTP::Post.new(uri)
upstream["Content-Type"] = "application/json"
upstream["Authorization"] = "Bearer #{api_key}"
upstream["Accept"] = "text/event-stream"
upstream.body = payload
http.request(upstream) do |res|
unless res.code.to_i == 200
err = +""
res.read_body { |c| err << c }
response.stream.write("data: #{JSON.generate(error: "OpenAI returned #{res.code}: #{err}")}\n\n")
response.stream.write("data: [DONE]\n\n")
next
end
res.read_body do |chunk|
response.stream.write(chunk)
end
end
end
rescue IOError, Errno::EPIPE
rescue => e
Rails.logger.error("[chat] stream error: #{e.class}: #{e.message}")
begin
response.stream.write("data: #{JSON.generate(error: e.message)}\n\n")
response.stream.write("data: [DONE]\n\n")
rescue IOError, Errno::EPIPE
end
ensure
response.stream.close
end
private
def set_cors_headers
response.headers["Access-Control-Allow-Origin"] = ENV.fetch("FRONTEND_ORIGIN", "http://localhost:3000")
response.headers["Vary"] = "Origin"
response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
end
def handle_preflight
head(:no_content) if request.method == "OPTIONS"
end
end
Backend: config/routes.rb
Rails.application.routes.draw do
post "/api/chat", to: "chat#create"
match "/api/chat", to: "chat#create", via: :options
end
Frontend: 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:3001/api/chat"
/>
);
}
The Rails backend forwards OpenAI's SSE stream verbatim through ActionController::Live (response.stream.write(chunk) per Net::HTTP read_body chunk), so the client sees tokens as they arrive. Pair it with openAIAdapter() on the frontend. openAIReadableStreamAdapter() is for NDJSON (no data: prefix) and will silently produce no output here.
Net::HTTP (standard library) is the dependency-free default here. The ruby-openai / openai gems are alternatives if you want a typed client, but forwarding the raw SSE bytes needs no gem.
System Prompt Generation
npx @openuidev/cli generate ./src/lib/library.ts --out config/system-prompt.txt
Validation Checklist
Error Patterns
| Error | Cause | Fix |
|---|
| CORS blocked | Origin mismatch | Set FRONTEND_ORIGIN to match the frontend |
system-prompt.txt missing (Errno::ENOENT at boot) | File not generated | Run the CLI generate command into config/ |
| Response arrives all at once, not streamed | Rack::ETag buffering | Keep the Last-Modified header (or remove Rack::ETag) |
| Stream hangs / never flushes | Running on WEBrick | Run on Puma (threaded server) |
ActionController::Live::ClientDisconnected / Errno::EPIPE | Client closed the tab mid-stream | Rescue it; the ensure still closes the stream |
| Socket stays open after request | response.stream.close skipped | Close in an ensure block (as shown) |
| 422 Unprocessable Entity on POST | CSRF check | skip_forgery_protection in the controller |
| Empty response | Body not forwarded | Verify the `read_body do |