| 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?() && !incoming.empty?
render( { }, )
messages = [{ => , => }]
incoming.each ||
m.is_a?()
messages << { => m[].to_s, => m[].to_s }
response.headers[] =
response.headers[] =
response.headers[] = .now.httpdate
response.headers[] =
uri = .parse()
payload = .generate(
,
,
messages,
)
.start(uri.host, uri.port, uri.scheme == , ) ||
upstream = .new(uri)
upstream[] =
upstream[] =
upstream[] =
upstream.body = payload
http.request(upstream) ||
res.code.to_i ==
err = +
res.read_body { || err << c }
response.stream.write()
response.stream.write()
res.read_body ||
response.stream.write(chunk)
,
=> e
.logger.error()
response.stream.write()
response.stream.write()
,
response.stream.close
response.headers[] = .fetch(, )
response.headers[] =
response.headers[] =
response.headers[] =
head() request.method ==
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 |