Openui forge csharp
OpenUI generative UI with C# ASP.NET Core Minimal API backend. Direct OpenAI API SSE streaming via HttpClient on .NET 10.From its SKILL.md
npx -y skills add OthmanAdi/openui-forge --skill openui-forge-csharpAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 20 stars20 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
11.2 KB, ~2.6k tokens by cl100k_base, as published. Nobody here has run it
OpenUI Forge — C#
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.
Activation Triggers
- "openui csharp", "openui c#", "openui dotnet", "openui aspnet"
- "generative ui csharp", "c# streaming ui backend", "asp.net core openui"
Prerequisites
- Node.js >= 22 (24 LTS recommended) + React >= 18.3.1 (19+ recommended) (frontend)
- .NET SDK 10.0 (backend; .NET 10 is the current LTS, supported until Nov 2028. .NET 8 LTS also works but reaches end of support Nov 2026.)
OPENAI_API_KEYenvironment 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:
npx @openuidev/cli generate ./src/lib/library.ts --out backend/system-prompt.txt
- Create the C# backend (see Full Code below)
- Run:
dotnet runon:5000, frontend on:3000
Full Code
Backend: backend/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: backend/Program.cs
using 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);
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: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.
System Prompt Generation
npx @openuidev/cli generate ./src/lib/library.ts --out backend/system-prompt.txt
Validation Checklist
-
system-prompt.txtexists in the C# backend directory (next to the.csproj) -
OPENAI_API_KEYis set in environment (.envis not auto-loaded; export it or usedotnet user-secrets) -
OPENAI_BASE_URLhonored for OpenAI-compatible providers (defaults tohttps://api.openai.com/v1) - CORS policy allows the frontend origin via
WithOrigins(FRONTEND_ORIGIN)(notAllowAnyOrigin) - Response streams SSE directly from OpenAI API (passthrough),
Content-Type: text/event-stream -
HttpCompletionOption.ResponseHeadersReadused so the body is not buffered -
Response.Body.FlushAsync()called after each line - Frontend
apiUrlpoints tohttp://localhost:5000/api/chat - Frontend uses
streamProtocol={openAIAdapter()}andopenAIMessageFormat -
componentLibrary={openuiChatLibrary}prop passed toFullScreen - CSS import in root layout (
@openuidev/react-ui/components.css)
Error Patterns
| 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 |
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.