WYSIWYG editing Image gallery upload Content templates
AI Provider Settings (BYOK)

Pattern for a bring-your-own-key deployment: your app hosts an admin page where each tenant picks a provider and saves a key; the editor calls your backend, which reads the key from your secrets store and talks to the provider. In ASP.NET Core the idiomatic production path is to register your own IRichTextBoxAiResolver - the settings form on this page is the tenant-facing piece that feeds it. See also: Structured content demo for JSON / Markdown round-trip.

Demo disclaimer. This page stores the configured provider, model, and endpoint in localStorage for convenience, and keeps the entered key in memory only for this page's lifetime. Never put a real API key in browser storage in production - it's a credential leak. In a real deployment, your admin form posts the key to your server over HTTPS and stores it in a secrets manager (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault, or an encrypted DB column).
1 Admin configures Tenant admin picks a provider and pastes a key. Your form POSTs it to your server over HTTPS.
2 Server stores Your backend writes the key into your secrets manager, scoped to the tenant. Never returned to the browser.
3 Editor asks The editor's resolver POSTs to your /api/ai. Only the session cookie identifies the caller.
4 Backend relays Your endpoint looks up the tenant's key, calls the provider, and returns the result to the editor.

Production path: implement IRichTextBoxAiResolver

For a real deployment, skip the client-side resolver swap and register a server-side resolver in DI. The tenant's API key never reaches the browser - the resolver reads it from your secrets store, calls the provider, and returns the response.

// Program.cs
builder.Services.AddRichTextBox();
builder.Services.AddSingleton<ITenantAIConfigStore, TenantAIConfigStore>();
builder.Services.AddSingleton<IRichTextBoxAiResolver, ByokAiResolver>();
public sealed class ByokAiResolver : IRichTextBoxAiResolver
{
    private readonly ITenantAIConfigStore _secrets;
    private readonly IHttpContextAccessor _http;

    public ByokAiResolver(ITenantAIConfigStore secrets, IHttpContextAccessor http)
    {
        _secrets = secrets;
        _http = http;
    }

    public async ValueTask<RichTextBoxAiResponse> ResolveAsync(
        RichTextBoxAiRequest request,
        CancellationToken cancellationToken = default)
    {
        // Resolve the tenant from the current user/session.
        var tenantId = _http.HttpContext?.User?.FindFirst("tenant_id")?.Value;
        var cfg = await _secrets.GetAsync(tenantId!, cancellationToken);
        if (cfg is null)
        {
            return RichTextBoxAiResponseBuilder.FromOperations(
                "No AI provider configured for this tenant.",
                RichTextBoxAiResponseBuilder.PreviewSuggestion(request.DocumentText,
                    "Ask an admin to configure a provider in the tenant settings."));
        }

        // Call the provider with the tenant's key (never echoed back to the browser).
        var reply = await _providerRouter.CallAsync(cfg.Provider, cfg.Model, cfg.ApiKey,
            request.Mode, request.Source, request.Language, cancellationToken);

        return RichTextBoxAiResponseBuilder.FromOperations(
            reply.Explanation,
            RichTextBoxAiResponseBuilder.PreviewSuggestion(reply.Text, reply.Explanation));
    }
}

Minimal API sketch (no full resolver)

If you want the editor to hit your own endpoint instead of registering a resolver, swap the client-side resolver with aiToolkit.setResolver() and stand up an endpoint like this:

// Program.cs - add next to app.MapRichTextBoxUploads();
app.MapPost("/api/ai", async (HttpContext ctx, AIRequest body, ITenantAIConfigStore secrets) =>
{
    var tenantId = ctx.User.FindFirst("tenant_id")?.Value;
    var cfg = await secrets.GetAsync(tenantId!);
    if (cfg is null) return Results.BadRequest(new { error = "No provider configured." });

    var reply = await ProviderRouter.CallAsync(cfg.Provider, cfg.Model, cfg.ApiKey,
                                               body.Mode, body.Text, body.Language);

    return Results.Json(new { result = reply.Text, explanation = reply.Explanation });
});
Key takeaway

Keys belong in your secrets manager, not in browser storage. The editor UI (Ask AI dialog, Chat panel, Review drawer) is provider-agnostic - whatever your IRichTextBoxAiResolver returns drives every surface. The admin settings form above only lets tenants choose which resolver config to activate; the secret material itself never crosses the client boundary in a real deployment.