$npx -y skills add OthmanAdi/openui-forge --skill openui-forge-rubyOpenUI generative UI with a Ruby on Rails backend. SSE streaming via ActionController::Live, forwarding the OpenAI API stream with Net::HTTP.
| 1 | # OpenUI Forge — Ruby |
| 2 | |
| 3 | 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`. |
| 4 | |
| 5 | ## Activation Triggers |
| 6 | |
| 7 | - "openui ruby", "openui rails", "openui ruby backend" |
| 8 | - "generative ui ruby", "rails streaming ui backend" |
| 9 | |
| 10 | ## Prerequisites |
| 11 | |
| 12 | - Node.js >= 22 (24 LTS recommended) + React >= 18.3.1 (19+ recommended) (frontend) |
| 13 | - Ruby >= 3.2 + Rails 8.1.x (backend; run on Puma — `ActionController::Live` needs a threaded server, not WEBrick) |
| 14 | - `OPENAI_API_KEY` environment variable set |
| 15 | |
| 16 | ## Quick Start |
| 17 | |
| 18 | 1. Create the React frontend and install OpenUI deps: |
| 19 | ```bash |
| 20 | npm install @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang lucide-react zod |
| 21 | ``` |
| 22 | 2. Generate the system prompt into the Rails app: |
| 23 | ```bash |
| 24 | npx @openuidev/cli generate ./src/lib/library.ts --out config/system-prompt.txt |
| 25 | ``` |
| 26 | 3. Create the Rails backend (see Full Code below) |
| 27 | 4. Run: `bin/rails server -p 3001` on `:3001`, frontend on `:3000` |
| 28 | |
| 29 | ## Full Code |
| 30 | |
| 31 | ### Backend: `Gemfile` |
| 32 | |
| 33 | ```ruby |
| 34 | source "https://rubygems.org" |
| 35 | |
| 36 | gem "rails", "~> 8.1" |
| 37 | gem "puma", ">= 6.0" |
| 38 | # Net::HTTP is in the standard library — no extra HTTP-client gem required. |
| 39 | # Optional: load OPENAI_API_KEY etc. from a .env file in development. |
| 40 | gem "dotenv-rails", groups: [:development, :test] |
| 41 | ``` |
| 42 | |
| 43 | ### Backend: `app/controllers/chat_controller.rb` |
| 44 | |
| 45 | ```ruby |
| 46 | require "net/http" |
| 47 | require "json" |
| 48 | require "uri" |
| 49 | |
| 50 | class ChatController < ApplicationController |
| 51 | include ActionController::Live |
| 52 | |
| 53 | skip_forgery_protection |
| 54 | before_action :set_cors_headers |
| 55 | before_action :handle_preflight, only: :create |
| 56 | |
| 57 | OPENAI_BASE_URL = ENV.fetch("OPENAI_BASE_URL", "https://api.openai.com/v1").freeze |
| 58 | OPENAI_MODEL = ENV.fetch("OPENAI_MODEL", "gpt-5.5").freeze |
| 59 | |
| 60 | # Loaded once at boot. |
| 61 | SYSTEM_PROMPT = Rails.root.join("config", "system-prompt.txt").read.freeze |
| 62 | |
| 63 | # POST /api/chat { "messages": [{ "role": "...", "content": "..." }] } |
| 64 | def create |
| 65 | api_key = ENV["OPENAI_API_KEY"] |
| 66 | if api_key.nil? || api_key.empty? |
| 67 | render(json: { error: "OPENAI_API_KEY not set" }, status: :internal_server_error) |
| 68 | return |
| 69 | end |
| 70 | |
| 71 | body = JSON.parse(request.body.read) rescue {} |
| 72 | incoming = body["messages"] |
| 73 | unless incoming.is_a?(Array) && !incoming.empty? |
| 74 | render(json: { error: "messages must be a non-empty array" }, status: :bad_request) |
| 75 | return |
| 76 | end |
| 77 | |
| 78 | # Prepend the server-side system prompt; never trust a client-sent one. |
| 79 | messages = [{ "role" => "system", "content" => SYSTEM_PROMPT }] |
| 80 | incoming.each do |m| |
| 81 | next unless m.is_a?(Hash) |
| 82 | messages << { "role" => m["role"].to_s, "content" => m["content"].to_s } |
| 83 | end |
| 84 | |
| 85 | # Headers MUST be set before the first write (the response commits on write). |
| 86 | response.headers["Content-Type"] = "text/event-stream" |
| 87 | response.headers["Cache-Control"] = "no-cache" |
| 88 | # Rails inserts Rack::ETag, which buffers the whole body and breaks |
| 89 | # streaming. Setting Last-Modified makes Rack::ETag pass the body through. |
| 90 | response.headers["Last-Modified"] = Time.now.httpdate |
| 91 | # Defeat proxy buffering (nginx) so chunks reach the browser immediately. |
| 92 | response.headers["X-Accel-Buffering"] = "no" |
| 93 | |
| 94 | uri = URI.parse("#{OPENAI_BASE_URL}/chat/completions") |
| 95 | payload = JSON.generate( |
| 96 | model: OPENAI_MODEL, |
| 97 | stream: true, |
| 98 | messages: messages, |
| 99 | ) |
| 100 | |
| 101 | # read_timeout: nil disables the default 60s per-read timeout; a streaming |
| 102 | # completion can pause longer than 60s between chunks and would otherwise |
| 103 | # raise Net::ReadTimeout and cut the response off mid-stream. |
| 104 | Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: nil) do |http| |
| 105 | upstream = Net::HTTP::Post.new(uri) |
| 106 | upstream["Content-Type"] = "application/json" |
| 107 | upstream["Authorization"] = "Bearer #{api_key}" |
| 108 | upstream["Accept"] = "text/event-stream" |
| 109 | upstream.body = payload |
| 110 | |
| 111 | http.request(upstream) do |res| |
| 112 | unless res.code.to_i == 200 |
| 113 | err = +"" |
| 114 | res.read_body { |c| err << c } |
| 115 | response.stream.write("data: #{JSON.generate(error: "OpenAI returned #{res.code}: #{err}")}\n\n") |
| 116 | response.stream.write("data: [DONE]\n\n") |
| 117 | next |
| 118 | end |
| 119 | |
| 120 | # Forward OpenAI's native SSE bytes verbatim. The upstream already emits |
| 121 | # `data: {chunk}\n\n` frames terminated by `data: [DONE]`, so we just |
| 122 | # write each chunk through and flush — no buffering, no reframing. This |
| 123 | # avoids splitting a frame that straddles a TCP read boundary, because |
| 124 | # the browser's adapter reassembles SSE frames itself. |
| 125 | res.read_body do |chunk| |
| 126 | response.stream.write(chunk) |
| 127 | end |
| 128 | end |