Skip to content
Shiny.NET

AI Conversations | Getting Started

Shiny.AiConversation builds conversations with an AI on top of Microsoft.Extensions.AI. You start a session, send it input, and get back a turn: the reply streams in as it is generated, tool calls run, and when the AI needs something from the user it asks a typed question instead of leaving you to guess from the wording.

Sessions are persisted and never end. Like a chat window, a session keeps growing and can be picked up again at any time, even after the app restarts. The library tracks what each session costs (token usage) and how full the model’s context window is, so your UI can warn the user before a long conversation gets too big.

GitHub GitHub stars for shinyorg/speech
Downloads NuGet downloads for Shiny.AiConversation
Frameworks
.NET
.NET MAUI
Operating Systems
Android
iOS
Windows
Web
Feature Description
Sessions IAiSessionManager starts, loads, queries and deletes sessions. Every load reads the store and returns a fresh instance
Streaming replies stream as TextDelta events while the model is still generating
Typed questions the built-in ask_user tool gives the session PendingQuestions, with optional multiple-choice answers
One active turn sending while a turn is running supersedes it, and an interrupted reply is stored as what the user actually heard
Persistence the user message is stored before the model is called and the reply is stored as it streams, so a failure or crash still leaves an honest record
Usage & context cumulative SessionUsage, plus a ContextSnapshot with ContextPressure (Normal / Warning / Critical)
Compaction older messages fold into a rolling summary, manually or automatically as a safety net. See Context & Compaction
Assistant memory a silent memory tool for private notes across conversations, with full user control. See Assistant Memory
Conversation search optional tools to search past conversations, plus session titles and long-term memory. See AI Tools
SQLite store Shiny.AiConversation.Stores.SqliteDocDb persists sessions, messages and memory with full-text search
Voice Shiny.AiConversation.Voice adds push-to-talk, wake word, follow-up answers, interruption and sentence-streamed text-to-speech. See Voice
Interceptors ISessionInterceptor adds tools and system prompts per session or per turn, optionally scoped to a session Profile
One event stream session.GetEvents() is an IAsyncEnumerable that starts with a Snapshot. No Rx, no C# events
Pluggable providers resolves IChatClient from DI by default; implement IChatClientProvider for custom auth
AOT compatible source-generated JSON for every stored type, IsAotCompatible enabled
IAiSessionManager ── Start / Get ──► IAiSession ── Send ──► IAiTurn
│ │ ▲
▼ │ └── ISessionInterceptor (tools, prompts)
IAiSessionStore ◄──────────────────────┘
IChatClientProvider ──► IChatClient (Microsoft.Extensions.AI)

See Architecture for the details.

Changed files
  • MyApp/
