| name | openui-forge-csharp |
| description | OpenUI generative UI with C# ASP.NET Core Minimal API backend. Direct OpenAI API SSE streaming via HttpClient on .NET 10. |
| version | 1.2.0 |
| author | OthmanAdi |
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_KEY environment 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 run on :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, and System.Text.Json all ship in the .NET 10 shared framework referenced by Microsoft.NET.Sdk.Web.
Backend: backend/Program.cs
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("openai", client =>
{
client.Timeout = Timeout.InfiniteTimeSpan;
});
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();
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);
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");
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();
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";
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;
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)
{
}
finally
{
upstream.Dispose();
}
}
}
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 StreamReader loop, calling Response.Body.FlushAsync() after each line (no CopyToAsync buffering), 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.
HttpCompletionOption.ResponseHeadersRead is 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 (OpenAI on NuGet) as an alternative to hand-rolling the HTTP call. This skill keeps raw HttpClient as 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 (from System.Net.ServerSentEvents), which writes the data: framing for you when you yield strongly-typed SseItem<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
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 |