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 | |
| Downloads |
Features
Section titled “Features”| 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 |
Architecture
Section titled “Architecture”IAiSessionManager ── Start / Get ──► IAiSession ── Send ──► IAiTurn │ │ ▲ ▼ │ └── ISessionInterceptor (tools, prompts)IAiSessionStore ◄──────────────────────┘IChatClientProvider ──► IChatClient (Microsoft.Extensions.AI)See Architecture for the details.
- MyApp/
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>-
Install the NuGet package
Terminal window dotnet add package Shiny.AiConversation -
Register a chat client
The simplest approach is to register an
IChatClientin 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.OpenAiStatic OpenAI-compatible client (OpenAI, Azure, Ollama) Shiny.AiConversation.GithubCopilotGitHub Copilot from a personal access token Shiny.AiConversation.Maui.GithubCopilotGitHub Copilot with device code auth (MAUI only) On .NET MAUI, add the UI packages as needed:
Package Description Shiny.AiConversation.MauiAiChatViewbound to a session, push-to-talk, choice chips,AiMemoryView(referencesShiny.AiConversation.Voice)Shiny.AiConversation.Maui.Camerathe use_cameratool over Shiny’s CameraView modalFor other backends, implement
IChatClientProvider. See Chat Client Provider. -
Register AI Conversation
builder.Services.AddShinyAiConversation(opts =>{// all optionalopts.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 promptsopts.AddTool(AIFunctionFactory.Create(GetWeather, "get_weather"));opts.AddTool(cookingTimer, AiToolFlags.None, "cooking"); // only for "cooking" sessionsopts.AddSessionTitles();opts.AddConversationSearch();}); -
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}");}}
Sessions
Section titled “Sessions”// new session, optionally with persisted settingsvar 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 offvar 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 firstvar recent = await sessions.Query(new SessionQuery(Text: "recipe", Take: 20));
// remove a session and all of its messagesawait 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.
Questions and answers
Section titled “Questions and answers”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.
Session events
Section titled “Session events”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.
History
Section titled “History”var page = await session.GetMessages(beforeSequence: null, count: 30); // newest pageif (page.HasMore) page = await session.GetMessages(page.Messages[0].Sequence, 30); // olderEach AiMessageDocument carries its Role, Source (typed, voice, choice answer, model, …), Status (Complete, Streaming, Interrupted, Failed), ordered Parts and a plain Text for display.
Usage and context
Section titled “Usage and context”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.
Interceptors
Section titled “Interceptors”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.
Dependencies
Section titled “Dependencies”| Package | Purpose |
|---|---|
Microsoft.Extensions.AI |
IChatClient abstraction for AI chat completions |
Samples
Section titled “Samples”- 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
AI Coding Assistant
Section titled “AI Coding Assistant”Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install shiny@shinyOne 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.
Step 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install shiny@shinyOne 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.
Next Steps
Section titled “Next Steps”- Architecture — sessions, turns, persistence and recovery
- Questions — the
ask_usertool and pending questions - Chat Client Provider — implement
IChatClientProviderfor other AI backends - Session Store — persist sessions with
IAiSessionStoreor the SQLite store - Context & Compaction — context pressure and rolling summaries
- Assistant Memory — the assistant’s private notes, and how users control them
- AI Tools — your tools, conversation search, titles and long-term memory
- Voice — talk to a session: push-to-talk, wake word, interruption, streamed speech
- Acknowledgements & Sound — how voice replies are delivered
- MAUI Chat UI —
AiChatViewandAiMemoryView - Images & Attachments — sending images and how long the model sees them
- Camera Tool — let the assistant ask to use the camera