Platforms/Android/AndroidManifest.xml
AndroidManifest.xml
1<?xml version="1.0" encoding="utf-8"?>2<manifest xmlns:android="http://schemas.android.com/apk/res/android">3	<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true">4	</application>5	<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>6	<uses-permission android:name="android.permission.BATTERY_STATS" />7	<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />8	<uses-permission android:name="android.permission.INTERNET" />9	<uses-permission android:name="android.permission.RECORD_AUDIO" />10	<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />11</manifest>
  1. Install the NuGet package

    NuGet package Shiny.AiConversation
    Terminal window
    dotnet add package Shiny.AiConversation
  2. Register a chat client

    The simplest approach is to register an IChatClient in DI. The library resolves it automatically:

    builder.Services.AddChatClient(new OpenAIClient("your-api-key").GetChatClient("gpt-4o").AsIChatClient());

    Or use one of the provider packages:

    Package Description
    Shiny.AiConversation.OpenAi Static OpenAI-compatible client (OpenAI, Azure, Ollama)
    Shiny.AiConversation.GithubCopilot GitHub Copilot from a personal access token
    Shiny.AiConversation.Maui.GithubCopilot GitHub Copilot with device code auth (MAUI only)

    On .NET MAUI, add the UI packages as needed:

    Package Description
    Shiny.AiConversation.Maui AiChatView bound to a session, push-to-talk, choice chips, AiMemoryView (references Shiny.AiConversation.Voice)
    Shiny.AiConversation.Maui.Camera the use_camera tool over Shiny’s CameraView modal

    For other backends, implement IChatClientProvider. See Chat Client Provider.

  3. Register AI Conversation

    builder.Services.AddShinyAiConversation(opts =>
    {
    // all optional
    opts.AddStaticOpenAIChatClient("your-api-key", "https://api.openai.com/v1", "gpt-4o");
    opts.SetSqliteDocDbStores("ai.db"); // default: in-memory (Shiny.AiConversation.Stores.SqliteDocDb)
    opts.AddInterceptor<MyInterceptor>(); // tools + system prompts
    opts.AddTool(AIFunctionFactory.Create(GetWeather, "get_weather"));
    opts.AddTool(cookingTimer, AiToolFlags.None, "cooking"); // only for "cooking" sessions
    opts.AddSessionTitles();
    opts.AddConversationSearch();
    });
  4. Start a session and send to it

    public class ChatViewModel(IAiSessionManager sessions)
    {
    public async Task Send(string message)
    {
    await using var session = await sessions.GetLatest() ?? await sessions.Start();
    var turn = session.Send(AiInput.Text(message));
    await foreach (var e in turn.Events)
    {
    if (e is TextDelta delta)
    Console.Write(delta.Text);
    }
    var result = await turn.Completion;
    Console.WriteLine($"\n{result.Outcome}: {result.Reply?.Text}");
    }
    }
// new session, optionally with persisted settings
var session = await sessions.Start(new AiSessionSettings
{
Profile = "cooking", // interceptors use this to pick tools and prompts
ContextWindow = 128_000, // falls back to IChatClientProvider.ContextWindow
Tags = ["kitchen"]
});
// continue where the user left off
var latest = await sessions.GetLatest();
// load a specific one (a new instance every call; you own it and dispose it)
var again = await sessions.Get(session.SessionId);
// list headers without loading them, most recently active first
var recent = await sessions.Query(new SessionQuery(Text: "recipe", Take: 20));
// remove a session and all of its messages
await sessions.Delete(session.SessionId);

The manager is stateless. It keeps no sessions in memory, so Get always returns a new instance rehydrated from the store. Disposing an instance releases that instance only; the session stays in the store.

A session can also be renamed or reconfigured:

await session.SetTitle("Weeknight dinners");
await session.UpdateSettings(s => s with { ContextWindow = 200_000 });

Send returns an IAiTurn immediately. A session has at most one active turn: sending again supersedes the running one.

Member Description
Events the turn’s events in order. Enumerating again replays them from the start
Completion an AiTurnResult with Outcome, the stored Reply, Questions, Usage and Error. Never faults
Interrupt(spokenText) stops the turn because the user interrupted. Pass how far text-to-speech got so the model is told what was actually heard

Inputs come from AiInput.Text(...), AiInput.Voice(...) (asks for spoken-style replies and at most one question) and AiInput.Answer(question, choiceIds). WithInstructions(...) adds system instructions for that turn only; they are sent to the model but never stored:

session.Send(AiInput.Text("Summarize our chat").WithInstructions("Answer in one sentence."));

Turn events: TurnStarted, TextDelta, ToolCallStarted, ToolCallCompleted, QuestionsAsked, then one of TurnCompleted, TurnInterrupted or TurnFailed.

AiTurnOutcome is Completed, AwaitingUser, Interrupted, Superseded or Failed. Interruption and cancellation are normal outcomes, not errors.

When the AI needs something from the user it calls the built-in ask_user tool. The turn ends with AwaitingUser, and the session’s PendingQuestions holds the questions:

var result = await session.Send(AiInput.Text("Order me a coffee")).Completion;
if (result.Outcome == AiTurnOutcome.AwaitingUser)
{
var question = session.PendingQuestions[0]; // "What size?" with choices Small / Large
if (question.HasChoices)
await session.Send(AiInput.Answer(question, "large")).Completion;
else
await session.Send(AiInput.Text("A large one")).Completion;
}

