Cloud AI APIs are powerful but come with trade-offs: latency, per-request cost, privacy concerns, and offline limitations. For many mobile use cases — a writing assistant, a personal knowledge base, a local code helper — an on-device model running via Ollama is a better fit.
This guide builds a complete .NET MAUI AI chat app that runs entirely on-device using Microsoft.Extensions.AI and Ollama. The same code works with cloud providers when you need them — swap one line in the DI registration.
Table of Contents
- App Architecture Overview
- Project Setup
- Configuring DI and IChatClient
- Chat Data Models
- Chat ViewModel with Streaming
- Chat UI in XAML
- Conversation Management
- Settings Screen: Model and Provider Switching
- Persisting Chat History
- Switching Between Local and Cloud
- FAQ
- Conclusion
App Architecture Overview
The app follows MVVM with CommunityToolkit.Mvvm and uses Microsoft.Extensions.AI’s IChatClient as the AI abstraction. The architecture:
- UI Layer — XAML pages with compiled bindings
- ViewModel Layer — CommunityToolkit.Mvvm with
[ObservableProperty]and[RelayCommand] - Service Layer —
IChatClient(provider-agnostic),IConversationStore(SQLite persistence) - Infrastructure — Ollama for local models, optionally swappable to OpenAI/Azure
The key design decision: the ViewModel never references Ollama or OpenAI directly. Everything goes through IChatClient — switching providers requires zero ViewModel changes.
Project Setup
# Create the project
dotnet new maui -n MauiAiChat
cd MauiAiChat
# Add dependencies
dotnet add package Microsoft.Extensions.AI
dotnet add package Microsoft.Extensions.AI.Ollama
dotnet add package Microsoft.Extensions.AI.OpenAI # For cloud fallback
dotnet add package CommunityToolkit.Mvvm
dotnet add package sqlite-net-pcl # Chat history persistence
dotnet add package SQLitePCLRaw.bundle_green
Setting Up Ollama (Local Dev)
Install Ollama from ollama.com, then pull a small model suitable for mobile/chat:
# Pull a lightweight model (good for development)
ollama pull phi3.5 # 3.8B params, excellent for chat
ollama pull llama3.2:3b # 3B params, fast responses
ollama pull gemma2:2b # 2B params, good reasoning
# Start Ollama server
ollama serve # Runs on http://localhost:11434
For on-device deployment on Android/iOS, Ollama runs on the backend server your app connects to over local network. True on-device inference (ONNX Runtime Mobile) is a separate approach — this guide uses Ollama on a local server, which covers the offline + no-cloud-cost use case for development and internal tools.
Configuring DI and IChatClient
// MauiProgram.cs
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMaui();
// Register AI services
RegisterAiServices(builder.Services);
// Register app services
builder.Services.AddSingleton<IConversationStore, SqliteConversationStore>();
builder.Services.AddSingleton<IAiSettingsService, AiSettingsService>();
// Register ViewModels
builder.Services.AddTransient<ChatViewModel>();
builder.Services.AddTransient<SettingsViewModel>();
// Register Pages
builder.Services.AddTransient<ChatPage>();
builder.Services.AddTransient<SettingsPage>();
return builder.Build();
}
private static void RegisterAiServices(IServiceCollection services)
{
services.AddSingleton<IChatClient>(sp =>
{
var settings = sp.GetRequiredService<IAiSettingsService>();
return settings.Provider switch
{
AiProvider.Ollama => new OllamaChatClient(
new Uri(settings.OllamaEndpoint),
settings.SelectedModel),
AiProvider.OpenAI => new OpenAIClient(
new ApiKeyCredential(settings.OpenAiApiKey))
.AsChatClient(settings.SelectedModel),
_ => new OllamaChatClient(
new Uri("http://localhost:11434"),
"phi3.5")
};
});
}
}
Chat Data Models
// Models/ChatMessageItem.cs — UI display model
public partial class ChatMessageItem : ObservableObject
{
public Guid Id { get; init; } = Guid.NewGuid();
[ObservableProperty]
private string _text = string.Empty;
[ObservableProperty]
private bool _isStreaming;
public bool IsUser { get; init; }
public DateTime Timestamp { get; init; } = DateTime.Now;
// Computed for UI bubble alignment
public LayoutOptions HorizontalAlignment =>
IsUser ? LayoutOptions.End : LayoutOptions.Start;
public Color BubbleColor =>
IsUser
? Color.FromArgb("#6200EE")
: Color.FromArgb("#F0F0F0");
public Color TextColor =>
IsUser ? Colors.White : Colors.Black;
}
// Models/Conversation.cs — persistence model
public class Conversation
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
public string Title { get; set; } = "New Conversation";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public string ModelUsed { get; set; } = string.Empty;
}
// Models/StoredMessage.cs
public class StoredMessage
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
public int ConversationId { get; set; }
public string Role { get; set; } = string.Empty; // "user" or "assistant"
public string Content { get; set; } = string.Empty;
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
Chat ViewModel with Streaming
// ViewModels/ChatViewModel.cs
public partial class ChatViewModel : ObservableObject
{
private readonly IChatClient _chatClient;
private readonly IConversationStore _store;
private readonly List<ChatMessage> _meaiHistory = new();
private CancellationTokenSource? _streamingCts;
public ChatViewModel(IChatClient chatClient, IConversationStore store)
{
_chatClient = chatClient;
_store = store;
InitializeSystemPrompt();
}
[ObservableProperty]
private ObservableCollection<ChatMessageItem> _messages = new();
[ObservableProperty]
private string _userInput = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SendCommand))]
private bool _isResponding;
[ObservableProperty]
private string _modelName = "phi3.5";
[ObservableProperty]
private string _statusText = "Ready";
private void InitializeSystemPrompt()
{
_meaiHistory.Add(new ChatMessage(ChatRole.System,
"You are a helpful, concise AI assistant. " +
"Keep responses focused and under 300 words unless detail is specifically requested. " +
"Format code with proper markdown code blocks."));
}
[RelayCommand(CanExecute = nameof(CanSend))]
private async Task SendAsync()
{
if (string.IsNullOrWhiteSpace(UserInput)) return;
var userText = UserInput.Trim();
UserInput = string.Empty;
IsResponding = true;
StatusText = "Thinking...";
// Add user message to UI
var userMessage = new ChatMessageItem { Text = userText, IsUser = true };
Messages.Add(userMessage);
// Add to MEAI history
_meaiHistory.Add(new ChatMessage(ChatRole.User, userText));
// Placeholder for AI response
var aiMessage = new ChatMessageItem
{
Text = "",
IsUser = false,
IsStreaming = true
};
Messages.Add(aiMessage);
_streamingCts = new CancellationTokenSource();
try
{
await StreamResponseAsync(aiMessage, _streamingCts.Token);
// Save to conversation history
_meaiHistory.Add(new ChatMessage(ChatRole.Assistant, aiMessage.Text));
await _store.SaveMessagesAsync(userText, aiMessage.Text);
StatusText = "Ready";
}
catch (OperationCanceledException)
{
aiMessage.Text += " [stopped]";
StatusText = "Stopped";
}
catch (Exception ex)
{
aiMessage.Text = $"Error: {ex.Message}";
StatusText = "Error";
}
finally
{
aiMessage.IsStreaming = false;
IsResponding = false;
_streamingCts?.Dispose();
_streamingCts = null;
}
}
private bool CanSend() => !IsResponding && !string.IsNullOrWhiteSpace(UserInput);
private async Task StreamResponseAsync(ChatMessageItem target, CancellationToken ct)
{
var sb = new System.Text.StringBuilder();
await foreach (var update in _chatClient.CompleteStreamingAsync(
_meaiHistory,
cancellationToken: ct))
{
if (string.IsNullOrEmpty(update.Text)) continue;
sb.Append(update.Text);
// Update UI on main thread
var currentText = sb.ToString();
await MainThread.InvokeOnMainThreadAsync(() =>
{
target.Text = currentText;
});
}
}
[RelayCommand]
private void StopGeneration()
{
_streamingCts?.Cancel();
}
[RelayCommand]
private void ClearConversation()
{
Messages.Clear();
_meaiHistory.RemoveAll(m => m.Role != ChatRole.System);
StatusText = "Conversation cleared";
}
}
Chat UI in XAML
<?xml version="1.0" encoding="utf-8"?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:MauiAiChat.ViewModels"
xmlns:models="clr-namespace:MauiAiChat.Models"
x:Class="MauiAiChat.Views.ChatPage"
x:DataType="vm:ChatViewModel"
Title="{Binding ModelName}"
Shell.NavBarIsVisible="True">
<Shell.TitleView>
<HorizontalStackLayout Spacing="8" VerticalOptions="Center">
<Ellipse WidthRequest="10" HeightRequest="10"
Fill="#4CAF50" VerticalOptions="Center" />
<Label Text="{Binding ModelName}"
FontAttributes="Bold" VerticalOptions="Center" />
<Label Text="{Binding StatusText}"
TextColor="#666" FontSize="12" VerticalOptions="Center" />
</HorizontalStackLayout>
</Shell.TitleView>
<Grid RowDefinitions="*,Auto" Padding="0,8,0,0">
<!-- Messages List -->
<CollectionView Grid.Row="0"
ItemsSource="{Binding Messages}"
ItemsUpdatingScrollMode="KeepLastItemInView"
Margin="12,0">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:ChatMessageItem">
<Grid Margin="0,4">
<Border StrokeShape="RoundRectangle 16"
BackgroundColor="{Binding BubbleColor}"
Padding="14,10"
MaximumWidthRequest="290"
HorizontalOptions="{Binding HorizontalAlignment}"
Stroke="Transparent">
<VerticalStackLayout Spacing="4">
<Label Text="{Binding Text}"
TextColor="{Binding TextColor}"
LineBreakMode="WordWrap" />
<!-- Typing indicator -->
<HorizontalStackLayout IsVisible="{Binding IsStreaming}"
Spacing="4">
<Ellipse WidthRequest="6" HeightRequest="6"
Fill="#999" />
<Ellipse WidthRequest="6" HeightRequest="6"
Fill="#999" />
<Ellipse WidthRequest="6" HeightRequest="6"
Fill="#999" />
</HorizontalStackLayout>
</VerticalStackLayout>
</Border>
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
<CollectionView.EmptyView>
<VerticalStackLayout HorizontalOptions="Center"
VerticalOptions="Center"
Spacing="12" Opacity="0.5">
<Label Text="💬" FontSize="48" HorizontalOptions="Center" />
<Label Text="Start a conversation"
FontSize="16" HorizontalOptions="Center" />
</VerticalStackLayout>
</CollectionView.EmptyView>
</CollectionView>
<!-- Input Row -->
<Grid Grid.Row="1"
ColumnDefinitions="*,Auto,Auto"
Padding="12,8"
ColumnSpacing="8"
BackgroundColor="{AppThemeBinding Light=White, Dark=#1E1E1E}">
<Border Grid.Column="0"
StrokeShape="RoundRectangle 24"
Stroke="#E0E0E0"
Padding="16,4">
<Editor Text="{Binding UserInput}"
Placeholder="Message..."
AutoSize="TextChanges"
MaximumHeightRequest="120"
ReturnType="Send"
BackgroundColor="Transparent" />
</Border>
<!-- Stop button (visible while responding) -->
<Button Grid.Column="1"
Text="⏹"
Command="{Binding StopGenerationCommand}"
IsVisible="{Binding IsResponding}"
BackgroundColor="Transparent"
TextColor="#F44336"
FontSize="20"
WidthRequest="44" HeightRequest="44" />
<!-- Send button -->
<Button Grid.Column="2"
Text="↑"
Command="{Binding SendCommand}"
IsVisible="{Binding IsResponding, Converter={StaticResource InvertBool}}"
BackgroundColor="#6200EE"
TextColor="White"
CornerRadius="22"
FontSize="18"
FontAttributes="Bold"
WidthRequest="44" HeightRequest="44" />
</Grid>
</Grid>
</ContentPage>
Conversation Management
// Services/IConversationStore.cs
public interface IConversationStore
{
Task SaveMessagesAsync(string userMessage, string aiResponse);
Task<List<StoredMessage>> GetHistoryAsync(int conversationId, int limit = 50);
Task<List<Conversation>> GetConversationsAsync();
Task DeleteConversationAsync(int conversationId);
}
// Services/SqliteConversationStore.cs
public class SqliteConversationStore : IConversationStore
{
private readonly SQLiteAsyncConnection _db;
private int _currentConversationId;
public SqliteConversationStore()
{
var dbPath = Path.Combine(
FileSystem.AppDataDirectory,
"conversations.db");
_db = new SQLiteAsyncConnection(dbPath);
InitializeAsync().GetAwaiter().GetResult();
}
private async Task InitializeAsync()
{
await _db.CreateTableAsync<Conversation>();
await _db.CreateTableAsync<StoredMessage>();
}
public async Task SaveMessagesAsync(string userMessage, string aiResponse)
{
if (_currentConversationId == 0)
{
var convo = new Conversation
{
Title = userMessage.Length > 40
? userMessage[..40] + "..."
: userMessage,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
await _db.InsertAsync(convo);
_currentConversationId = convo.Id;
}
await _db.InsertAllAsync(new[]
{
new StoredMessage
{
ConversationId = _currentConversationId,
Role = "user",
Content = userMessage
},
new StoredMessage
{
ConversationId = _currentConversationId,
Role = "assistant",
Content = aiResponse
}
});
}
}
Settings Screen: Model and Provider Switching
public partial class SettingsViewModel : ObservableObject
{
private readonly IAiSettingsService _settings;
public SettingsViewModel(IAiSettingsService settings)
{
_settings = settings;
LoadSettings();
}
[ObservableProperty]
private ObservableCollection<string> _availableModels = new()
{
"phi3.5", "llama3.2:3b", "gemma2:2b", "mistral:7b"
};
[ObservableProperty]
private string _selectedModel = "phi3.5";
[ObservableProperty]
private string _ollamaEndpoint = "http://localhost:11434";
[ObservableProperty]
private string _systemPrompt = "You are a helpful AI assistant.";
[ObservableProperty]
private bool _useCloudFallback;
[RelayCommand]
private async Task SaveSettingsAsync()
{
await _settings.SaveAsync(new AiSettings
{
SelectedModel = SelectedModel,
OllamaEndpoint = OllamaEndpoint,
SystemPrompt = SystemPrompt,
UseCloudFallback = UseCloudFallback
});
await Shell.Current.GoToAsync("..");
}
}
Persisting Chat History
// Restore conversation history on app resume
public async Task LoadConversationAsync(int conversationId)
{
var stored = await _store.GetHistoryAsync(conversationId);
Messages.Clear();
_meaiHistory.RemoveAll(m => m.Role != ChatRole.System);
foreach (var msg in stored)
{
Messages.Add(new ChatMessageItem
{
Text = msg.Content,
IsUser = msg.Role == "user",
Timestamp = msg.Timestamp
});
_meaiHistory.Add(new ChatMessage(
msg.Role == "user" ? ChatRole.User : ChatRole.Assistant,
msg.Content));
}
}
Switching Between Local and Cloud
// Factory pattern for runtime provider switching
public class DynamicChatClientFactory
{
private readonly IAiSettingsService _settings;
public DynamicChatClientFactory(IAiSettingsService settings)
{
_settings = settings;
}
public IChatClient CreateClient()
{
if (_settings.UseCloudFallback && IsNetworkAvailable())
{
return new OpenAIClient(
new ApiKeyCredential(_settings.OpenAiApiKey))
.AsChatClient("gpt-4o-mini");
}
return new OllamaChatClient(
new Uri(_settings.OllamaEndpoint),
_settings.SelectedModel);
}
private bool IsNetworkAvailable() =>
Connectivity.Current.NetworkAccess == NetworkAccess.Internet;
}
// Register with factory
builder.Services.AddSingleton<DynamicChatClientFactory>();
builder.Services.AddTransient<IChatClient>(sp =>
sp.GetRequiredService<DynamicChatClientFactory>().CreateClient());
FAQ
Does this app actually run AI on the phone’s processor?
As built in this guide, Ollama runs on a local server (your dev machine or a home server) and the MAUI app connects over the network. True on-device inference on the phone processor requires ONNX Runtime Mobile with quantized models. That’s a deeper topic — this approach covers the “local network, no cloud cost” use case which is valuable for development and internal tools.
What’s the minimum model size that works well for chat?
Phi-3.5 (3.8B parameters) and Llama 3.2 3B are the sweet spot for development — they’re fast to respond and handle general chat well. For more complex tasks (code generation, analysis), Llama 3.1 8B or Mistral 7B produce better results at the cost of slower inference on CPU.
How do I handle the case where Ollama isn’t running?
Catch HttpRequestException in the ViewModel when Ollama is unreachable. Show a user-friendly error and optionally fall back to a cloud provider if one is configured. The DynamicChatClientFactory pattern in the Provider Switching section handles this gracefully.
Can I stream responses on both iOS and Android?
Yes — await foreach with CompleteStreamingAsync works identically on both platforms. The only platform consideration is threading: always update ChatMessageItem.Text via MainThread.InvokeOnMainThreadAsync, as shown in the ViewModel.
How do I add image understanding (multimodal) to this app?
Use MediaPicker.PickPhotoAsync() to get an image, convert it to bytes, then pass it as an ImageContent in the ChatMessage.Contents list. Ensure your Ollama model supports vision — LLaVA, Llama 3.2 Vision, or Moondream are the options. Cloud providers (GPT-4o, Claude 3) all support images via the same MEAI API.
Conclusion
A .NET MAUI AI chat app built on Microsoft.Extensions.AI is clean, portable, and production-ready. The IChatClient abstraction means the ViewModel is completely decoupled from whether it’s talking to Ollama, OpenAI, or Azure — change one registration in MauiProgram.cs and the entire app switches providers.
The streaming implementation delivers the ChatGPT-like progressive response experience that users expect. SQLite persistence gives users their conversation history across app restarts. And the settings screen lets power users switch between local and cloud models based on their needs.
[INTERNAL_LINK: Microsoft.Extensions.AI explained] [INTERNAL_LINK: MVVM in .NET MAUI with CommunityToolkit.Mvvm]

Leave a Reply