From openui-forge
Builds generative UI apps with React frontend and C# ASP.NET Core backend, streaming OpenAI API responses via SSE on .NET 10.
How this skill is triggered — by the user, by Claude, or both
Slash command
/openui-forge:openui-forge-csharpThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Build generative UI apps with a React frontend + C# backend. Streams OpenAI API responses directly via an ASP.NET Core Minimal API (.NET 10 LTS) using `HttpClient`.
Build generative UI apps with a React frontend + C# backend. Streams OpenAI API responses directly via an ASP.NET Core Minimal API (.NET 10 LTS) using HttpClient.
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/system-prompt.txt
dotnet run on :5000, frontend on :3000backend/openui-backend.csproj<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
No NuGet packages required. ASP.NET Core,
HttpClient/IHttpClientFactory, andSystem.Text.Jsonall ship in the .NET 10 shared framework referenced byMicrosoft.NET.Sdk.Web.
backend/Program.csusing System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
// Pooled, correctly-disposed HttpClient instances. Infinite timeout because
// this is a long-lived streaming proxy; client disconnects cancel the request.
builder.Services.AddHttpClient("openai", client =>
{
client.Timeout = Timeout.InfiniteTimeSpan;
});
// CORS: lock to the configured frontend origin. Do NOT use AllowAnyOrigin —
// a wildcard would let any site call this backend and burn your API key.
var frontendOrigin =
Environment.GetEnvironmentVariable("FRONTEND_ORIGIN") ?? "http://localhost:3000";
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
policy.WithOrigins(frontendOrigin)
.WithMethods("POST", "OPTIONS")
.AllowAnyHeader());
});
var app = builder.Build();
app.UseCors();
// Load the generated system prompt ONCE at startup; fail fast if missing.
var promptPath = Path.Combine(Directory.GetCurrentDirectory(), "system-prompt.txt");
if (!File.Exists(promptPath))
{
throw new FileNotFoundException(
"system-prompt.txt not found. Generate it with: " +
"npx @openuidev/cli generate ./src/lib/library.ts --out system-prompt.txt",
promptPath);
}
var systemPrompt = await File.ReadAllTextAsync(promptPath);
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY");
var baseUrl = (Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? "https://api.openai.com/v1")
.TrimEnd('/');
var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5.5";
app.MapPost("/api/chat", async (ChatRequest req, IHttpClientFactory httpClientFactory, HttpContext ctx) =>
{
if (string.IsNullOrEmpty(apiKey))
return Results.Json(new { error = "OPENAI_API_KEY not set" }, statusCode: 500);
if (req.Messages is null || req.Messages.Length == 0)
return Results.Json(new { error = "messages must be a non-empty array" }, statusCode: 400);
// Prepend the server-side system prompt; never trust the client to supply it.
var messages = new List<ChatMessage> { new("system", systemPrompt) };
messages.AddRange(req.Messages);
var payload = JsonSerializer.Serialize(new OpenAiRequest(model, true, messages));
using var upstreamRequest = new HttpRequestMessage(
HttpMethod.Post, $"{baseUrl}/chat/completions")
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
upstreamRequest.Headers.Add("Authorization", $"Bearer {apiKey}");
var client = httpClientFactory.CreateClient("openai");
// ResponseHeadersRead returns as soon as headers arrive, so we read the
// body incrementally off the socket instead of buffering it into memory.
var upstream = await client.SendAsync(
upstreamRequest, HttpCompletionOption.ResponseHeadersRead, ctx.RequestAborted);
if (!upstream.IsSuccessStatusCode)
{
var errorBody = await upstream.Content.ReadAsStringAsync(ctx.RequestAborted);
upstream.Dispose();
return Results.Json(
new { error = $"OpenAI returned {(int)upstream.StatusCode}: {errorBody}" },
statusCode: (int)upstream.StatusCode);
}
return Results.Extensions.SseProxy(upstream);
});
app.Run();
// SSE passthrough: forward OpenAI's native `data: {chunk}\n\n` lines verbatim,
// flushing after each so tokens appear as they arrive. Pair with openAIAdapter().
//
// Idiomatic .NET 10 alternative: if you parse each chunk into a typed payload,
// return TypedResults.ServerSentEvents(IAsyncEnumerable<SseItem<T>>) from
// System.Net.ServerSentEvents — the framework writes the SSE framing for you.
// We keep the raw passthrough because the upstream is ALREADY valid SSE.
static class ResultExtensions
{
public static IResult SseProxy(this IResultExtensions _, HttpResponseMessage upstream)
=> new SseProxyResult(upstream);
}
sealed class SseProxyResult(HttpResponseMessage upstream) : IResult
{
public async Task ExecuteAsync(HttpContext httpContext)
{
var response = httpContext.Response;
response.StatusCode = 200;
response.ContentType = "text/event-stream";
response.Headers.CacheControl = "no-cache";
response.Headers.Connection = "keep-alive";
response.Headers["X-Accel-Buffering"] = "no"; // defeat proxy buffering
try
{
await using var upstreamStream =
await upstream.Content.ReadAsStreamAsync(httpContext.RequestAborted);
using var reader = new StreamReader(upstreamStream, Encoding.UTF8);
while (await reader.ReadLineAsync(httpContext.RequestAborted) is { } line)
{
if (line.Length == 0) continue; // re-emit our own framing below
await response.WriteAsync(line + "\n", httpContext.RequestAborted);
if (line.StartsWith("data:", StringComparison.Ordinal))
await response.WriteAsync("\n", httpContext.RequestAborted);
await response.Body.FlushAsync(httpContext.RequestAborted);
if (line == "data: [DONE]") break;
}
}
catch (OperationCanceledException)
{
// Client aborted — nothing to do.
}
finally
{
upstream.Dispose();
}
}
}
// JsonPropertyName pins wire names to lowercase: incoming binding is
// case-insensitive, but Serialize defaults to PascalCase and OpenAI needs
// lowercase role/content/model.
record ChatMessage(
[property: JsonPropertyName("role")] string Role,
[property: JsonPropertyName("content")] string Content);
record ChatRequest(
[property: JsonPropertyName("messages")] ChatMessage[] Messages);
record OpenAiRequest(
[property: JsonPropertyName("model")] string Model,
[property: JsonPropertyName("stream")] bool Stream,
[property: JsonPropertyName("messages")] List<ChatMessage> Messages);
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:5000/api/chat"
/>
);
}
The C# backend forwards OpenAI's SSE stream line-by-line with a
StreamReaderloop, callingResponse.Body.FlushAsync()after each line (noCopyToAsyncbuffering), 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.
HttpCompletionOption.ResponseHeadersReadis the key to incremental streaming: it returns control once the upstream headers arrive instead of buffering the whole response body into memory first.An official OpenAI .NET SDK exists (
OpenAIon NuGet) as an alternative to hand-rolling the HTTP call. This skill keeps rawHttpClientas the dependency-free default (everything used here ships in the .NET 10 shared framework). .NET 10 also added a native typed SSE result,TypedResults.ServerSentEvents(fromSystem.Net.ServerSentEvents), which writes thedata:framing for you when you yield strongly-typedSseItem<T>values — use it if you want to parse and reshape chunks rather than pass them through.
npx @openuidev/cli generate ./src/lib/library.ts --out backend/system-prompt.txt
system-prompt.txt exists in the C# backend directory (next to the .csproj)OPENAI_API_KEY is set in environment (.env is not auto-loaded; export it or use dotnet user-secrets)OPENAI_BASE_URL honored for OpenAI-compatible providers (defaults to https://api.openai.com/v1)WithOrigins(FRONTEND_ORIGIN) (not AllowAnyOrigin)Content-Type: text/event-streamHttpCompletionOption.ResponseHeadersRead used so the body is not bufferedResponse.Body.FlushAsync() called after each lineapiUrl points to http://localhost:5000/api/chatstreamProtocol={openAIAdapter()} and openAIMessageFormatcomponentLibrary={openuiChatLibrary} prop passed to FullScreen@openuidev/react-ui/components.css)| Error | Cause | Fix |
|---|---|---|
| CORS blocked | Origin mismatch | Set FRONTEND_ORIGIN to match, ensure app.UseCors() runs before the endpoint |
FileNotFoundException: system-prompt.txt | File missing from backend dir | Run the CLI generate command into the project directory |
500 OPENAI_API_KEY not set | Env var missing | Export OPENAI_API_KEY (or set it in launchSettings.json for local dev) |
| Upstream status forwarded (e.g. 401/429) | Key invalid or rate limited | Check OPENAI_API_KEY, model name, and provider quota |
| Empty response / components show as raw text | Frontend adapter mismatch | Backend emits SSE — use openAIAdapter(), not openAIReadableStreamAdapter() |
| Stream arrives all at once at the end | Buffering somewhere | Confirm ResponseHeadersRead + per-line FlushAsync(); keep X-Accel-Buffering: no |
role/content rejected by API | PascalCase JSON | Keep the [JsonPropertyName(...)] attributes on the DTO records |
npx claudepluginhub othmanadi/openui-forgeProvides 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().
Builds C# applications with .NET 8+, ASP.NET Core APIs, Blazor, and Entity Framework Core. Implements async patterns, CQRS with MediatR, and minimal API routing.
Builds C# applications with .NET 8+, ASP.NET Core APIs, and Blazor. Configures EF Core, implements async/CQRS patterns, and optimizes performance.