See Questions.

GetEvents() is a hot broadcast. Each call gets its own subscription, and the first item is always a Snapshot (state, pending questions, usage and context), so a page that subscribes late still knows where things are.

await foreach (var e in session.GetEvents(cancellationToken))
{
switch (e)
{
case Snapshot s: Render(s.State, s.PendingQuestions); break;
case MessageAdded m: AddBubble(m.Message); break;
case MessageUpdated m: UpdateBubble(m.Message); break;
case TurnEventOccurred { Event: TextDelta d }: AppendToStreamingBubble(d.Text); break;
case StateChanged s: ShowTyping(s.State is AiSessionState.Thinking or AiSessionState.Responding); break;
case ContextPressureChanged c: WarnIfLong(c.Context.Pressure); break;
case SessionError err: ShowError(err.Error); break;
}
}

The subscription ends when the token is cancelled or the session instance is disposed. A slow subscriber never stalls the conversation: when its queue fills, streamed text deltas are dropped first.

AiSessionState is Idle, Thinking, Responding, AwaitingUser or Compacting.

var page = await session.GetMessages(beforeSequence: null, count: 30); // newest page
if (page.HasMore)
page = await session.GetMessages(page.Messages[0].Sequence, 30); // older

Each AiMessageDocument carries its Role, Source (typed, voice, choice answer, model, …), Status (Complete, Streaming, Interrupted, Failed), ordered Parts and a plain Text for display.

Two different numbers:

session.Usage (SessionUsage) session.Context (ContextSnapshot)
Meaning everything billed in this session how full the context window is right now
Fields InputTokens, OutputTokens, CachedInputTokens, ReasoningTokens, TotalTokens, RequestCount CurrentTokens, MaxTokens, PercentUsed, Pressure, IsEstimate, MeasuredAt
Updated after every model request, including each tool-loop round trip after every turn

CurrentTokens is the input plus output of the last request in the turn, which is the true size of what the provider last saw. When the provider reports no usage, it is estimated and IsEstimate is true.

Pressure is Normal, Warning (75% by default) or Critical (90% by default), set with AiSessionSettings.ContextWarningThreshold / ContextCriticalThreshold. It is null when the context window is unknown. The library shows nothing; your app decides whether to warn the user, call session.Compact() or offer to start a new session. See Context & Compaction.

ISessionInterceptor replaces IContextProvider. Tools are never persisted, so interceptors run on every load and rebuild them:

public class CookingInterceptor(IRecipeService recipes) : ISessionInterceptor
{
public Task OnSessionLoaded(SessionLoadedContext ctx)
{
if (ctx.Session.Settings.Profile == "cooking")
{
ctx.SystemPrompts.Add("You are a friendly cooking assistant.");
ctx.AddTool(AIFunctionFactory.Create(recipes.Find, "find_recipe"));
}
return Task.CompletedTask;
}
public async Task OnBeforeTurn(TurnContext ctx)
{
// per-turn context (RAG, location, ...) applies to this turn only
ctx.SystemPrompts.Add(await recipes.GetPantrySummary());
}
public Task OnAfterTurn(TurnContext ctx, AiTurnResult result) => Task.CompletedTask;
}

A built-in interceptor adds every tool registered with opts.AddTool(...) (and any AITool in DI) plus the current time to each turn. An exception in an interceptor is logged and never fails the turn.

Package Purpose
Microsoft.Extensions.AI IChatClient abstraction for AI chat completions
  • Sample MAUI App — one shared session across a chat page (AiChatView), a voice page and settings, with GitHub Copilot, SQLite persistence, memory and the camera tool
  • Sample Blazor WASM App — Blazor WebAssembly app on v3 sessions and voice with a static OpenAI provider
claude plugin marketplace add shinyorg/skills
claude plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

View shiny-aiconversation Skill