From openui-forge
Provides code and instructions for building OpenUI generative UI apps with a React frontend and Laravel 13.x backend, streaming OpenAI SSE responses via response()->stream().
How this skill is triggered — by the user, by Claude, or both
Slash command
/openui-forge:openui-forge-phpThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Build generative UI apps with a React frontend + Laravel backend. Streams the OpenAI API's native SSE response straight through `response()->stream()`.
Build generative UI apps with a React frontend + Laravel backend. Streams the OpenAI API's native SSE response straight through response()->stream().
guzzlehttp/guzzle ships with Laravel and backs the Http facade (no extra dependency to call OpenAI)OPENAI_API_KEY environment variable setnpm install @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang lucide-react zod
npx @openuidev/cli generate ./src/lib/library.ts --out backend/storage/app/system-prompt.txt
php artisan install:api.php artisan serve on :8000, frontend on :3000composer.json (require block){
"require": {
"php": "^8.3",
"laravel/framework": "^13.0"
}
}
Laravel bundles
guzzlehttp/guzzle, so theHttpfacade can call OpenAI with no extra package. The OpenAI SSE passthrough below needs nothing beyond the framework.
routes/api.php<?php
use App\Http\Controllers\ChatController;
use Illuminate\Support\Facades\Route;
// `php artisan install:api` creates this file and prefixes it with /api,
// so this route is reachable at POST /api/chat.
Route::post('/chat', [ChatController::class, 'chat']);
app/Http/Controllers/ChatController.php<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ChatController extends Controller
{
// Loaded once per worker process, then reused across requests.
private static ?string $systemPrompt = null;
private function systemPrompt(): string
{
if (self::$systemPrompt === null) {
$path = storage_path('app/system-prompt.txt');
if (! is_file($path)) {
abort(500, 'system-prompt.txt not found at ' . $path);
}
self::$systemPrompt = (string) file_get_contents($path);
}
return self::$systemPrompt;
}
public function chat(Request $request): StreamedResponse
{
// Read config(), never env(), in a controller: after `php artisan
// config:cache` (standard in production) env() returns null outside
// config files. See the config/services.php block below.
$apiKey = config('services.openai.key');
if (! $apiKey) {
abort(500, 'OPENAI_API_KEY not set');
}
$incoming = $request->validate([
'messages' => 'required|array|min:1',
'messages.*.role' => 'required|string',
'messages.*.content' => 'required|string',
])['messages'];
// Prepend the system prompt; never trust a client-sent system message.
$messages = array_merge(
[['role' => 'system', 'content' => $this->systemPrompt()]],
array_map(
fn (array $m) => ['role' => $m['role'], 'content' => $m['content']],
$incoming,
),
);
$baseUrl = rtrim(config('services.openai.base_url'), '/');
$model = config('services.openai.model');
// stream => true returns the Guzzle PSR-7 response with its body still
// on the wire, so we read it chunk-by-chunk instead of buffering the
// whole completion in memory.
$upstream = Http::withToken($apiKey)
->withOptions(['stream' => true])
->acceptJson()
->post("{$baseUrl}/chat/completions", [
'model' => $model,
'stream' => true,
'messages' => $messages,
]);
if ($upstream->failed()) {
abort($upstream->status(), 'OpenAI request failed: ' . $upstream->body());
}
$body = $upstream->toPsrResponse()->getBody();
return response()->stream(function () use ($body): void {
// Forward OpenAI's SSE bytes verbatim. OpenAI already emits
// `data: {chunk}\n\n` frames plus a final `data: [DONE]`, which is
// exactly what openAIAdapter() parses, so no re-framing is needed.
while (! $body->eof()) {
$chunk = $body->read(8192);
if ($chunk === '') {
usleep(1000); // avoid a busy-wait if the stream momentarily has no data
continue;
}
echo $chunk;
if (ob_get_level() > 0) {
@ob_flush();
}
flush();
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no',
]);
}
}
config/services.php (add an openai entry)Read provider settings via config(), not env(), in the controller: after php artisan config:cache (standard in production) env() returns null outside config files. env() is only safe inside config files like this one.
<?php
return [
// ...existing services...
'openai' => [
'key' => env('OPENAI_API_KEY'),
'base_url' => env('OPENAI_BASE_URL', 'https://api.openai.com/v1'),
'model' => env('OPENAI_MODEL', 'gpt-5.5'),
],
];
config/cors.php (publish once with php artisan config:publish cors)<?php
return [
'paths' => ['api/*'],
'allowed_methods' => ['POST', 'OPTIONS'],
// Pin the frontend origin, not a wildcard.
'allowed_origins' => [env('FRONTEND_ORIGIN', 'http://localhost:3000')],
'allowed_headers' => ['Content-Type', 'Authorization'],
'exposed_headers' => [],
'max_age' => 0,
// Leave false unless you send cookies; with credentials a wildcard is illegal.
'supports_credentials' => false,
];
Laravel's built-in
HandleCorsmiddleware reads this config and answers theOPTIONSpreflight automatically, so the controller never hand-writesAccess-Control-*headers.
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:8000/api/chat"
/>
);
}
The Laravel backend forwards OpenAI's SSE stream verbatim through
response()->stream(), reading the Guzzle PSR-7 body in chunks and callingflush()after each one, so the client sees tokens as they arrive. Pair it withopenAIAdapter()on the frontend.openAIReadableStreamAdapter()is for NDJSON (nodata:prefix) and will silently produce no output here.Do not reach for Laravel's
response()->eventStream()here: it wraps each yield as a named SSE event and appends a</stream>sentinel, which rewrites the bytes and breaksopenAIAdapter(). Plainresponse()->stream()keeps OpenAI'sdata: {chunk}frames and the literaldata: [DONE]intact.An official-style OpenAI PHP client exists (
openai-php/client, requires PHP 8.2+) with acreateStreamed()helper as an alternative to calling the HTTP endpoint directly. This skill keeps the bundled Guzzle-backedHttpfacade as the dependency-free default. A pureext-curl+CURLOPT_WRITEFUNCTIONvariant (return the chunk's byte count from the callback,echo+flush()inside it) is shown intemplates/handler-php.php.template.
npx @openuidev/cli generate ./src/lib/library.ts --out backend/storage/app/system-prompt.txt
system-prompt.txt exists at storage/app/system-prompt.txt in the Laravel backendOPENAI_API_KEY is set in .env (and OPENAI_BASE_URL / OPENAI_MODEL if overriding)php artisan install:api has been run so POST /api/chat existsconfig/cors.php allowed_origins lists the frontend origin (not *)response()->stream())X-Accel-Buffering: no header is set so nginx/FastCGI does not buffer the streamapiUrl points to http://localhost:8000/api/chatstreamProtocol={openAIAdapter()} and openAIMessageFormatcomponentLibrary={openuiChatLibrary} prop passed to FullScreen@openuidev/react-ui/components.css)| Error | Cause | Fix |
|---|---|---|
| CORS blocked | Origin mismatch | Set allowed_origins in config/cors.php to the frontend origin |
404 on /api/chat | API routes not installed | Run php artisan install:api and confirm routes/api.php exists |
system-prompt.txt not found | File missing from storage/app | Run the CLI generate command into storage/app/system-prompt.txt |
500 OPENAI_API_KEY not set | Env var missing, or read via env() after config:cache | Set OPENAI_API_KEY in .env and read it through config('services.openai.key') (cache-safe) |
Upstream error surfaced via abort() | OpenAI key invalid or model wrong | Check OPENAI_API_KEY, OPENAI_MODEL, OPENAI_BASE_URL |
| Response arrives all at once (no streaming) | Output buffered by server | Keep X-Accel-Buffering: no; ensure flush() runs and no full-page output buffer wraps it |
| Empty response | Wrong adapter | Use openAIAdapter() (SSE), not openAIReadableStreamAdapter() (NDJSON) |
npx claudepluginhub othmanadi/openui-forgeCreates a generative UI app with a React frontend and Python FastAPI backend, streaming OpenAI-compatible NDJSON via OpenAI or Anthropic SDKs.
Livewire 3 reactive components - wire:model, actions, events, Volt, Folio. Use when building reactive UI without JavaScript.
Generative UI implementation patterns for AI SDK RSC including server-side streaming components, dynamic UI generation, and client-server coordination. Use when implementing generative UI, building AI SDK RSC, creating streaming components, or when user mentions generative UI, React Server Components, dynamic UI, AI-generated interfaces, or server-side streaming.