OpenUI Forge — Ruby

SkillAI & models

OpenUI generative UI with a Ruby on Rails backend. SSE streaming via ActionController::Live, forwarding the OpenAI API stream with Net::HTTP.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the OpenUI Forge — Ruby skill

What this skill tells your AI

The instructions your AI receives, as published by othmanadi/openui-forge in skills/openui-forge-ruby/SKILL.md and read by ahel’s review.

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

  1. Create the React frontend and install OpenUI deps:
npm install @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang lucide-react zod
  1. Generate the system prompt into the Rails app:
npx @openuidev/cli generate ./src/lib/library.ts --out config/system-prompt.txt
  1. Create the Rails backend (see Full Code below)
  2. 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"
# Net::HTTP is in the standard library — no extra HTTP-client gem required.
# Optional: load OPENAI_API_KEY etc. from a .env file in development.
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

  # Loaded once at boot.
  SYSTEM_PROMPT = Rails.root.join("config", "system-prompt.txt").read.freeze

  # POST /api/chat  { "messages": [{ "role": "...", "content": "..." }] }
  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

    # Prepend the server-side system prompt; never trust a client-sent one.
    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

    # Headers MUST be set before the first write (the response commits on write).
    response.headers["Content-Type"]  = "text/event-stream"
    response.headers["Cache-Control"] = "no-cache"
    # Rails inserts Rack::ETag, which buffers the whole body and breaks
    # streaming. Setting Last-Modified makes Rack::ETag pass the body through.
    response.headers["Last-Modified"] = Time.now.httpdate
    # Defeat proxy buffering (nginx) so chunks reach the browser immediately.
    response.headers["X-Accel-Buffering"] = "no"

    uri = URI.parse("#{OPENAI_BASE_URL}/chat/completions")
    payload = JSON.generate(
      model: OPENAI_MODEL,
      stream: true,
      messages: messages,
    )

    # read_timeout: nil disables the default 60s per-read timeout; a streaming
    # completion can pause longer than 60s between chunks and would otherwise
    # raise Net::ReadTimeout and cut the response off mid-stream.
    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

        # Forward OpenAI's native SSE bytes verbatim. The upstream already emits
        # `data: {chunk}\n\n` frames terminated by `data: [DONE]`, so we just
        # write each chunk through and flush — no buffering, no reframing. This
        # avoids splitting a frame that straddles a TCP read boundary, because
        # the browser's adapter reassembles SSE frames itself.
        res.read_body do |chunk|
          response.stream.write(chunk)
        end
      end
    end
  rescue IOError, Errno::EPIPE
    # Client disconnected mid-stream — nothing more to send.
  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
      # client already gone
    end
  ensure
    # ALWAYS close, or the socket leaks for the lifetime of the worker.
    response.stream.close
  end

  private

  # Lock CORS to the single configured frontend origin (NOT "*"): the request
  # is credentialed-capable and a wildcard would let any site spend your key.
  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  # CORS preflight
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

  • config/system-prompt.txt exists in the Rails app
  • OPENAI_API_KEY is set in environment or .env
  • Controller include ActionController::Live and skip_forgery_protection
  • CORS headers allow the frontend origin (not *)
  • Response streams SSE directly from OpenAI API (verbatim passthrough)
  • response.stream.close runs in an ensure block
  • Last-Modified (or removed Rack::ETag) so the response is not buffered
  • Running on Puma, not WEBrick
  • Frontend apiUrl points to http://localhost:3001/api/chat
  • Frontend uses streamProtocol={openAIAdapter()} and openAIMessageFormat
  • componentLibrary={openuiChatLibrary} prop passed to FullScreen
  • CSS import in root layout (@openuidev/react-ui/components.css)

Error Patterns

ErrorCauseFix
CORS blockedOrigin mismatchSet FRONTEND_ORIGIN to match the frontend
system-prompt.txt missing (Errno::ENOENT at boot)File not generatedRun the CLI generate command into config/
Response arrives all at once, not streamedRack::ETag bufferingKeep the Last-Modified header (or remove Rack::ETag)
Stream hangs / never flushesRunning on WEBrickRun on Puma (threaded server)
ActionController::Live::ClientDisconnected / Errno::EPIPEClient closed the tab mid-streamRescue it; the ensure still closes the stream
Socket stays open after requestresponse.stream.close skippedClose in an ensure block (as shown)
422 Unprocessable Entity on POSTCSRF checkskip_forgery_protection in the controller
Empty responseBody not forwardedVerify the `read_body do

Signals

GitHub stars
22
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
openui-forge-ruby
Source
github.com/othmanadi/openui-forge