diff --git a/.github/workflows/ci-ai.yml b/.github/workflows/ci-ai.yml
new file mode 100644
index 000000000..ed0cbb386
--- /dev/null
+++ b/.github/workflows/ci-ai.yml
@@ -0,0 +1,36 @@
+name: CI - AI Extensions
+
+on:
+ push:
+ branches: [main, mattleibow/ai-annotations]
+ paths:
+ - 'src/AIExtensions/**'
+ - 'tests/AIExtensions/**'
+ - 'eng/**'
+ - 'Directory.Build.props'
+ - 'Directory.Build.targets'
+ - 'Directory.Packages.props'
+ - 'global.json'
+ - 'NuGet.config'
+ pull_request:
+ types: [opened, synchronize, reopened, edited]
+ branches: [main, mattleibow/ai-annotations]
+ paths:
+ - 'src/AIExtensions/**'
+ - 'tests/AIExtensions/**'
+ - 'eng/**'
+ - 'Directory.Build.props'
+ - 'Directory.Build.targets'
+ - 'Directory.Packages.props'
+ - 'global.json'
+ - 'NuGet.config'
+
+jobs:
+ build:
+ uses: ./.github/workflows/_build.yml
+ with:
+ project-path: src/AIExtensions/AIExtensions.slnf
+ project-name: ai-extensions
+ run-tests: true
+ pack: true
+ install-workloads: false
diff --git a/Directory.Build.props b/Directory.Build.props
index 994a2a147..af8305f71 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -11,6 +11,7 @@
https://github.com/dotnet/maui-labs
https://github.com/dotnet/maui-labs
false
+ false
true
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7da8ccd4f..bdecf324e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -24,14 +24,28 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
@@ -63,17 +77,14 @@
-
-
-
-
+
@@ -93,6 +104,8 @@
+
+
diff --git a/MauiLabs.slnx b/MauiLabs.slnx
index 576edb0c8..50e0a6847 100644
--- a/MauiLabs.slnx
+++ b/MauiLabs.slnx
@@ -8,6 +8,9 @@
+
+
+
@@ -17,6 +20,14 @@
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index c9abc1d49..1c4f66923 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,26 @@ A comprehensive MAUI testing, automation, and debugging toolkit. The DevFlow CLI
| `Microsoft.Maui.DevFlow.Driver` | Platform driver library |
| `Microsoft.Maui.DevFlow.Logging` | Buffered JSONL file logger |
+### AI Extensions
+
+AI integration packages for `Microsoft.Extensions.AI` and .NET MAUI apps.
+
+#### AI Attributes
+
+Source-generated AI tool discovery — annotate methods or property accessors with `[ExportAIFunction]` to create AI-callable tools. Composed or auto-generated tool contexts, DI-aware parameter binding, approval gates, AOT-friendly.
+
+| Package | Description |
+|---------|-------------|
+| `Microsoft.Maui.AI.Attributes` | Source-generated AI tool contexts for `Microsoft.Extensions.AI` |
+
+#### AI Navigation
+
+Runtime Shell route discovery and template-aware navigation for AI agents. Clean URIs like `//main/products/product/seed-tomato/review` are automatically parsed into multi-step `GoToAsync` calls with extracted parameters.
+
+| Package | Description |
+|---------|-------------|
+| `Microsoft.Maui.AI.Navigation` | Runtime Shell route discovery and AI-friendly navigation |
+
### macOS AppKit Backend
A native macOS AppKit backend for .NET MAUI — run MAUI apps as true AppKit apps with NSWindow, NSButton, NSScrollView, native menu bar, sidebar flyout, and more. An alternative to Mac Catalyst.
@@ -168,6 +188,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for build instructions and development se
For the formal DevFlow HTTP and WebSocket contract, see [`docs/DevFlow/spec`](docs/DevFlow/spec/README.md).
+For AI Extensions usage and samples, see [`src/AIExtensions/README.md`](src/AIExtensions/README.md) and [`samples/AIExtensions.Sample.Garden`](samples/AIExtensions.Sample.Garden/README.md).
+
## Support
See [SUPPORT.md](.github/SUPPORT.md) for how to file issues, get help, and the support policy for this repository.
diff --git a/eng/Versions.props b/eng/Versions.props
index 2621e9927..437b601d7 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -29,15 +29,26 @@
10.0.5
10.0.5
10.0.5
+ 10.0.5
10.0.5
10.0.5
+ 10.0.5
+ 10.0.5
10.0.5
+
+ 10.4.1
+ 10.4.1
+ 2.1.0
+
+
+ 3.11.0
+
5.0.0
+ 8.4.2
1.3.1
- 4.12.0
3.119.2
0.54.0
2.0.5
@@ -71,8 +82,7 @@
1
- 10.3.0
- 10.3.0
+ 10.4.1
1.0.0-rc2
1.0.0-rc2
@@ -86,6 +96,8 @@
2.9.3
3.1.5
8.0.0
+ 28.15.0
+ 2.5.0
10.0.201
diff --git a/eng/pipelines/devflow-official.yml b/eng/pipelines/devflow-official.yml
index 0ed97dd98..f49a5e9f8 100644
--- a/eng/pipelines/devflow-official.yml
+++ b/eng/pipelines/devflow-official.yml
@@ -36,6 +36,10 @@ parameters:
displayName: 'Publish macOS AppKit packages to NuGet.org'
type: boolean
default: false
+- name: publishAIExtensionsNuget
+ displayName: 'Publish AI Extensions packages to NuGet.org'
+ type: boolean
+ default: false
- name: publishEssentialsAINuget
displayName: 'Publish EssentialsAI packages to NuGet.org'
type: boolean
@@ -141,6 +145,30 @@ extends:
$(_OfficialBuildArgs)
displayName: Build and Test Cli
+ - job: AI
+ displayName: AI Extensions - Windows
+ pool:
+ name: NetCore1ESPool-Internal
+ demands: ImageOverride -equals windows.vs2026preview.scout.amd64
+ strategy:
+ matrix:
+ Release:
+ _BuildConfig: Release
+ _OfficialBuildArgs: /p:DotNetSignType=$(_SignType)
+ /p:TeamName=$(_TeamName)
+ /p:OfficialBuildId=$(BUILD.BUILDNUMBER)
+ steps:
+ - task: UseDotNet@2
+ displayName: Install .NET SDK
+ inputs:
+ version: 10.0.105
+ - script: eng\common\cibuild.cmd
+ -configuration $(_BuildConfig)
+ -prepareMachine
+ -projects $(Build.SourcesDirectory)\src\AIExtensions\AIExtensions.slnf
+ $(_OfficialBuildArgs)
+ displayName: Build and Test AI Extensions
+
- job: AppProjectReference
displayName: AppProjectReference - Windows
pool:
@@ -587,6 +615,61 @@ extends:
nuGetFeedType: external
publishFeedCredentials: 'nuget.org (dotnetframework)'
+ # Publish AI Extensions packages to NuGet.org
+ - ${{ if eq(parameters.publishAIExtensionsNuget, true) }}:
+ - stage: publish_ai_extensions_nuget
+ displayName: 'Publish AI Extensions to NuGet.org'
+ dependsOn:
+ - Validate
+ - publish_using_darc
+ jobs:
+ - job: PrepareArtifacts
+ displayName: 'Prepare AI Artifacts'
+ timeoutInMinutes: 15
+ pool:
+ name: NetCore1ESPool-Internal
+ image: windows.vs2026preview.scout.amd64
+ os: windows
+ templateContext:
+ outputs:
+ - output: pipelineArtifact
+ displayName: Publish AI Packages
+ targetPath: '$(Pipeline.Workspace)/AIPackages'
+ artifactName: AIPackagesForNuGet
+ steps:
+ - download: current
+ artifact: PackageArtifacts
+ displayName: Download PackageArtifacts
+ - powershell: |
+ New-Item -ItemType Directory -Force -Path '$(Pipeline.Workspace)/AIPackages'
+ Copy-Item '$(Pipeline.Workspace)/PackageArtifacts/Microsoft.Maui.AI.*.nupkg' '$(Pipeline.Workspace)/AIPackages/' -Verbose
+ displayName: Filter AI packages
+
+ - job: PublishNuGet
+ displayName: 'Push AI to NuGet.org'
+ dependsOn: PrepareArtifacts
+ timeoutInMinutes: 30
+ pool:
+ name: NetCore1ESPool-Internal
+ image: windows.vs2026preview.scout.amd64
+ os: windows
+ templateContext:
+ type: releaseJob
+ isProduction: true
+ inputs:
+ - input: pipelineArtifact
+ artifactName: AIPackagesForNuGet
+ targetPath: '$(Pipeline.Workspace)/AIPackages'
+ steps:
+ - task: 1ES.PublishNuget@1
+ displayName: 'Push AI to NuGet.org'
+ inputs:
+ useDotNetTask: false
+ packagesToPush: '$(Pipeline.Workspace)/AIPackages/*.nupkg'
+ packageParentPath: '$(Pipeline.Workspace)/AIPackages'
+ nuGetFeedType: external
+ publishFeedCredentials: 'nuget.org (dotnetframework)'
+
# Publish WPF packages to NuGet.org
- ${{ if eq(parameters.publishWpfNuget, true) }}:
- stage: publish_wpf_nuget
diff --git a/samples/AIExtensions.Sample.DIParameters/AIExtensions.Sample.DIParameters.csproj b/samples/AIExtensions.Sample.DIParameters/AIExtensions.Sample.DIParameters.csproj
new file mode 100644
index 000000000..8afe0bd1d
--- /dev/null
+++ b/samples/AIExtensions.Sample.DIParameters/AIExtensions.Sample.DIParameters.csproj
@@ -0,0 +1,31 @@
+
+
+
+ Exe
+ net10.0
+ AIExtensions.Sample.DIParameters
+ enable
+ enable
+ ai-attributes-secrets
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.DIParameters/ModelProviders.cs b/samples/AIExtensions.Sample.DIParameters/ModelProviders.cs
new file mode 100644
index 000000000..170590e04
--- /dev/null
+++ b/samples/AIExtensions.Sample.DIParameters/ModelProviders.cs
@@ -0,0 +1,20 @@
+namespace AIExtensions.Sample.DIParameters;
+
+///
+/// A keyed service. Pulled from DI using [FromKeyedServices("premium")]
+/// or [FromKeyedServices("free")].
+///
+public interface IModelProvider
+{
+ string Name { get; }
+}
+
+public sealed class FreeModelProvider : IModelProvider
+{
+ public string Name => "free-v1";
+}
+
+public sealed class PremiumModelProvider : IModelProvider
+{
+ public string Name => "premium-v2";
+}
diff --git a/samples/AIExtensions.Sample.DIParameters/Program.cs b/samples/AIExtensions.Sample.DIParameters/Program.cs
new file mode 100644
index 000000000..a0313155e
--- /dev/null
+++ b/samples/AIExtensions.Sample.DIParameters/Program.cs
@@ -0,0 +1,114 @@
+using System.ClientModel;
+using AIExtensions;
+using AIExtensions.Sample.DIParameters;
+using Azure.AI.OpenAI;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Maui.AI.Attributes;
+
+// This sample focuses on the parameter binding shapes Microsoft.Maui.AI.Attributes
+// supports — the stuff that makes it more than "attributes wrapping ReflectionAIFunction":
+//
+// • [FromServices] for DI-resolved parameters
+// • [FromKeyedServices] for keyed services
+// • CancellationToken as a direct parameter
+// • plain records / primitives bind from the argument dictionary (no annotation)
+//
+// See TranslatorService.Translate for the single tool.
+
+var configuration = new ConfigurationBuilder()
+ .AddUserSecrets()
+ .Build();
+
+var apiKey = configuration["AI:ApiKey"];
+var endpoint = configuration["AI:Endpoint"];
+var deployment = configuration["AI:DeploymentName"];
+
+if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(endpoint) || string.IsNullOrEmpty(deployment))
+{
+ Console.Error.WriteLine("""
+ AI:Endpoint, AI:ApiKey and AI:DeploymentName must be set. Configure user-secrets:
+
+ dotnet user-secrets --id ai-attributes-secrets set "AI:Endpoint" ""
+ dotnet user-secrets --id ai-attributes-secrets set "AI:ApiKey" ""
+ dotnet user-secrets --id ai-attributes-secrets set "AI:DeploymentName" ""
+
+ (shared across all 4 AI.Attributes samples)
+ """);
+ return 1;
+}
+
+var services = new ServiceCollection();
+
+// Logging. PigLatinTranslator takes an ILogger in its constructor — this
+// demonstrates nested DI: the [FromServices] ITranslator parameter resolves
+// to PigLatinTranslator, which in turn needs a logger.
+services.AddLogging(b =>
+ b.AddSimpleConsole(o =>
+ {
+ o.SingleLine = true;
+ o.IncludeScopes = false;
+ o.TimestampFormat = null;
+ }));
+
+// Backing service that owns the [ExportAIFunction] method.
+services.AddSingleton();
+
+// [FromServices] ITranslator resolves to this implementation. It in turn takes
+// ILogger in its constructor, so the full dependency chain
+// is: tool method → ITranslator → ILogger.
+services.AddSingleton();
+
+// Keyed DI: [FromKeyedServices("premium")] pulls this specific instance.
+services.AddKeyedSingleton("premium");
+services.AddKeyedSingleton("free");
+
+// Tool composition — no DI registration needed; the source generator emits
+// a static Default singleton. Each generated tool reads AIFunctionArguments.Services
+// at invocation time, which ChatClientBuilder.UseFunctionInvocation().Build(sp)
+// populates with the root provider automatically.
+
+// Chat client.
+var azure = new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey));
+services.AddSingleton(azure.GetChatClient(deployment).AsIChatClient());
+
+var root = services.BuildServiceProvider();
+var chat = new ChatClientBuilder(root.GetRequiredService())
+ .UseFunctionInvocation()
+ .Build(root);
+
+var tools = AIExtensionsSampleDIParametersToolContext.Default.Tools;
+var options = new ChatOptions { Tools = [.. tools] };
+
+Console.WriteLine($"{tools.Count} tool(s) registered:");
+foreach (var t in tools)
+ Console.WriteLine($" - {t.Name}: {t.Description}");
+Console.WriteLine();
+Console.WriteLine("Try: \"Translate 'hello world' to pig latin with verbose output.\"");
+Console.WriteLine("Ctrl+C to exit.");
+Console.WriteLine();
+
+var history = new List
+{
+ new(ChatRole.System,
+ """
+ You are a translation assistant. You MUST call the translate tool for every
+ translation request, even trivial ones — never translate text yourself.
+ """)
+};
+
+while (true)
+{
+ Console.Write("> ");
+ var input = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(input))
+ continue;
+
+ history.Add(new ChatMessage(ChatRole.User, input));
+ var response = await chat.GetResponseAsync(history, options);
+ history.AddMessages(response);
+ Console.WriteLine(response.Text);
+ Console.WriteLine();
+}
diff --git a/samples/AIExtensions.Sample.DIParameters/README.md b/samples/AIExtensions.Sample.DIParameters/README.md
new file mode 100644
index 000000000..f1319aad0
--- /dev/null
+++ b/samples/AIExtensions.Sample.DIParameters/README.md
@@ -0,0 +1,52 @@
+# DIParameters — parameter binding shapes
+
+A console app that shows the parameter shapes
+AI Extensions supports beyond "plain value in, value out".
+
+## What this demonstrates
+
+All on a single `[ExportAIFunction]` method:
+
+- **`[FromServices]`.** An `ITranslator translator` parameter is resolved from the
+ service provider at invocation time. The AI never sees it in the schema.
+- **Keyed DI.** `[FromKeyedServices("premium")] IModelProvider model` pulls
+ the keyed registration.
+- **Plain record argument.** `TranslationOptions` has no attribute — the generator
+ treats it as a normal model-filled argument because it isn't marked for DI.
+- **`CancellationToken`.** Bound automatically; never in the schema.
+
+## Why this exists
+
+Other attribute-based libraries typically only wrap `ReflectionAIFunction`
+and expect you to hand-author `AIFunctionFactory.Create` calls to get DI
+support. This sample is the proof the source generator emits the same
+behaviour without runtime reflection — and adds first-class
+`[FromServices]`/`[FromKeyedServices]` attribute support on top.
+
+## Run
+
+All four `AIExtensions.Sample.*` apps share one `UserSecretsId`
+(`ai-attributes-secrets`), so you configure the endpoint once:
+
+```bash
+dotnet user-secrets --id ai-attributes-secrets set "AI:Endpoint" "https://.openai.azure.com"
+dotnet user-secrets --id ai-attributes-secrets set "AI:ApiKey" ""
+dotnet user-secrets --id ai-attributes-secrets set "AI:DeploymentName" ""
+
+dotnet run --project samples/AIExtensions.Sample.DIParameters
+```
+
+Try: `Translate 'hello world' to pig latin with verbose output.`
+
+## Inspecting the generated source
+
+This csproj sets `EmitCompilerGeneratedFiles=true` so you can see exactly
+what `Microsoft.Maui.AI.Attributes.Generators` emits for each tool context.
+This is **not required** to run the sample. If you do not need to inspect
+the generated source, you can remove the property from the csproj.
+
+After a build, look under:
+
+```
+artifacts/obj////generated/Microsoft.Maui.AI.Attributes.Generators/Microsoft.Maui.AI.Attributes.Generators.AIToolContextGenerator/*.g.cs
+```
diff --git a/samples/AIExtensions.Sample.DIParameters/TranslatorService.cs b/samples/AIExtensions.Sample.DIParameters/TranslatorService.cs
new file mode 100644
index 000000000..82be925c4
--- /dev/null
+++ b/samples/AIExtensions.Sample.DIParameters/TranslatorService.cs
@@ -0,0 +1,49 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Maui.AI.Attributes;
+
+namespace AIExtensions.Sample.DIParameters;
+
+///
+/// Options the AI model supplies as part of the tool call. A plain record
+/// (not an interface/abstract class) so the generator schemas it by default
+/// — no explicit attribute is needed to keep it in the tool schema.
+///
+public sealed record TranslationOptions(
+ bool Verbose = false);
+
+public class TranslatorService(ILogger logger)
+{
+ [Description("Translates a phrase using the configured translator.")]
+ [ExportAIFunction("translate")]
+ public string Translate(
+ [Description("The text to translate")] string text,
+ // Explicit DI: [FromServices] resolves from IServiceProvider and
+ // excludes the parameter from the tool schema.
+ [FromServices] ITranslator translator,
+ // Explicit keyed DI: resolved via [FromKeyedServices].
+ [FromKeyedServices("premium")] IModelProvider model,
+ // A plain record — no attribute needed. The AI fills it in per call.
+ TranslationOptions options,
+ // Direct CancellationToken support — never appears in the schema.
+ CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ // Log what DI resolved and what the model filled in, so the sample
+ // demonstrates parameter binding even when the LLM paraphrases the
+ // tool result.
+ if (options.Verbose)
+ {
+ logger.LogInformation(
+ "translate tool called: text={Text}, translator={Translator}, model={Model}, options={Options}",
+ text,
+ translator.GetType().Name,
+ model.Name,
+ options);
+ }
+
+ return translator.Translate(text, options.Verbose);
+ }
+}
diff --git a/samples/AIExtensions.Sample.DIParameters/Translators.cs b/samples/AIExtensions.Sample.DIParameters/Translators.cs
new file mode 100644
index 000000000..2e17266f3
--- /dev/null
+++ b/samples/AIExtensions.Sample.DIParameters/Translators.cs
@@ -0,0 +1,38 @@
+using Microsoft.Extensions.Logging;
+
+namespace AIExtensions.Sample.DIParameters;
+
+///
+/// A translator service. Resolved via DI — the [FromServices] attribute
+/// on the parameter tells the generator to inject it from the service provider
+/// at invocation time rather than expecting the AI model to supply it.
+///
+public interface ITranslator
+{
+ string Translate(string text, bool verbose = false);
+}
+
+///
+/// Takes as a constructor dependency — this exercises
+/// nested DI: the tool method asks for ITranslator, which the
+/// container resolves to this type, which in turn needs a logger. The logger
+/// is produced by the standard AddLogging() registration and appears
+/// in the console output, making the resolution chain visible.
+///
+public sealed class PigLatinTranslator(ILogger logger) : ITranslator
+{
+ public string Translate(string text, bool verbose = false)
+ {
+ var words = text.Split(' ');
+
+ if (verbose)
+ {
+ logger.LogInformation(
+ "Translating {WordCount} word(s) to pig latin...",
+ words.Length);
+ }
+
+ return string.Join(' ', words.Select(w =>
+ w.Length > 1 ? w[1..] + w[0] + "ay" : w));
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/AIExtensions.Sample.Garden.csproj b/samples/AIExtensions.Sample.Garden/AIExtensions.Sample.Garden.csproj
new file mode 100644
index 000000000..37e10e986
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/AIExtensions.Sample.Garden.csproj
@@ -0,0 +1,68 @@
+
+
+
+ net10.0-android;net10.0-ios;net10.0-maccatalyst
+ $(TargetFrameworks);net10.0-windows10.0.19041.0
+
+ Exe
+ AIExtensions.Sample.Garden
+ true
+ true
+
+ Garden AI Sample
+ com.microsoft.maui.aiattributes.sample.garden
+ 1.0
+ 1
+
+ None
+
+ 10.0.17763.0
+
+ ai-attributes-secrets
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $([System.Environment]::GetFolderPath(SpecialFolder.UserProfile))\AppData\Roaming\Microsoft\UserSecrets\$(UserSecretsId)\secrets.json
+ $([System.Environment]::GetFolderPath(SpecialFolder.UserProfile))/.microsoft/usersecrets/$(UserSecretsId)/secrets.json
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/App.xaml b/samples/AIExtensions.Sample.Garden/App.xaml
new file mode 100644
index 000000000..971c2247a
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/App.xaml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/App.xaml.cs b/samples/AIExtensions.Sample.Garden/App.xaml.cs
new file mode 100644
index 000000000..d43108dff
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/App.xaml.cs
@@ -0,0 +1,14 @@
+namespace AIExtensions.Sample.Garden;
+
+public partial class App : Application
+{
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected override Window CreateWindow(IActivationState? activationState)
+ {
+ return new Window(new AppShell());
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/AppShell.xaml b/samples/AIExtensions.Sample.Garden/AppShell.xaml
new file mode 100644
index 000000000..5417b0b58
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/AppShell.xaml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/AppShell.xaml.cs b/samples/AIExtensions.Sample.Garden/AppShell.xaml.cs
new file mode 100644
index 000000000..087668291
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/AppShell.xaml.cs
@@ -0,0 +1,20 @@
+using AIExtensions.Sample.Garden.Pages;
+
+namespace AIExtensions.Sample.Garden;
+
+public partial class AppShell : Shell
+{
+ public AppShell()
+ {
+ InitializeComponent();
+
+ // Detail pages under products — friendly URLs: /products/product?sku=X
+ Routing.RegisterRoute("product", typeof(ProductDetailPage));
+ // Review modal — /products/product/review?sku=X
+ Routing.RegisterRoute("review", typeof(ProductReviewPage));
+ // Order detail — /orders/order?orderId=X
+ Routing.RegisterRoute("order", typeof(OrderDetailPage));
+ // Cart stays modal — slides up from anywhere
+ Routing.RegisterRoute("cart", typeof(CartPage));
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/FluentIcons.cs b/samples/AIExtensions.Sample.Garden/FluentIcons.cs
new file mode 100644
index 000000000..eeeaa2eda
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/FluentIcons.cs
@@ -0,0 +1,44 @@
+namespace AIExtensions.Sample.Garden;
+
+///
+/// Fluent UI System Icons (Filled) glyph constants.
+/// Use with FontFamily="FluentFilled" in XAML or code.
+///
+static class FluentIcons
+{
+ public const string Cart = "\uF26B";
+ public const string Send = "\uF6A3";
+ public const string Compose = "\uF315";
+ public const string Dismiss = "\uF36A";
+ public const string LeafOne = "\uE769";
+ public const string LeafThree = "\uE76C";
+ public const string Box = "\uE1D0";
+ public const string ShoppingBag = "\uF788";
+ public const string Receipt = "\uEA07";
+ public const string Delete = "\uF34D";
+ public const string Checkmark = "\uF295";
+ public const string Home = "\uF488";
+ public const string Sparkle = "\uEB3D";
+ public const string Food = "\uF448";
+ public const string Drop = "\uE59D";
+ public const string Toolbox = "\uF848";
+ public const string Star = "\uF719";
+ public const string ChatSparkle = "\uF7ED";
+ public const string Add = "\uF10A";
+
+ // Product category / item icons
+ public const string WeatherSunny = "\uF8BA";
+ public const string BowlSalad = "\uEEE9";
+ public const string Earth = "\uF3DA";
+ public const string Beaker = "\uF1D8";
+ public const string Wrench = "\uF8D9";
+ public const string Cut = "\uF33B";
+ public const string HandRight = "\uE6ED";
+ public const string Temperature = "\uF7A8";
+ public const string PaintBrush = "\uF59D";
+ public const string LockClosed = "\uE79E";
+
+ // Chevrons (for expand/collapse and list navigation)
+ public const string ChevronRight = "\uF2B0";
+ public const string ChevronDown = "\uF2A3";
+}
diff --git a/samples/AIExtensions.Sample.Garden/IsNotNullConverter.cs b/samples/AIExtensions.Sample.Garden/IsNotNullConverter.cs
new file mode 100644
index 000000000..adca35e40
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/IsNotNullConverter.cs
@@ -0,0 +1,15 @@
+using System.Globalization;
+
+namespace AIExtensions.Sample.Garden;
+
+///
+/// Returns true if the value is not null (and not empty for strings).
+///
+public sealed class IsNotNullConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is string s ? !string.IsNullOrEmpty(s) : value is not null;
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/samples/AIExtensions.Sample.Garden/MauiProgram.cs b/samples/AIExtensions.Sample.Garden/MauiProgram.cs
new file mode 100644
index 000000000..fb8a2b462
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/MauiProgram.cs
@@ -0,0 +1,122 @@
+using System.ClientModel;
+using System.Reflection;
+using AIExtensions.Sample.Garden.Pages;
+using AIExtensions.Sample.Garden.Services;
+using AIExtensions.Sample.Garden.ViewModels;
+using Azure.AI.OpenAI;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.Maui.AI.Navigation;
+using Microsoft.Maui.DevFlow.Agent;
+
+namespace AIExtensions.Sample.Garden;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder
+ .UseMauiApp()
+ .ConfigureFonts(fonts =>
+ {
+ fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
+ fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
+ fonts.AddFont("FluentSystemIcons-Filled.ttf", "FluentFilled");
+ })
+ .ConfigureMauiHandlers(handlers =>
+ {
+ // Remove native Entry border so our custom Border wrapper is the only visible frame.
+#if IOS || MACCATALYST
+ Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("NoBorder", (handler, _) =>
+ {
+ handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None;
+ });
+#elif ANDROID
+ Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("NoBorder", (handler, _) =>
+ {
+ handler.PlatformView.BackgroundTintList = Android.Content.Res.ColorStateList.ValueOf(Android.Graphics.Color.Transparent);
+ });
+#endif
+ });
+
+ builder.Configuration.AddUserSecrets();
+
+#if DEBUG
+ builder.AddMauiDevFlowAgent();
+#endif
+
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+
+ builder.AddOpenAIServices();
+
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddSingleton();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+
+#if DEBUG
+ builder.Logging.AddDebug();
+#endif
+
+ return builder.Build();
+ }
+
+ private static void AddUserSecrets(this ConfigurationManager manager)
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ var resourceNames = assembly.GetManifestResourceNames();
+ var secretsResource = resourceNames.FirstOrDefault(n => n.EndsWith("secrets.json"));
+ if (secretsResource is not null)
+ {
+ using var stream = assembly.GetManifestResourceStream(secretsResource);
+ if (stream is not null)
+ manager.AddJsonStream(stream);
+ }
+ }
+
+ private static MauiAppBuilder AddOpenAIServices(this MauiAppBuilder builder)
+ {
+ var aiSection = builder.Configuration.GetSection("AI");
+ var apiKey = aiSection["ApiKey"];
+ var endpoint = aiSection["Endpoint"];
+ var deploymentName = aiSection["DeploymentName"];
+
+ if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(endpoint) || string.IsNullOrEmpty(deploymentName))
+ {
+ throw new InvalidOperationException(
+ """
+ AI services are not configured. Set up user secrets (shared across all AIExtensions samples):
+
+ dotnet user-secrets --id ai-attributes-secrets set "AI:Endpoint" ""
+ dotnet user-secrets --id ai-attributes-secrets set "AI:ApiKey" ""
+ dotnet user-secrets --id ai-attributes-secrets set "AI:DeploymentName" ""
+ """);
+ }
+
+ var azureClient = new AzureOpenAIClient(
+ new Uri(endpoint),
+ new ApiKeyCredential(apiKey));
+ var chatClient = azureClient.GetChatClient(deploymentName);
+
+ builder.Services.AddSingleton(chatClient.AsIChatClient());
+
+ return builder;
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Messages/Messages.cs b/samples/AIExtensions.Sample.Garden/Messages/Messages.cs
new file mode 100644
index 000000000..953998aff
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Messages/Messages.cs
@@ -0,0 +1,26 @@
+using AIExtensions.Sample.Garden.ViewModels;
+
+namespace AIExtensions.Sample.Garden.Messages;
+
+///
+/// Broadcast when the cart contents change (add, remove, clear, qty change).
+///
+public sealed class CartChangedMessage;
+
+///
+/// Broadcast after the AI chat completes a full turn (response + tool calls).
+///
+public sealed class ChatTurnCompletedMessage;
+
+///
+/// Broadcast when a new chat message is appended so views can scroll.
+///
+public sealed class ChatMessageAddedMessage(ChatMessageViewModel message)
+{
+ public ChatMessageViewModel Message { get; } = message;
+}
+
+///
+/// Request that the chat VM starts a fresh session (clears history + messages).
+///
+public sealed class StartNewChatSessionMessage;
diff --git a/samples/AIExtensions.Sample.Garden/Models/ListItem.cs b/samples/AIExtensions.Sample.Garden/Models/ListItem.cs
new file mode 100644
index 000000000..e72839aa3
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Models/ListItem.cs
@@ -0,0 +1,11 @@
+namespace AIExtensions.Sample.Garden.Models;
+
+///
+/// A line item in a cart or order.
+///
+public record ListItem(
+ Product Product,
+ int Quantity)
+{
+ public decimal Subtotal => Product.Price * Quantity;
+}
diff --git a/samples/AIExtensions.Sample.Garden/Models/Order.cs b/samples/AIExtensions.Sample.Garden/Models/Order.cs
new file mode 100644
index 000000000..c5078e7f4
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Models/Order.cs
@@ -0,0 +1,13 @@
+namespace AIExtensions.Sample.Garden.Models;
+
+///
+/// A committed shopping order in the singleton archive.
+/// Created by checkout_list after the user approves the transition.
+///
+public record Order(
+ string Id,
+ DateTime PlacedAt,
+ IReadOnlyList Items)
+{
+ public decimal Total => Items.Sum(i => i.Subtotal);
+}
diff --git a/samples/AIExtensions.Sample.Garden/Models/Product.cs b/samples/AIExtensions.Sample.Garden/Models/Product.cs
new file mode 100644
index 000000000..e68cd1260
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Models/Product.cs
@@ -0,0 +1,16 @@
+namespace AIExtensions.Sample.Garden.Models;
+
+///
+/// A product in the garden shop catalog (seeds, soil, tools, fertilizer, etc.).
+///
+/// Stable id used by tools.
+/// Display name shown in chat and on cards.
+/// Top-level grouping for the workspace panel.
+/// Unit price in USD.
+/// Emoji shown next to the product everywhere.
+public record Product(
+ string Sku,
+ string Name,
+ string Category,
+ decimal Price,
+ string Emoji);
diff --git a/samples/AIExtensions.Sample.Garden/Models/ProductReview.cs b/samples/AIExtensions.Sample.Garden/Models/ProductReview.cs
new file mode 100644
index 000000000..623caa575
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Models/ProductReview.cs
@@ -0,0 +1,10 @@
+namespace AIExtensions.Sample.Garden.Models;
+
+///
+/// A user review on a product.
+///
+public record ProductReview(
+ string ProductSku,
+ int Rating,
+ string? Comment,
+ DateTime CreatedAt);
diff --git a/samples/AIExtensions.Sample.Garden/Pages/CartPage.xaml b/samples/AIExtensions.Sample.Garden/Pages/CartPage.xaml
new file mode 100644
index 000000000..9716cf400
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/CartPage.xaml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Pages/CartPage.xaml.cs b/samples/AIExtensions.Sample.Garden/Pages/CartPage.xaml.cs
new file mode 100644
index 000000000..52cec0aee
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/CartPage.xaml.cs
@@ -0,0 +1,17 @@
+using AIExtensions.Sample.Garden.ViewModels;
+
+namespace AIExtensions.Sample.Garden.Pages;
+
+public partial class CartPage : ContentPage
+{
+ public CartPage(CartViewModel vm)
+ {
+ InitializeComponent();
+ BindingContext = vm;
+ }
+
+ private async void OnCloseClicked(object? sender, EventArgs e)
+ {
+ await Shell.Current.GoToAsync("..");
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Pages/CatalogPage.xaml b/samples/AIExtensions.Sample.Garden/Pages/CatalogPage.xaml
new file mode 100644
index 000000000..a83dbf5d5
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/CatalogPage.xaml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Pages/CatalogPage.xaml.cs b/samples/AIExtensions.Sample.Garden/Pages/CatalogPage.xaml.cs
new file mode 100644
index 000000000..6089145ae
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/CatalogPage.xaml.cs
@@ -0,0 +1,14 @@
+namespace AIExtensions.Sample.Garden.Pages;
+
+public partial class CatalogPage : ContentPage
+{
+ public CatalogPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnBackClicked(object? sender, EventArgs e)
+ {
+ await Shell.Current.GoToAsync("//main/chat");
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Pages/MainPage.xaml b/samples/AIExtensions.Sample.Garden/Pages/MainPage.xaml
new file mode 100644
index 000000000..36e3fde45
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/MainPage.xaml
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Pages/MainPage.xaml.cs b/samples/AIExtensions.Sample.Garden/Pages/MainPage.xaml.cs
new file mode 100644
index 000000000..729fdb51e
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/MainPage.xaml.cs
@@ -0,0 +1,26 @@
+using AIExtensions.Sample.Garden.ViewModels;
+
+namespace AIExtensions.Sample.Garden.Pages;
+
+public partial class MainPage : ContentPage
+{
+ private readonly MainViewModel _viewModel;
+
+ public MainPage(MainViewModel viewModel)
+ {
+ InitializeComponent();
+ BindingContext = _viewModel = viewModel;
+ }
+
+ protected override void OnAppearing()
+ {
+ base.OnAppearing();
+ _viewModel.Initialize();
+ }
+
+ private async void OnProductsClicked(object? sender, EventArgs e)
+ => await Shell.Current.GoToAsync("//main/products");
+
+ private async void OnOrdersClicked(object? sender, EventArgs e)
+ => await Shell.Current.GoToAsync("//main/orders");
+}
diff --git a/samples/AIExtensions.Sample.Garden/Pages/OrderDetailPage.xaml b/samples/AIExtensions.Sample.Garden/Pages/OrderDetailPage.xaml
new file mode 100644
index 000000000..fca6f265b
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/OrderDetailPage.xaml
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Pages/OrderDetailPage.xaml.cs b/samples/AIExtensions.Sample.Garden/Pages/OrderDetailPage.xaml.cs
new file mode 100644
index 000000000..a3ae120a6
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/OrderDetailPage.xaml.cs
@@ -0,0 +1,36 @@
+using AIExtensions.Sample.Garden.ViewModels;
+
+namespace AIExtensions.Sample.Garden.Pages;
+
+public partial class OrderDetailPage : ContentPage, IQueryAttributable
+{
+ public OrderDetailPage(OrderDetailViewModel vm)
+ {
+ InitializeComponent();
+ BindingContext = vm;
+ }
+
+ public void ApplyQueryAttributes(IDictionary query)
+ {
+ if (query.TryGetValue("orderId", out var id) && id is string s)
+ {
+ if (BindingContext is OrderDetailViewModel vm)
+ vm.OrderId = s;
+ }
+ }
+
+ private async void OnBackClicked(object? sender, EventArgs e)
+ {
+ await Shell.Current.GoToAsync("..");
+ }
+
+ private async void OnProductTapped(object? sender, TappedEventArgs e)
+ {
+ if (e.Parameter is OrderLineViewModel line)
+ {
+ var sku = line.Sku;
+ if (!string.IsNullOrWhiteSpace(sku))
+ await Shell.Current.GoToAsync($"product?sku={sku}");
+ }
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Pages/OrdersPage.xaml b/samples/AIExtensions.Sample.Garden/Pages/OrdersPage.xaml
new file mode 100644
index 000000000..7f4671412
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/OrdersPage.xaml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Pages/OrdersPage.xaml.cs b/samples/AIExtensions.Sample.Garden/Pages/OrdersPage.xaml.cs
new file mode 100644
index 000000000..be385ea87
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/OrdersPage.xaml.cs
@@ -0,0 +1,14 @@
+namespace AIExtensions.Sample.Garden.Pages;
+
+public partial class OrdersPage : ContentPage
+{
+ public OrdersPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnBackClicked(object? sender, EventArgs e)
+ {
+ await Shell.Current.GoToAsync("//main/chat");
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Pages/ProductDetailPage.xaml b/samples/AIExtensions.Sample.Garden/Pages/ProductDetailPage.xaml
new file mode 100644
index 000000000..ad11944ac
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/ProductDetailPage.xaml
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Pages/ProductDetailPage.xaml.cs b/samples/AIExtensions.Sample.Garden/Pages/ProductDetailPage.xaml.cs
new file mode 100644
index 000000000..97800f212
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/ProductDetailPage.xaml.cs
@@ -0,0 +1,33 @@
+using AIExtensions.Sample.Garden.ViewModels;
+
+namespace AIExtensions.Sample.Garden.Pages;
+
+public partial class ProductDetailPage : ContentPage, IQueryAttributable
+{
+ public ProductDetailPage(ProductDetailViewModel vm)
+ {
+ InitializeComponent();
+ BindingContext = vm;
+ }
+
+ public void ApplyQueryAttributes(IDictionary query)
+ {
+ if (query.TryGetValue("sku", out var sku) && sku is string s)
+ {
+ if (BindingContext is ProductDetailViewModel vm)
+ vm.Sku = s;
+ }
+ }
+
+ protected override void OnNavigatedTo(NavigatedToEventArgs args)
+ {
+ base.OnNavigatedTo(args);
+ if (BindingContext is ProductDetailViewModel vm)
+ vm.RefreshReviews();
+ }
+
+ private async void OnBackClicked(object? sender, EventArgs e)
+ {
+ await Shell.Current.GoToAsync("..");
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Pages/ProductReviewPage.xaml b/samples/AIExtensions.Sample.Garden/Pages/ProductReviewPage.xaml
new file mode 100644
index 000000000..5ed9cb4a5
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/ProductReviewPage.xaml
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Pages/ProductReviewPage.xaml.cs b/samples/AIExtensions.Sample.Garden/Pages/ProductReviewPage.xaml.cs
new file mode 100644
index 000000000..356308fbb
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Pages/ProductReviewPage.xaml.cs
@@ -0,0 +1,21 @@
+using AIExtensions.Sample.Garden.ViewModels;
+
+namespace AIExtensions.Sample.Garden.Pages;
+
+public partial class ProductReviewPage : ContentPage, IQueryAttributable
+{
+ public ProductReviewPage(ProductReviewViewModel vm)
+ {
+ InitializeComponent();
+ BindingContext = vm;
+ }
+
+ public void ApplyQueryAttributes(IDictionary query)
+ {
+ if (query.TryGetValue("sku", out var sku) && sku is string s)
+ {
+ if (BindingContext is ProductReviewViewModel vm)
+ vm.Sku = s;
+ }
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/Android/AndroidManifest.xml b/samples/AIExtensions.Sample.Garden/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 000000000..e9937ad77
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/Android/MainActivity.cs b/samples/AIExtensions.Sample.Garden/Platforms/Android/MainActivity.cs
new file mode 100644
index 000000000..b0d3b9aa4
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/Android/MainActivity.cs
@@ -0,0 +1,10 @@
+using Android.App;
+using Android.Content.PM;
+using Android.OS;
+
+namespace AIExtensions.Sample.Garden;
+
+[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
+public class MainActivity : MauiAppCompatActivity
+{
+}
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/Android/MainApplication.cs b/samples/AIExtensions.Sample.Garden/Platforms/Android/MainApplication.cs
new file mode 100644
index 000000000..641a4a230
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/Android/MainApplication.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Runtime;
+
+namespace AIExtensions.Sample.Garden;
+
+[Application]
+public class MainApplication : MauiApplication
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership)
+ {
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/Android/Resources/values/colors.xml b/samples/AIExtensions.Sample.Garden/Platforms/Android/Resources/values/colors.xml
new file mode 100644
index 000000000..c04d7492a
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/Android/Resources/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #512BD4
+ #2B0B98
+ #2B0B98
+
\ No newline at end of file
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/MacCatalyst/AppDelegate.cs b/samples/AIExtensions.Sample.Garden/Platforms/MacCatalyst/AppDelegate.cs
new file mode 100644
index 000000000..ea23d19da
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/MacCatalyst/AppDelegate.cs
@@ -0,0 +1,9 @@
+using Foundation;
+
+namespace AIExtensions.Sample.Garden;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/MacCatalyst/Entitlements.plist b/samples/AIExtensions.Sample.Garden/Platforms/MacCatalyst/Entitlements.plist
new file mode 100644
index 000000000..963a59676
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/MacCatalyst/Entitlements.plist
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+ com.apple.security.network.client
+
+
+ com.apple.security.network.server
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/MacCatalyst/Info.plist b/samples/AIExtensions.Sample.Garden/Platforms/MacCatalyst/Info.plist
new file mode 100644
index 000000000..f2e09873d
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/MacCatalyst/Info.plist
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UIDeviceFamily
+
+ 2
+
+ LSApplicationCategoryType
+ public.app-category.lifestyle
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/MacCatalyst/Program.cs b/samples/AIExtensions.Sample.Garden/Platforms/MacCatalyst/Program.cs
new file mode 100644
index 000000000..7b9c1b591
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/MacCatalyst/Program.cs
@@ -0,0 +1,14 @@
+using UIKit;
+
+namespace AIExtensions.Sample.Garden;
+
+public class Program
+{
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/Windows/App.xaml b/samples/AIExtensions.Sample.Garden/Platforms/Windows/App.xaml
new file mode 100644
index 000000000..c2d6a67ce
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/Windows/App.xaml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/Windows/App.xaml.cs b/samples/AIExtensions.Sample.Garden/Platforms/Windows/App.xaml.cs
new file mode 100644
index 000000000..1efb31797
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/Windows/App.xaml.cs
@@ -0,0 +1,25 @@
+using Microsoft.UI.Xaml;
+
+namespace AIExtensions.Sample.Garden.WinUI;
+
+public partial class App : MauiWinUIApplication
+{
+ public App()
+ {
+ this.InitializeComponent();
+ this.UnhandledException += OnUnhandledException;
+ }
+
+ private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
+ {
+ var logPath = System.IO.Path.Combine(
+ System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
+ "maui_crash.log");
+ System.IO.File.AppendAllText(logPath,
+ $"[{System.DateTime.Now}] UNHANDLED: {e.Exception}\n{e.Exception?.StackTrace}\n\n");
+ // Do NOT set e.Handled = true — let the exception propagate
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
+
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/Windows/Package.appxmanifest b/samples/AIExtensions.Sample.Garden/Platforms/Windows/Package.appxmanifest
new file mode 100644
index 000000000..d6a172be5
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/Windows/Package.appxmanifest
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+ $placeholder$
+ User Name
+ $placeholder$.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/Windows/app.manifest b/samples/AIExtensions.Sample.Garden/Platforms/Windows/app.manifest
new file mode 100644
index 000000000..efd4d6cff
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/Windows/app.manifest
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ true/PM
+ PerMonitorV2, PerMonitor
+
+ true
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/iOS/AppDelegate.cs b/samples/AIExtensions.Sample.Garden/Platforms/iOS/AppDelegate.cs
new file mode 100644
index 000000000..ea23d19da
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/iOS/AppDelegate.cs
@@ -0,0 +1,9 @@
+using Foundation;
+
+namespace AIExtensions.Sample.Garden;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/iOS/Info.plist b/samples/AIExtensions.Sample.Garden/Platforms/iOS/Info.plist
new file mode 100644
index 000000000..0004a4fde
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/iOS/Info.plist
@@ -0,0 +1,32 @@
+
+
+
+
+ LSRequiresIPhoneOS
+
+ UIDeviceFamily
+
+ 1
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/iOS/Program.cs b/samples/AIExtensions.Sample.Garden/Platforms/iOS/Program.cs
new file mode 100644
index 000000000..bfda9af69
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/iOS/Program.cs
@@ -0,0 +1,15 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace AIExtensions.Sample.Garden;
+
+public class Program
+{
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Platforms/iOS/Resources/PrivacyInfo.xcprivacy b/samples/AIExtensions.Sample.Garden/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
new file mode 100644
index 000000000..807978644
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
@@ -0,0 +1,50 @@
+
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategorySystemBootTime
+ NSPrivacyAccessedAPITypeReasons
+
+ 35F9.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryDiskSpace
+ NSPrivacyAccessedAPITypeReasons
+
+ E174.1
+
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+
+ CA92.1
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/README.md b/samples/AIExtensions.Sample.Garden/README.md
new file mode 100644
index 000000000..e9105545d
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/README.md
@@ -0,0 +1,98 @@
+# Garden Shop AI Chat
+
+A polished .NET MAUI sample that demonstrates **AI Extensions**
+in a real app surface. The assistant, **Sage**, can browse the catalog, manage
+the cart, open modal pages, recommend starter bundles, and review or reorder
+past purchases using source-generated tools.
+
+## What to try
+
+- `Add 5 packs of tomato seeds and a trowel`
+- `Build me a basil starter bundle`
+- `Show me the basil seeds` — navigates to the product detail page
+- `Open the product catalog`
+- `Go to my past orders`
+- `Show compact cart`
+- `Rate the tomato seeds 5 stars`
+
+## App behaviors
+
+- **Responsive main surface** — chat stays centered and readable; the cart shows
+ as a sidebar on wider windows and moves behind a header button on narrower layouts.
+- **Live tool inventory** — the welcome screen renders cards from
+ `GardenShopTools.Default.Tools`, so any new exported tool automatically appears there.
+- **Modal navigation** — catalog, cart, and orders open through Shell routes as
+ animated modal overlays.
+- **Deep navigation** — the AI navigates directly to product detail and review
+ pages using template-style URIs via `Microsoft.Maui.AI.Navigation`.
+- **Approval flow** — checkout and destructive actions pause the chat and show an
+ inline approve/reject banner.
+
+## Tool sources and lifetimes
+
+`GardenShopTools` composes several very different source types with repeated
+`[AIToolSource]` attributes — no hand-written wrapper classes required.
+The sample uses an **explicit** context on purpose to curate the exact set of
+tools Sage should see, even though the library can also auto-generate an
+assembly-wide context for the whole app.
+
+| Source type | Lifetime | What it contributes |
+|---|---|---|
+| `ProductCatalog` | static | Catalog browsing tools like `list_all_products`, `search_products`, and `get_product` |
+| `CurrentCart` | singleton | Cart inspection and mutation tools like `show_list`, `add_to_list`, `change_qty`, and `remove_from_list` |
+| `IOrderArchive` | singleton interface | Past-order lookup, `checkout_list`, `reorder`, and `clear_past_orders` |
+| `AINavigationService` | singleton | Route-aware navigation: `get_routes`, `get_current_route`, `navigate` |
+| `CartViewModel` | singleton | Accessor-level tools: `get_cart_mode` / `set_cart_mode` |
+| `CatalogViewModel` | transient | `recommend_bundle`, a page-local bundle recommender that returns a starter kit without mutating the cart |
+
+This sample is especially useful if you want to see a **transient view-model**
+participate in a shared tool context while still writing through to singleton state.
+
+## Tool scenarios
+
+| Area | Tools |
+|---|---|
+| Catalog discovery | `list_all_products`, `search_products`, `get_product` |
+| Cart management | `show_list`, `add_to_list`, `change_qty`, `remove_from_list`, `cancel_list` |
+| Cart presentation | `get_cart_mode`, `set_cart_mode` |
+| Orders | `list_past_orders`, `find_order`, `checkout_list`, `reorder`, `clear_past_orders` |
+| Page navigation | `get_routes`, `get_current_route`, `navigate` |
+| Recommendations | `recommend_bundle` |
+
+## Feature showcase
+
+| Feature | Where |
+|---|---|
+| `[ExportAIFunction]` on a **static property** | `Services/Catalog/ProductCatalog.cs` → `All` / `list_all_products` |
+| `[ExportAIFunction]` on a **static method** with an optional param | `ProductCatalog.SearchProducts` |
+| Custom tool names (method ≠ tool name) | `ProductCatalog.FindByName` → `get_product`, `CurrentCart.SetQuantity` → `change_qty` |
+| `[ExportAIFunction]` on a **singleton DI service** | `Services/Cart/CurrentCart.cs` |
+| `[ExportAIFunction]` on an **interface** | `Services/Order/IOrderArchive.cs` |
+| `[FromServices]` parameter injection | `IOrderArchive.Checkout([FromServices] CurrentCart cart)` |
+| Accessor-level property tools | `ViewModels/Cart/CartViewModel.cs` → `get_cart_mode` / `set_cart_mode` |
+| Transient tool host | `ViewModels/Catalog/CatalogViewModel.cs` → `recommend_bundle` |
+| Shell modal navigation tools | `Services/Navigation/AINavigationService.cs` + `AppShell.xaml.cs` |
+| AI-library bridge wrapper | `AINavigationService` wraps `ShellNavigationService` from `Microsoft.Maui.AI.Navigation` |
+| Dynamic system prompt with route discovery | `ViewModels/Chat/ChatViewModel.cs` → `BuildSystemPrompt()` |
+| Responsive welcome cards and centered chat layout | `Views/ChatView.xaml` + `Pages/MainPage.xaml` |
+
+## Approval flow
+
+`checkout_list`, `cancel_list`, and `clear_past_orders` carry
+`[ExportAIFunction(ApprovalRequired = true)]`. When the model requests one of
+those actions, the input bar is replaced by an approval banner until you accept
+or reject it.
+
+## Build & run
+
+```bash
+dotnet build samples/AIExtensions.Sample.Garden -f net10.0-maccatalyst
+```
+
+Configure user secrets (shared across AI Extensions samples):
+
+```bash
+dotnet user-secrets --id ai-attributes-secrets set "AI:Endpoint" ""
+dotnet user-secrets --id ai-attributes-secrets set "AI:ApiKey" ""
+dotnet user-secrets --id ai-attributes-secrets set "AI:DeploymentName" ""
+```
diff --git a/samples/AIExtensions.Sample.Garden/Resources/AppIcon/appicon.svg b/samples/AIExtensions.Sample.Garden/Resources/AppIcon/appicon.svg
new file mode 100644
index 000000000..9d63b6513
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Resources/AppIcon/appicon.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/samples/AIExtensions.Sample.Garden/Resources/AppIcon/appiconfg.svg b/samples/AIExtensions.Sample.Garden/Resources/AppIcon/appiconfg.svg
new file mode 100644
index 000000000..21dfb25f1
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Resources/AppIcon/appiconfg.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/samples/AIExtensions.Sample.Garden/Resources/Fonts/FluentSystemIcons-Filled.ttf b/samples/AIExtensions.Sample.Garden/Resources/Fonts/FluentSystemIcons-Filled.ttf
new file mode 100644
index 000000000..7a63e2fb9
Binary files /dev/null and b/samples/AIExtensions.Sample.Garden/Resources/Fonts/FluentSystemIcons-Filled.ttf differ
diff --git a/samples/AIExtensions.Sample.Garden/Resources/Fonts/OpenSans-Regular.ttf b/samples/AIExtensions.Sample.Garden/Resources/Fonts/OpenSans-Regular.ttf
new file mode 100644
index 000000000..2cc82d2ed
Binary files /dev/null and b/samples/AIExtensions.Sample.Garden/Resources/Fonts/OpenSans-Regular.ttf differ
diff --git a/samples/AIExtensions.Sample.Garden/Resources/Fonts/OpenSans-Semibold.ttf b/samples/AIExtensions.Sample.Garden/Resources/Fonts/OpenSans-Semibold.ttf
new file mode 100644
index 000000000..fcb528467
Binary files /dev/null and b/samples/AIExtensions.Sample.Garden/Resources/Fonts/OpenSans-Semibold.ttf differ
diff --git a/samples/AIExtensions.Sample.Garden/Resources/Images/dotnet_bot.png b/samples/AIExtensions.Sample.Garden/Resources/Images/dotnet_bot.png
new file mode 100644
index 000000000..054167e59
Binary files /dev/null and b/samples/AIExtensions.Sample.Garden/Resources/Images/dotnet_bot.png differ
diff --git a/samples/AIExtensions.Sample.Garden/Resources/Splash/splash.svg b/samples/AIExtensions.Sample.Garden/Resources/Splash/splash.svg
new file mode 100644
index 000000000..21dfb25f1
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Resources/Splash/splash.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/samples/AIExtensions.Sample.Garden/Resources/Styles/Colors.xaml b/samples/AIExtensions.Sample.Garden/Resources/Styles/Colors.xaml
new file mode 100644
index 000000000..603c3eedf
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Resources/Styles/Colors.xaml
@@ -0,0 +1,127 @@
+
+
+
+
+ #5B8C5A
+ #3D6B3D
+ #D4E8D4
+ #C9A96E
+ #C9A96E
+ #8B6F47
+ #C62828
+
+ #FFFFFF
+ #000000
+
+
+ #E8E0D4
+ #D4CABF
+ #B5ADA2
+ #968E83
+ #7A7062
+ #4A4238
+ #2C2416
+ #1C1B18
+
+
+ #D600AA
+ #190649
+ #1f1f1f
+
+
+ 48
+ 28
+ 20
+ 17
+ 14
+ 12
+
+
+
+ #F2F5F0
+ #1A1A18
+ #E0EBDD
+ #15201A
+
+
+ #FFFFFF
+ #243024
+ #D5E5D2
+ #3A4A38
+ #F6FAF5
+ #2A3A2A
+
+
+ #FFFFFF
+ #222222
+ #D0D8CE
+ #3A4A3A
+
+
+ #5A6A5A
+ #8AA08A
+ #7A8A7A
+ #6A8068
+ #3A5A3A
+ #B4D4B4
+ #555555
+ #AAAAAA
+
+
+ #E8C9A8
+ #604830
+ #3A2A1A
+ #F0E0D0
+ #FFFFFF
+ #2A2A2A
+
+
+ #E3F2E3
+ #2A3A2A
+ #E8F2E6
+ #2E3A2C
+ #B8D4B4
+ #3A4A3A
+
+
+ #FFFFFF
+ #263026
+ #CFE0CD
+ #3A4A3A
+
+
+ #FFF8E1
+ #3E3520
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/AIExtensions.Sample.Garden/Resources/Styles/Styles.xaml b/samples/AIExtensions.Sample.Garden/Resources/Styles/Styles.xaml
new file mode 100644
index 000000000..5fef12ae8
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Resources/Styles/Styles.xaml
@@ -0,0 +1,434 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Services/Cart/CurrentCart.cs b/samples/AIExtensions.Sample.Garden/Services/Cart/CurrentCart.cs
new file mode 100644
index 000000000..e0436c645
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Services/Cart/CurrentCart.cs
@@ -0,0 +1,116 @@
+using System.ComponentModel;
+using AIExtensions.Sample.Garden.Messages;
+using AIExtensions.Sample.Garden.Models;
+using CommunityToolkit.Mvvm.Messaging;
+using Microsoft.Maui.AI.Attributes;
+
+namespace AIExtensions.Sample.Garden.Services;
+
+///
+/// Manages the active shopping cart. Registered as a singleton in DI;
+/// call to empty the cart.
+/// Demonstrates: [ExportAIFunction] on instance methods and properties of a DI service.
+///
+public sealed class CurrentCart
+{
+ private readonly List _items = [];
+
+ private void NotifyChanged() =>
+ WeakReferenceMessenger.Default.Send(new CartChangedMessage());
+
+ // Feature: [ExportAIFunction] on an instance property — the generator
+ // resolves CurrentCart from DI then reads the getter.
+ [ExportAIFunction("show_list")]
+ [Description("Returns every item currently in the shopping cart with quantity, unit price, and subtotal.")]
+ public IReadOnlyList Items => [.. _items];
+
+ public ListItem? FindItem(string skuOrName)
+ {
+ var product = ProductCatalog.FindByName(skuOrName);
+ if (product is null)
+ return null;
+ return _items.FirstOrDefault(i => string.Equals(i.Product.Sku, product.Sku, StringComparison.OrdinalIgnoreCase));
+ }
+
+ // Feature: [ExportAIFunction] with described parameters — each
+ // [Description] becomes part of the JSON schema the AI model sees.
+ [ExportAIFunction("add_to_list")]
+ [Description("Adds a product to the cart, or increments the quantity if it's already there.")]
+ public ListItem AddItem(
+ [Description("Product sku or name (e.g., 'seed-tomato' or 'Heirloom Tomato Seeds').")] string skuOrName,
+ [Description("How many to add. Defaults to 1.")] int quantity = 1)
+ {
+ if (quantity <= 0)
+ throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
+
+ var product = ProductCatalog.FindByName(skuOrName)
+ ?? throw new InvalidOperationException($"No product matched '{skuOrName}'. Try search_products to browse the catalog.");
+
+ var idx = _items.FindIndex(i => string.Equals(i.Product.Sku, product.Sku, StringComparison.OrdinalIgnoreCase));
+ ListItem updated;
+ if (idx >= 0)
+ {
+ updated = _items[idx] with { Quantity = _items[idx].Quantity + quantity };
+ _items[idx] = updated;
+ }
+ else
+ {
+ updated = new ListItem(product, quantity);
+ _items.Add(updated);
+ }
+ NotifyChanged();
+ return updated;
+ }
+
+ // Feature: [ExportAIFunction] with a custom tool name that differs from
+ // the method name. The AI sees "change_qty" but the method is SetQuantity.
+ [ExportAIFunction("change_qty")]
+ [Description("Sets a new quantity for an item in the cart. Setting it to 0 removes the item.")]
+ public ListItem? SetQuantity(
+ [Description("Product sku or name already in the cart.")] string skuOrName,
+ [Description("The new quantity. Use 0 to remove the item.")] int quantity)
+ {
+ var product = ProductCatalog.FindByName(skuOrName)
+ ?? throw new InvalidOperationException($"No product matched '{skuOrName}'.");
+
+ if (quantity <= 0)
+ {
+ RemoveItem(product.Sku);
+ return null;
+ }
+
+ var idx = _items.FindIndex(i => string.Equals(i.Product.Sku, product.Sku, StringComparison.OrdinalIgnoreCase));
+ if (idx < 0)
+ return null;
+
+ var updated = _items[idx] with { Quantity = quantity };
+ _items[idx] = updated;
+ NotifyChanged();
+ return updated;
+ }
+
+ [ExportAIFunction("remove_from_list")]
+ [Description("Removes a product from the cart entirely.")]
+ public bool RemoveItem(
+ [Description("Product sku or name to remove.")] string skuOrName)
+ {
+ var product = ProductCatalog.FindByName(skuOrName);
+ if (product is null)
+ return false;
+
+ var idx = _items.FindIndex(i => string.Equals(i.Product.Sku, product.Sku, StringComparison.OrdinalIgnoreCase));
+ if (idx < 0)
+ return false;
+ _items.RemoveAt(idx);
+ NotifyChanged();
+ return true;
+ }
+
+ [ExportAIFunction("cancel_list", ApprovalRequired = true)]
+ [Description("Discards every item from the cart.")]
+ public void Clear()
+ {
+ _items.Clear();
+ NotifyChanged();
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Services/Catalog/ProductCatalog.cs b/samples/AIExtensions.Sample.Garden/Services/Catalog/ProductCatalog.cs
new file mode 100644
index 000000000..7ec3ce4a0
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Services/Catalog/ProductCatalog.cs
@@ -0,0 +1,78 @@
+using System.ComponentModel;
+using AIExtensions.Sample.Garden.Models;
+using Microsoft.Maui.AI.Attributes;
+
+namespace AIExtensions.Sample.Garden.Services;
+
+///
+/// Hard-coded product catalog for the garden shop.
+/// Demonstrates: exporting tools from a static class.
+///
+public static class ProductCatalog
+{
+ // Feature: [ExportAIFunction] on a static property — exposes a read-only
+ // collection as a zero-parameter AI tool. The generator treats the getter
+ // as a parameterless method and emits a schema with no inputs.
+ [ExportAIFunction("list_all_products")]
+ [Description("Returns every product in the garden shop catalog.")]
+ public static IReadOnlyList All { get; } =
+ [
+ // Seeds — icon font glyphs (FluentFilled)
+ new("seed-tomato", "Heirloom Tomato Seeds", "Seeds", 3.49m, FluentIcons.Food),
+ new("seed-basil", "Sweet Basil Seeds", "Seeds", 2.49m, FluentIcons.LeafOne),
+ new("seed-pepper", "Bell Pepper Seeds", "Seeds", 2.99m, FluentIcons.Temperature),
+ new("seed-sunflower", "Giant Sunflower Seeds", "Seeds", 3.99m, FluentIcons.WeatherSunny),
+ new("seed-lettuce", "Mixed Lettuce Seeds", "Seeds", 2.29m, FluentIcons.BowlSalad),
+
+ // Soil & amendments
+ new("soil-pottingmix", "All-Purpose Potting Mix", "Soil", 11.99m, FluentIcons.Earth),
+ new("soil-compost", "Organic Compost (10 lb)", "Soil", 8.49m, FluentIcons.LeafThree),
+ new("soil-mulch", "Cedar Mulch (2 cu ft)", "Soil", 14.99m, FluentIcons.Box),
+
+ // Fertilizer
+ new("fert-tomato", "Tomato Plant Food", "Fertilizer", 9.99m, FluentIcons.Drop),
+ new("fert-allpurpose", "All-Purpose Fertilizer", "Fertilizer", 7.99m, FluentIcons.Beaker),
+
+ // Tools & equipment
+ new("tool-trowel", "Hand Trowel", "Tools", 12.49m, FluentIcons.Wrench),
+ new("tool-pruner", "Bypass Pruners", "Tools", 18.99m, FluentIcons.Cut),
+ new("tool-glove", "Garden Gloves (pair)", "Tools", 6.99m, FluentIcons.HandRight),
+ new("tool-hose", "50 ft Garden Hose", "Equipment", 29.99m, FluentIcons.PaintBrush),
+ new("tool-watering", "Watering Can (1 gal)", "Equipment", 14.99m, FluentIcons.Drop),
+ ];
+
+ // Feature: [ExportAIFunction] on a static method with an optional parameter.
+ // The generator emits a schema where 'query' is not required, letting the
+ // AI call it with or without a filter string.
+ [ExportAIFunction("search_products")]
+ [Description("Searches the garden shop catalog by name, category, or sku. Returns every product when no query is given.")]
+ public static List SearchProducts(
+ [Description("Optional text to filter by product name, sku, or category. Leave blank to list everything.")]
+ string? query = null)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ return [.. All];
+
+ var q = query.Trim();
+ return [.. All.Where(p =>
+ p.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
+ p.Sku.Contains(q, StringComparison.OrdinalIgnoreCase) ||
+ p.Category.Contains(q, StringComparison.OrdinalIgnoreCase))];
+ }
+
+ // Feature: [ExportAIFunction] with a custom tool name that differs from
+ // the method name. The AI sees "get_product" but the real method is FindByName.
+ [ExportAIFunction("get_product")]
+ [Description("Looks up a single product by sku or exact name.")]
+ public static Product? FindByName(
+ [Description("The product sku or exact name (e.g., 'seed-tomato' or 'Heirloom Tomato Seeds').")]
+ string nameOrSku)
+ {
+ if (string.IsNullOrWhiteSpace(nameOrSku))
+ return null;
+ var q = nameOrSku.Trim();
+ return All.FirstOrDefault(p => string.Equals(p.Sku, q, StringComparison.OrdinalIgnoreCase))
+ ?? All.FirstOrDefault(p => string.Equals(p.Name, q, StringComparison.OrdinalIgnoreCase))
+ ?? All.FirstOrDefault(p => p.Name.Contains(q, StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Services/Navigation/AINavigationService.cs b/samples/AIExtensions.Sample.Garden/Services/Navigation/AINavigationService.cs
new file mode 100644
index 000000000..8bc8f45f6
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Services/Navigation/AINavigationService.cs
@@ -0,0 +1,31 @@
+using System.ComponentModel;
+using Microsoft.Maui.AI.Attributes;
+using Microsoft.Maui.AI.Navigation;
+
+namespace AIExtensions.Sample.Garden.Services;
+
+///
+/// Thin wrapper that adds [ExportAIFunction] to the library's
+/// . The library itself has no
+/// dependency on AI.Attributes — this wrapper bridges the two.
+///
+public sealed class AINavigationService
+{
+ private readonly ShellNavigationService _inner;
+
+ public AINavigationService(ShellNavigationService inner) => _inner = inner;
+
+ [ExportAIFunction("get_routes")]
+ [Description("Lists all available navigation routes in the app with their full paths and query parameters. Use this to discover where you can navigate and what parameters each page accepts.")]
+ public IReadOnlyList GetRoutes() => _inner.GetRoutes();
+
+ [ExportAIFunction("get_current_route")]
+ [Description("Returns the current Shell navigation location as a URI string.")]
+ public string GetCurrentRoute() => _inner.GetCurrentRoute();
+
+ [ExportAIFunction("navigate")]
+ [Description("Navigate to a page using a clean URI. Put parameter values directly in the path after the route segment that accepts them. Examples: '//main/products/product/seed-tomato' (product detail), '//main/products/product/seed-tomato/review' (review for that product), '//main/orders/order/ORD-00001' (order detail). Special: '..' (back), '//main/chat' (home), 'cart' (modal).")]
+ public Task NavigateAsync(
+ [Description("Clean URI with parameter values inline in the path. Examples: '//main/products/product/seed-tomato', '//main/products/product/seed-basil/review', '//main/orders/order/ORD-00001'.")]
+ string route) => _inner.NavigateAsync(route);
+}
diff --git a/samples/AIExtensions.Sample.Garden/Services/Order/IOrderArchive.cs b/samples/AIExtensions.Sample.Garden/Services/Order/IOrderArchive.cs
new file mode 100644
index 000000000..a452302dd
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Services/Order/IOrderArchive.cs
@@ -0,0 +1,60 @@
+using System.ComponentModel;
+using AIExtensions.Sample.Garden.Models;
+using Microsoft.Maui.AI.Attributes;
+
+namespace AIExtensions.Sample.Garden.Services;
+
+///
+/// Persists completed orders and supports checkout and reorder flows.
+///
+/// Demonstrates: placing [ExportAIFunction] on an interface. The source
+/// generator targets whatever implementation is registered in DI, so
+/// swapping InMemoryOrderArchive for PreferencesOrderArchive (or any
+/// other implementation) requires zero changes to the AI tool wiring.
+///
+public interface IOrderArchive
+{
+ ///
+ /// Every past order, newest first.
+ /// Demonstrates: [ExportAIFunction] on an interface property.
+ ///
+ [ExportAIFunction("list_past_orders")]
+ [Description("Lists every past order, newest first.")]
+ IReadOnlyList Orders { get; }
+
+ ///
+ /// Looks up a single order by its id.
+ ///
+ [ExportAIFunction("find_order")]
+ [Description("Looks up a single past order by its id.")]
+ Order? FindOrder(
+ [Description("The order id (from list_past_orders).")] string orderId);
+
+ ///
+ /// Finalizes the current cart as an order and clears the cart.
+ /// Demonstrates: [ExportAIFunction] with ApprovalRequired on an
+ /// interface method, and [FromServices] to inject a sibling DI
+ /// service that the AI model never sees as a parameter.
+ ///
+ [ExportAIFunction("checkout_list", ApprovalRequired = true)]
+ [Description("Checks out the current cart as a finalized order and clears the cart.")]
+ Order Checkout([FromServices] CurrentCart cart);
+
+ ///
+ /// Copies every item from a past order into the current cart.
+ /// Demonstrates: [FromServices] parameter injection on an interface
+ /// method — the generator wires up CurrentCart from DI automatically.
+ ///
+ [ExportAIFunction("reorder")]
+ [Description("Copies every item from a past order into the current cart.")]
+ void Reorder(
+ [Description("The id of the past order to copy (from list_past_orders).")] string orderId,
+ [FromServices] CurrentCart cart);
+
+ ///
+ /// Removes all past orders from the archive.
+ ///
+ [ExportAIFunction("clear_past_orders", ApprovalRequired = true)]
+ [Description("Removes all past orders from the archive. This action cannot be undone.")]
+ void Clear();
+}
diff --git a/samples/AIExtensions.Sample.Garden/Services/Order/InMemoryOrderArchive.cs b/samples/AIExtensions.Sample.Garden/Services/Order/InMemoryOrderArchive.cs
new file mode 100644
index 000000000..a3a43a9c3
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Services/Order/InMemoryOrderArchive.cs
@@ -0,0 +1,44 @@
+using AIExtensions.Sample.Garden.Models;
+
+namespace AIExtensions.Sample.Garden.Services;
+
+///
+/// In-memory order archive. Orders live for the lifetime of the app
+/// and are lost on restart. Good for demos and testing.
+///
+public sealed class InMemoryOrderArchive : IOrderArchive
+{
+ private readonly List _orders = [];
+
+ public IReadOnlyList Orders => _orders;
+
+ public Order? FindOrder(string orderId) =>
+ _orders.FirstOrDefault(o => string.Equals(o.Id, orderId, StringComparison.OrdinalIgnoreCase));
+
+ public Order Checkout(CurrentCart cart)
+ {
+ var items = cart.Items;
+ if (items.Count == 0)
+ throw new InvalidOperationException("The cart is empty — nothing to check out.");
+
+ var next = _orders.Count + 1;
+ var order = new Order(
+ Id: $"ORD-{next:D5}",
+ PlacedAt: DateTime.Now,
+ Items: [.. items]);
+ _orders.Insert(0, order);
+
+ cart.Clear();
+ return order;
+ }
+
+ public void Reorder(string orderId, CurrentCart cart)
+ {
+ var order = FindOrder(orderId)
+ ?? throw new InvalidOperationException($"No past order with id '{orderId}'. Call list_past_orders to see available ids.");
+ foreach (var item in order.Items)
+ cart.AddItem(item.Product.Sku, item.Quantity);
+ }
+
+ public void Clear() => _orders.Clear();
+}
diff --git a/samples/AIExtensions.Sample.Garden/Services/Order/PreferencesOrderArchive.cs b/samples/AIExtensions.Sample.Garden/Services/Order/PreferencesOrderArchive.cs
new file mode 100644
index 000000000..b49f71202
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Services/Order/PreferencesOrderArchive.cs
@@ -0,0 +1,97 @@
+using System.Text.Json;
+using AIExtensions.Sample.Garden.Models;
+
+namespace AIExtensions.Sample.Garden.Services;
+
+///
+/// Preferences-backed order archive. Orders survive app restarts.
+/// Demonstrates that any IOrderArchive implementation automatically
+/// inherits AI tool capability — no attribute changes needed.
+///
+public sealed class PreferencesOrderArchive : IOrderArchive
+{
+ private const string StorageKey = "garden_orders";
+
+ private static readonly JsonSerializerOptions s_json = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
+
+ private List? _cache;
+
+ public IReadOnlyList Orders => LoadOrders();
+
+ public Order? FindOrder(string orderId) =>
+ LoadOrders().FirstOrDefault(o => string.Equals(o.Id, orderId, StringComparison.OrdinalIgnoreCase));
+
+ public Order Checkout(CurrentCart cart)
+ {
+ var items = cart.Items;
+ if (items.Count == 0)
+ throw new InvalidOperationException("The cart is empty — nothing to check out.");
+
+ var order = new Order(
+ Id: NextOrderId(),
+ PlacedAt: DateTime.Now,
+ Items: [.. items]);
+
+ var orders = LoadOrders();
+ orders.Insert(0, order);
+ Save(orders);
+
+ cart.Clear();
+ return order;
+ }
+
+ public void Reorder(string orderId, CurrentCart cart)
+ {
+ var order = FindOrder(orderId)
+ ?? throw new InvalidOperationException($"No past order with id '{orderId}'. Call list_past_orders to see available ids.");
+ foreach (var item in order.Items)
+ cart.AddItem(item.Product.Sku, item.Quantity);
+ }
+
+ public void Clear()
+ {
+ _cache = [];
+ Preferences.Default.Remove(StorageKey);
+ }
+
+ private List LoadOrders()
+ {
+ if (_cache is not null)
+ return _cache;
+
+ var json = Preferences.Default.Get(StorageKey, null);
+ if (string.IsNullOrEmpty(json))
+ {
+ _cache = [];
+ return _cache;
+ }
+
+ try
+ {
+ _cache = JsonSerializer.Deserialize>(json, s_json) ?? [];
+ }
+ catch
+ {
+ _cache = [];
+ }
+ return _cache;
+ }
+
+ private void Save(List orders)
+ {
+ _cache = orders;
+ var json = JsonSerializer.Serialize(orders, s_json);
+ Preferences.Default.Set(StorageKey, json);
+ }
+
+ private static string NextOrderId()
+ {
+ const string counterKey = "garden_order_counter";
+ var next = Preferences.Default.Get(counterKey, 0) + 1;
+ Preferences.Default.Set(counterKey, next);
+ return $"ORD-{next:D5}";
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Services/Reviews/ReviewStore.cs b/samples/AIExtensions.Sample.Garden/Services/Reviews/ReviewStore.cs
new file mode 100644
index 000000000..986070c0c
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Services/Reviews/ReviewStore.cs
@@ -0,0 +1,45 @@
+using System.ComponentModel;
+using AIExtensions.Sample.Garden.Models;
+using Microsoft.Maui.AI.Attributes;
+
+namespace AIExtensions.Sample.Garden.Services;
+
+///
+/// In-memory review store. Registered as a singleton in DI.
+///
+public sealed class ReviewStore
+{
+ private readonly List _reviews = [];
+
+ [ExportAIFunction("list_reviews")]
+ [Description("Lists all product reviews, newest first.")]
+ public IReadOnlyList Reviews => [.. _reviews.OrderByDescending(r => r.CreatedAt)];
+
+ [ExportAIFunction("get_product_reviews")]
+ [Description("Gets reviews for a specific product by sku.")]
+ public IReadOnlyList GetProductReviews(
+ [Description("The product sku to get reviews for.")] string sku)
+ => [.. _reviews.Where(r => string.Equals(r.ProductSku, sku, StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(r => r.CreatedAt)];
+
+ [ExportAIFunction("submit_review")]
+ [Description("Submit a product review with a rating (1-5) and optional comment.")]
+ public ProductReview Submit(
+ [Description("The product sku to review.")] string sku,
+ [Description("Rating from 1 (worst) to 5 (best).")] int rating,
+ [Description("Optional review comment.")] string? comment = null)
+ {
+ if (rating < 1 || rating > 5)
+ throw new ArgumentOutOfRangeException(nameof(rating), "Rating must be between 1 and 5.");
+
+ var review = new ProductReview(sku, rating, comment, DateTime.UtcNow);
+ _reviews.Add(review);
+ return review;
+ }
+
+ public double? AverageRating(string sku)
+ {
+ var productReviews = _reviews.Where(r => string.Equals(r.ProductSku, sku, StringComparison.OrdinalIgnoreCase)).ToList();
+ return productReviews.Count == 0 ? null : productReviews.Average(r => r.Rating);
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModelBinder.cs b/samples/AIExtensions.Sample.Garden/ViewModelBinder.cs
new file mode 100644
index 000000000..72e21c505
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModelBinder.cs
@@ -0,0 +1,33 @@
+namespace AIExtensions.Sample.Garden;
+
+///
+/// Attached property that auto-resolves a ViewModel from DI and sets it as BindingContext.
+/// Usage: <views:ChatView local:ViewModelBinder.Type="{x:Type vm:ChatViewModel}" />
+///
+public static class ViewModelBinder
+{
+ public static readonly BindableProperty TypeProperty =
+ BindableProperty.CreateAttached("Type", typeof(Type), typeof(ViewModelBinder), null,
+ propertyChanged: OnTypeChanged);
+
+ public static Type? GetType(BindableObject obj) => (Type?)obj.GetValue(TypeProperty);
+ public static void SetType(BindableObject obj, Type? value) => obj.SetValue(TypeProperty, value);
+
+ private static void OnTypeChanged(BindableObject bindable, object? oldValue, object? newValue)
+ {
+ if (bindable is not Element element || newValue is not Type vmType)
+ return;
+
+ element.HandlerChanged += (_, _) => TryResolve(element, vmType);
+ TryResolve(element, vmType);
+ }
+
+ private static void TryResolve(Element element, Type vmType)
+ {
+ if (element.IsSet(BindableObject.BindingContextProperty))
+ return;
+
+ if (element.Handler?.MauiContext?.Services?.GetService(vmType) is { } vm)
+ element.BindingContext = vm;
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Cart/CartItemViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Cart/CartItemViewModel.cs
new file mode 100644
index 000000000..6648d642b
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Cart/CartItemViewModel.cs
@@ -0,0 +1,17 @@
+using AIExtensions.Sample.Garden.Models;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// View-model wrapper around a for display in the cart.
+///
+public sealed class CartItemViewModel(ListItem item)
+{
+ public ListItem Item { get; } = item;
+
+ public string Sku => Item.Product.Sku;
+ public string Name => Item.Product.Name;
+ public string Emoji => Item.Product.Emoji;
+ public int Quantity => Item.Quantity;
+ public string QuantityLine => $"× {Item.Quantity} · {Item.Subtotal:C}";
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Cart/CartMode.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Cart/CartMode.cs
new file mode 100644
index 000000000..8b8ee1748
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Cart/CartMode.cs
@@ -0,0 +1,10 @@
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// Cart display modes.
+///
+public enum CartMode
+{
+ Normal,
+ Compact
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Cart/CartViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Cart/CartViewModel.cs
new file mode 100644
index 000000000..151080172
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Cart/CartViewModel.cs
@@ -0,0 +1,156 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using AIExtensions.Sample.Garden.Messages;
+using AIExtensions.Sample.Garden.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+using Microsoft.Maui.AI.Attributes;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// Owns all cart state: items, display mode, checkout, and the AI tools
+/// that manipulate the cart display. Designed to be reusable — any page
+/// can host a cart view bound to this VM.
+///
+public sealed partial class CartViewModel : ObservableObject, IRecipient
+{
+ private readonly CurrentCart _currentCart;
+ private readonly IOrderArchive _archive;
+
+ public CartViewModel(CurrentCart currentCart, IOrderArchive archive)
+ {
+ _currentCart = currentCart;
+ _archive = archive;
+
+ WeakReferenceMessenger.Default.Register(this);
+ Refresh();
+ }
+
+ void IRecipient.Receive(CartChangedMessage message) => Refresh();
+
+ public ObservableCollection Items { get; } = [];
+
+ [ObservableProperty]
+ public partial string CartTotal { get; set; } = $"Total: {0:C}";
+
+ [ObservableProperty]
+ public partial bool HasItems { get; set; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsNormalMode))]
+ [NotifyPropertyChangedFor(nameof(IsCompactMode))]
+ [NotifyPropertyChangedFor(nameof(CartModeLabel))]
+ public partial CartMode CartMode
+ {
+ [ExportAIFunction("get_cart_mode")]
+ [Description("Get the current cart display mode.")]
+ get;
+ [ExportAIFunction("set_cart_mode")]
+ [Description("Change the shopping cart display mode. 'normal' shows full cards with icons and details. 'compact' shows dense single-line rows.")]
+ set;
+ } = CartMode.Normal;
+
+ public bool IsNormalMode => CartMode == CartMode.Normal;
+ public bool IsCompactMode => CartMode == CartMode.Compact;
+ public string CartModeLabel => CartMode switch
+ {
+ CartMode.Normal => "Compact",
+ CartMode.Compact => "Normal",
+ _ => "Toggle"
+ };
+
+ [RelayCommand]
+ private void CycleCartMode()
+ {
+ CartMode = CartMode switch
+ {
+ CartMode.Normal => CartMode.Compact,
+ CartMode.Compact => CartMode.Normal,
+ _ => CartMode.Normal
+ };
+ }
+
+ [RelayCommand]
+ private void Checkout()
+ {
+ if (_currentCart.Items.Count == 0)
+ return;
+
+ _archive.Checkout(_currentCart);
+ }
+
+ [RelayCommand]
+ private void AddFromCatalog(string? sku)
+ {
+ if (string.IsNullOrWhiteSpace(sku))
+ return;
+
+ _currentCart.AddItem(sku);
+ }
+
+ ///
+ /// Refresh the observable collections from the underlying cart model.
+ ///
+ public void Refresh()
+ {
+ var source = _currentCart.Items;
+
+ SyncCollection(Items, source, v => v.Sku, i => i.Product.Sku, i => new CartItemViewModel(i));
+
+ CartTotal = $"Total: {source.Sum(i => i.Subtotal):C}";
+ HasItems = source.Count > 0;
+ }
+
+ ///
+ /// Clear cart and reset mode.
+ ///
+ public void Clear()
+ {
+ _currentCart.Clear();
+ CartMode = CartMode.Normal;
+ }
+
+ // ─────────────────────────────────────────────────────────────────
+
+ private static void SyncCollection(
+ ObservableCollection target,
+ IReadOnlyList source,
+ Func vmKey,
+ Func modelKey,
+ Func create)
+ {
+ for (int sourceIndex = 0; sourceIndex < source.Count; sourceIndex++)
+ {
+ var model = source[sourceIndex];
+ var key = modelKey(model);
+ var existingIndex = -1;
+
+ for (int targetIndex = 0; targetIndex < target.Count; targetIndex++)
+ {
+ if (vmKey(target[targetIndex]) == key)
+ {
+ existingIndex = targetIndex;
+ break;
+ }
+ }
+
+ var viewModel = create(model);
+
+ if (existingIndex < 0)
+ {
+ target.Insert(sourceIndex, viewModel);
+ continue;
+ }
+
+ if (existingIndex != sourceIndex)
+ target.Move(existingIndex, sourceIndex);
+
+ target[sourceIndex] = viewModel;
+ }
+
+ while (target.Count > source.Count)
+ target.RemoveAt(target.Count - 1);
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Catalog/CatalogGroupViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Catalog/CatalogGroupViewModel.cs
new file mode 100644
index 000000000..7dea13812
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Catalog/CatalogGroupViewModel.cs
@@ -0,0 +1,9 @@
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// Group header for catalog categories.
+///
+public sealed class CatalogGroupViewModel(string categoryName) : List
+{
+ public string CategoryName { get; } = categoryName;
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Catalog/CatalogItemViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Catalog/CatalogItemViewModel.cs
new file mode 100644
index 000000000..53ac165ac
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Catalog/CatalogItemViewModel.cs
@@ -0,0 +1,16 @@
+using AIExtensions.Sample.Garden.Models;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// Represents a single product in the catalog grid.
+///
+public sealed class CatalogItemViewModel(Product product)
+{
+ public string Sku { get; } = product.Sku;
+ public string Name { get; } = product.Name;
+ public string Emoji { get; } = product.Emoji;
+ public decimal Price { get; } = product.Price;
+ public string PriceLabel { get; } = $"{product.Price:C}";
+ public string Category { get; } = product.Category;
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Catalog/CatalogViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Catalog/CatalogViewModel.cs
new file mode 100644
index 000000000..2b07c083b
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Catalog/CatalogViewModel.cs
@@ -0,0 +1,91 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using AIExtensions.Sample.Garden.Models;
+using AIExtensions.Sample.Garden.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Maui.AI.Attributes;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// Owns the product catalog data and the add-to-cart action.
+///
+public sealed partial class CatalogViewModel : ObservableObject
+{
+ private readonly CurrentCart _currentCart;
+
+ public CatalogViewModel(CurrentCart currentCart)
+ {
+ _currentCart = currentCart;
+
+ var groups = ProductCatalog.All
+ .GroupBy(p => p.Category)
+ .Select(g =>
+ {
+ var group = new CatalogGroupViewModel(g.Key);
+ group.AddRange(g.Select(p => new CatalogItemViewModel(p)));
+ return group;
+ })
+ .ToList();
+
+ Products = new(groups.SelectMany(g => g));
+ Groups = groups;
+ }
+
+ public ObservableCollection Products { get; }
+
+ public IReadOnlyList Groups { get; }
+
+ [ExportAIFunction("recommend_bundle")]
+ [Description("Build a random starter bundle with seed packs, soil, fertilizer, and one tool or equipment item. Returns the bundle with quantities and total price but does not add anything to the cart.")]
+ public string RecommendBundle(
+ [Description("Optional focus like 'tomato', 'basil', 'flowers', or 'starter'. Leave blank for a surprise bundle.")]
+ string? focus = null)
+ {
+ static bool MatchesFocus(CatalogItemViewModel product, string? value) =>
+ !string.IsNullOrWhiteSpace(value) &&
+ (product.Name.Contains(value, StringComparison.OrdinalIgnoreCase) ||
+ product.Sku.Contains(value, StringComparison.OrdinalIgnoreCase) ||
+ product.Category.Contains(value, StringComparison.OrdinalIgnoreCase));
+
+ static CatalogItemViewModel PickRandom(List products, string category) =>
+ products.Count > 0
+ ? products[Random.Shared.Next(products.Count)]
+ : throw new InvalidOperationException($"No products found for category '{category}'.");
+
+ var seedOptions = Products.Where(p => p.Category == "Seeds").ToList();
+ var focusedSeeds = seedOptions.Where(p => MatchesFocus(p, focus)).ToList();
+ var seed = PickRandom(focusedSeeds.Count > 0 ? focusedSeeds : seedOptions, "Seeds");
+
+ var soil = PickRandom([.. Products.Where(p => p.Category == "Soil")], "Soil");
+ var fertilizer = PickRandom([.. Products.Where(p => p.Category == "Fertilizer")], "Fertilizer");
+ var gear = PickRandom([.. Products.Where(p => p.Category is "Tools" or "Equipment")], "Tools/Equipment");
+ var seedPacks = Random.Shared.Next(2, 6);
+
+ var lines = new[]
+ {
+ (Product: seed.Name, Qty: seedPacks, Unit: seed.Price, Subtotal: seed.Price * seedPacks),
+ (Product: soil.Name, Qty: 1, Unit: soil.Price, Subtotal: soil.Price),
+ (Product: fertilizer.Name, Qty: 1, Unit: fertilizer.Price, Subtotal: fertilizer.Price),
+ (Product: gear.Name, Qty: 1, Unit: gear.Price, Subtotal: gear.Price)
+ };
+
+ var total = lines.Sum(l => l.Subtotal);
+ var label = string.IsNullOrWhiteSpace(focus) ? "starter" : focus.Trim();
+
+ return
+ $"{label} bundle recommendation: " +
+ string.Join(", ", lines.Select(l => $"{l.Qty}x {l.Product} ({l.Subtotal:C})")) +
+ $". Total: {total:C}.";
+ }
+
+ [RelayCommand]
+ public void AddToCart(string? sku)
+ {
+ if (string.IsNullOrWhiteSpace(sku))
+ return;
+
+ _currentCart.AddItem(sku);
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Chat/ChatMessageKind.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Chat/ChatMessageKind.cs
new file mode 100644
index 000000000..ad98cd5b5
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Chat/ChatMessageKind.cs
@@ -0,0 +1,10 @@
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+public enum ChatMessageKind
+{
+ User,
+ Assistant,
+ Tool,
+ System,
+ Error,
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Chat/ChatMessageViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Chat/ChatMessageViewModel.cs
new file mode 100644
index 000000000..5e2461347
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Chat/ChatMessageViewModel.cs
@@ -0,0 +1,50 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// View model for one chat message row. is mutable so a
+/// streaming assistant reply can be updated in place while bound.
+///
+public sealed partial class ChatMessageViewModel(ChatMessageKind kind, string text, string? icon = null) : ObservableObject
+{
+ public ChatMessageKind Kind { get; } = kind;
+
+ ///
+ /// Optional Fluent icon glyph rendered with FluentFilled font.
+ ///
+ public string? Icon { get; } = icon;
+
+ public bool HasIcon => Icon is not null;
+
+ [ObservableProperty]
+ public partial string Text { get; set; } = text;
+
+ ///
+ /// Tool call arguments (JSON-formatted). Populated for Tool messages.
+ ///
+ [ObservableProperty]
+ public partial string? ToolArgs { get; set; }
+
+ ///
+ /// Tool call result. Populated after the tool returns.
+ ///
+ [ObservableProperty]
+ public partial string? ToolResult { get; set; }
+
+ public bool HasDetails => ToolArgs is not null || ToolResult is not null;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ExpandIcon))]
+ public partial bool IsExpanded { get; set; }
+
+ public string ExpandIcon => IsExpanded ? FluentIcons.ChevronDown : FluentIcons.ChevronRight;
+
+ [RelayCommand]
+ private void ToggleExpand()
+ {
+ if (HasDetails)
+ IsExpanded = !IsExpanded;
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Chat/ChatViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Chat/ChatViewModel.cs
new file mode 100644
index 000000000..614178dc0
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Chat/ChatViewModel.cs
@@ -0,0 +1,366 @@
+using System.Collections.ObjectModel;
+using System.Text;
+using AIExtensions.Sample.Garden.Messages;
+using AIExtensions.Sample.Garden.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+using Microsoft.Extensions.AI;
+using Microsoft.Maui.AI.Attributes;
+using Microsoft.Maui.AI.Navigation;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// Owns the AI chat loop, message history, tool invocation, and approval flow.
+/// Designed to be reusable — any page can host a ChatView bound to this VM.
+///
+public sealed partial class ChatViewModel : ObservableObject, IRecipient
+{
+ ///
+ /// Source-generated tool context that merges all tool sources into one.
+ /// Demonstrates several distinct attribute patterns:
+ ///
+ /// - Static class — ProductCatalog: tools on a plain static class.
+ /// - Instance class — CurrentCart: tools on a DI-registered instance.
+ /// - Interface — IOrderArchive: tools declared on the interface.
+ /// - Transient view-model — CatalogViewModel: stateless action tools that write through to singleton services.
+ /// - Navigation service — AINavigationService: route-aware navigate/get_routes/get_current_route.
+ ///
+ ///
+ [AIToolSource(typeof(ProductCatalog))]
+ [AIToolSource(typeof(CurrentCart))]
+ [AIToolSource(typeof(IOrderArchive))]
+ [AIToolSource(typeof(CartViewModel))]
+ [AIToolSource(typeof(CatalogViewModel))]
+ [AIToolSource(typeof(ReviewStore))]
+ [AIToolSource(typeof(AINavigationService))]
+ private partial class GardenShopTools : AIToolContext { }
+
+ private readonly IChatClient _chatClient;
+ private readonly ShellNavigationService _navigationService;
+ private List _history = [];
+ private ToolApprovalRequestContent? _pendingApproval;
+ private CancellationTokenSource _cts = new();
+
+ public ChatViewModel(IServiceProvider rootProvider, IChatClient innerChatClient, ShellNavigationService navigationService)
+ {
+ _chatClient = new ChatClientBuilder(innerChatClient)
+ .UseFunctionInvocation()
+ .Build(rootProvider);
+
+ _navigationService = navigationService;
+
+ WeakReferenceMessenger.Default.Register(this);
+
+ RefreshAvailableTools();
+ }
+
+ void IRecipient.Receive(StartNewChatSessionMessage message)
+ => StartNewSession();
+
+ public ObservableCollection Messages { get; } = [];
+
+ public ObservableCollection AvailableTools { get; } = [];
+
+ public IReadOnlyList SuggestionPrompts { get; } =
+ [
+ "Add 5 packs of tomato seeds and a trowel",
+ "Show me the basil seeds",
+ "Build me a starter bundle",
+ "Open the product catalog",
+ "Switch cart display mode",
+ "Checkout my shopping list",
+ "Go to my past orders",
+ "Rate the tomato seeds 5 stars",
+ ];
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsNotBusy))]
+ public partial bool IsBusy { get; set; }
+
+ public bool IsNotBusy => !IsBusy;
+
+ [ObservableProperty]
+ public partial string? InputText { get; set; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsInputVisible))]
+ public partial bool IsApprovalPending { get; set; }
+
+ public bool IsInputVisible => !IsApprovalPending;
+
+ [ObservableProperty]
+ public partial string ApprovalText { get; set; } = "";
+
+ public void StartNewSession()
+ {
+ try { _cts.Cancel(); } catch { /* best effort */ }
+ _cts.Dispose();
+ _cts = new CancellationTokenSource();
+
+ _history =
+ [
+ new(ChatRole.System, BuildSystemPrompt())
+ ];
+
+ Messages.Clear();
+ _pendingApproval = null;
+ IsApprovalPending = false;
+ }
+
+ [RelayCommand]
+ private async Task SendAsync()
+ {
+ var text = InputText?.Trim();
+ if (string.IsNullOrWhiteSpace(text) || IsBusy)
+ return;
+
+ InputText = string.Empty;
+ IsBusy = true;
+
+ AddMessage(ChatMessageKind.User, text);
+ _history.Add(new ChatMessage(ChatRole.User, text));
+
+ try
+ {
+ var options = new ChatOptions { Tools = [.. GardenShopTools.Default.Tools] };
+ await SendAndProcessResponseAsync(options);
+ }
+ catch (Exception ex)
+ {
+ AddMessage(ChatMessageKind.Error, $"Error: {ex.Message}");
+ }
+ finally
+ {
+ IsBusy = false;
+ WeakReferenceMessenger.Default.Send(new ChatTurnCompletedMessage());
+ }
+ }
+
+ [RelayCommand]
+ private async Task ApproveAsync() => await ResolveApprovalAsync(approved: true);
+
+ [RelayCommand]
+ private async Task RejectAsync() => await ResolveApprovalAsync(approved: false, reason: "User rejected");
+
+ [RelayCommand]
+ private async Task RunSuggestionAsync(string? prompt)
+ {
+ if (string.IsNullOrWhiteSpace(prompt) || IsBusy)
+ return;
+ InputText = prompt;
+ await SendAsync();
+ }
+
+ private async Task SendAndProcessResponseAsync(ChatOptions options)
+ {
+ var responseText = string.Empty;
+ ChatMessageViewModel? assistantMessage = null;
+ var updates = new List();
+ // Track tool call messages by CallId so we can attach results
+ var toolCallMessages = new Dictionary();
+
+ await foreach (var update in _chatClient.GetStreamingResponseAsync(_history, options, _cts.Token))
+ {
+ updates.Add(update);
+
+ foreach (var content in update.Contents)
+ {
+ switch (content)
+ {
+ case ToolApprovalRequestContent approval:
+ {
+ var toolName = approval.ToolCall is FunctionCallContent fcc ? fcc.Name : "unknown";
+ var args = approval.ToolCall is FunctionCallContent fc && fc.Arguments is not null
+ ? string.Join(", ", fc.Arguments.Select(kv => $"{kv.Key}: {kv.Value}"))
+ : "";
+ var msg = AddMessage(ChatMessageKind.Tool, $"Approval required: {toolName}({args})", FluentIcons.LockClosed);
+ msg.ToolArgs = args;
+ _pendingApproval = approval;
+ break;
+ }
+
+ case FunctionCallContent call:
+ {
+ var argsText = call.Arguments is not null
+ ? string.Join("\n", call.Arguments.Select(kv => $" {kv.Key}: {kv.Value}"))
+ : "";
+ var msg = AddMessage(ChatMessageKind.Tool, call.Name, FluentIcons.Wrench);
+ msg.ToolArgs = argsText;
+ if (call.CallId is not null)
+ toolCallMessages[call.CallId] = msg;
+ break;
+ }
+
+ case FunctionResultContent result:
+ {
+ // Serialize result to JSON for display (ToString() gives type names for collections)
+ string resultText;
+ try
+ {
+ resultText = result.Result switch
+ {
+ null => "(null)",
+ string s => s,
+ _ => System.Text.Json.JsonSerializer.Serialize(result.Result,
+ new System.Text.Json.JsonSerializerOptions { WriteIndented = true })
+ };
+ }
+ catch
+ {
+ resultText = result.Result?.ToString() ?? "";
+ }
+ if (result.CallId is not null && toolCallMessages.TryGetValue(result.CallId, out var toolMsg))
+ {
+ toolMsg.ToolResult = resultText;
+ OnPropertyChanged(nameof(toolMsg.HasDetails));
+ }
+ break;
+ }
+
+ case TextContent tc when tc.Text is not null:
+ responseText += tc.Text;
+ if (assistantMessage is null)
+ assistantMessage = AddMessage(ChatMessageKind.Assistant, responseText);
+ else
+ assistantMessage.Text = responseText;
+ break;
+ }
+ }
+ }
+
+ _history.AddMessages(updates);
+
+ if (_pendingApproval is not null)
+ {
+ var name = _pendingApproval.ToolCall is FunctionCallContent fc2 ? fc2.Name?.TrimEnd('(', ')') : "tool";
+ ApprovalText = $"{name} — approve?";
+ IsApprovalPending = true;
+ return;
+ }
+
+ if (assistantMessage is null && string.IsNullOrEmpty(responseText))
+ AddMessage(ChatMessageKind.Assistant, "(no response)");
+ }
+
+ private async Task ResolveApprovalAsync(bool approved, string? reason = null)
+ {
+ if (_pendingApproval is null)
+ return;
+
+ var approval = _pendingApproval;
+ _pendingApproval = null;
+ IsApprovalPending = false;
+ IsBusy = true;
+
+ try
+ {
+ var response = approval.CreateResponse(approved, reason);
+ _history.Add(new ChatMessage(ChatRole.User, [response]));
+ AddMessage(ChatMessageKind.Tool, approved ? "Approved" : "Rejected", approved ? FluentIcons.Checkmark : FluentIcons.Dismiss);
+
+ var options = new ChatOptions { Tools = [.. GardenShopTools.Default.Tools] };
+ await SendAndProcessResponseAsync(options);
+ }
+ catch (Exception ex)
+ {
+ AddMessage(ChatMessageKind.Error, $"Error: {ex.Message}");
+ }
+ finally
+ {
+ IsBusy = false;
+ WeakReferenceMessenger.Default.Send(new ChatTurnCompletedMessage());
+ }
+ }
+
+ private ChatMessageViewModel AddMessage(ChatMessageKind kind, string text, string? icon = null)
+ {
+ var vm = new ChatMessageViewModel(kind, text, icon);
+ Messages.Add(vm);
+ WeakReferenceMessenger.Default.Send(new ChatMessageAddedMessage(vm));
+ return vm;
+ }
+
+ private void RefreshAvailableTools()
+ {
+ AvailableTools.Clear();
+ var tools = GardenShopTools.Default.Tools;
+ foreach (var tool in tools.OrderBy(t => t.Name))
+ AvailableTools.Add(new ToolInfoViewModel(tool.Name, tool.Description ?? ""));
+ }
+
+ private string BuildSystemPrompt()
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("""
+ You are a helpful garden-shop assistant. Help the user browse seeds, soil,
+ tools, and equipment, manage their cart, and review past orders.
+
+ IMPORTANT RULES:
+ - Always use tools to perform actions. Never assume you know the cart state
+ from previous messages — call show_list to check.
+ - Use search_products to discover items by name or category.
+ - Use recommend_bundle when the user asks for a starter kit, gift set, or curated bundle idea.
+ - When the user says "check out", call checkout_list (which requires approval).
+ - After checkout clears the cart, the cart is EMPTY. If the user asks to add
+ items again, always call add_to_list — do not say items are already there.
+
+ NAVIGATION:
+ - When the user asks to "open", "show", "go to", or "see" a page, product,
+ order, or review — ALWAYS use navigate(route) to open the actual page.
+ Do NOT just list information in chat when the user wants to see a page.
+ - Put parameter values directly in the path after the route that accepts them.
+ - Use navigate("..") to go back, navigate("//main/chat") to go home.
+ - You can call get_routes() to see all available routes and their parameters.
+
+ CART DISPLAY:
+ - Use set_cart_mode("normal") or set_cart_mode("compact") to change the cart view.
+
+ REVIEWS:
+ - Use submit_review to add a review via AI, or navigate to the review page UI.
+ - Use get_product_reviews / list_reviews to read reviews.
+
+ Be concise and friendly.
+ """);
+
+ // Dynamically inject the discovered route table with template-style URIs
+ try
+ {
+ var routes = _navigationService.GetRoutes();
+ if (routes.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine("AVAILABLE ROUTES (use with the navigate tool):");
+ sb.AppendLine("Put parameter values inline in the path, right after the route segment.");
+ sb.AppendLine();
+ foreach (var route in routes)
+ {
+ if (route.Parameters.Count > 0)
+ {
+ sb.AppendLine($" {route.FullPath}/<{route.Parameters[0].QueryName}>");
+ }
+ else
+ {
+ sb.AppendLine($" {route.FullPath}");
+ }
+ }
+ sb.AppendLine();
+ sb.AppendLine("Examples:");
+ sb.AppendLine(" navigate(\"//main/products\") → product catalog");
+ sb.AppendLine(" navigate(\"//main/products/product/seed-tomato\") → product detail for seed-tomato");
+ sb.AppendLine(" navigate(\"//main/products/product/seed-basil/review\") → review page for basil");
+ sb.AppendLine(" navigate(\"//main/orders/order/ORD-00001\") → order detail");
+ sb.AppendLine(" navigate(\"..\") → go back");
+ sb.AppendLine(" navigate(\"//main/chat\") → go home");
+ sb.AppendLine(" navigate(\"cart\") → open cart modal");
+ }
+ }
+ catch
+ {
+ // Route discovery may fail before Shell is fully initialized
+ }
+
+ return sb.ToString();
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Chat/ToolInfoViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Chat/ToolInfoViewModel.cs
new file mode 100644
index 000000000..c17d64994
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Chat/ToolInfoViewModel.cs
@@ -0,0 +1,8 @@
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// View model for a single tool shown in the empty-state placeholder.
+///
+public sealed record ToolInfoViewModel(
+ string Name,
+ string Description);
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/MainViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/MainViewModel.cs
new file mode 100644
index 000000000..dabb9c78d
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/MainViewModel.cs
@@ -0,0 +1,42 @@
+using AIExtensions.Sample.Garden.Messages;
+using AIExtensions.Sample.Garden.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// Top-level view model for .
+/// Owns the new-session action and the cart header button.
+/// Navigation AI tools have moved to .
+///
+public sealed partial class MainViewModel(CurrentCart currentCart) : ObservableObject
+{
+ private bool _initialized;
+
+ ///
+ /// Called once from .
+ ///
+ public void Initialize()
+ {
+ if (_initialized)
+ return;
+ _initialized = true;
+
+ StartNewSession();
+ }
+
+ [RelayCommand]
+ private void StartNewSession()
+ {
+ currentCart.Clear();
+ WeakReferenceMessenger.Default.Send(new StartNewChatSessionMessage());
+ }
+
+ [RelayCommand]
+ private async Task ShowCartAsync()
+ {
+ await Shell.Current.GoToAsync("cart");
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Orders/OrderDetailViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Orders/OrderDetailViewModel.cs
new file mode 100644
index 000000000..e62b3dd2c
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Orders/OrderDetailViewModel.cs
@@ -0,0 +1,62 @@
+using System.Collections.ObjectModel;
+using AIExtensions.Sample.Garden.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// View model for the order detail page.
+/// Accepts an orderId query parameter.
+///
+[QueryProperty(nameof(OrderId), "orderId")]
+public sealed partial class OrderDetailViewModel : ObservableObject
+{
+ private readonly IOrderArchive _archive;
+ private readonly CurrentCart _currentCart;
+
+ public OrderDetailViewModel(IOrderArchive archive, CurrentCart currentCart)
+ {
+ _archive = archive;
+ _currentCart = currentCart;
+ }
+
+ [ObservableProperty]
+ public partial string? OrderId { get; set; }
+
+ [ObservableProperty]
+ public partial string PlacedAt { get; set; } = "";
+
+ [ObservableProperty]
+ public partial string Total { get; set; } = "";
+
+ [ObservableProperty]
+ public partial int ItemCount { get; set; }
+
+ public ObservableCollection Lines { get; } = [];
+
+ partial void OnOrderIdChanged(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return;
+
+ var order = _archive.FindOrder(value);
+ if (order is null)
+ return;
+
+ PlacedAt = order.PlacedAt.ToString("MMM d, yyyy h:mm tt");
+ Total = order.Total.ToString("C");
+ ItemCount = order.Items.Count;
+
+ Lines.Clear();
+ foreach (var item in order.Items)
+ Lines.Add(new OrderLineViewModel(item));
+ }
+
+ [RelayCommand]
+ private void Reorder()
+ {
+ if (!string.IsNullOrWhiteSpace(OrderId))
+ _archive.Reorder(OrderId, _currentCart);
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Orders/OrderLineViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Orders/OrderLineViewModel.cs
new file mode 100644
index 000000000..211f05d2b
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Orders/OrderLineViewModel.cs
@@ -0,0 +1,15 @@
+using AIExtensions.Sample.Garden.Models;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// One line item inside an expanded order card.
+///
+public sealed class OrderLineViewModel(ListItem item)
+{
+ public string Sku => item.Product.Sku;
+ public string Emoji => item.Product.Emoji;
+ public string ItemDescription => $"{item.Quantity}× {item.Product.Name}";
+ public string SubtotalLabel => item.Subtotal.ToString("C");
+ public string Line => $"{item.Quantity}× {item.Product.Name} · {item.Subtotal:C}";
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Orders/OrderViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Orders/OrderViewModel.cs
new file mode 100644
index 000000000..16e64db21
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Orders/OrderViewModel.cs
@@ -0,0 +1,17 @@
+using AIExtensions.Sample.Garden.Models;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// View model for one row in the past-orders list.
+///
+public sealed class OrderViewModel(Order order)
+{
+ public string OrderId => order.Id;
+ public string PlacedAt => order.PlacedAt.ToString("MMM d, h:mm tt");
+ public string Total => order.Total.ToString("C");
+ public int ItemCount => order.Items.Count;
+ public string ItemCountLabel => $"{order.Items.Count} item{(order.Items.Count != 1 ? "s" : "")}";
+ public IReadOnlyList Lines { get; } =
+ [.. order.Items.Select(i => new OrderLineViewModel(i))];
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Orders/OrdersViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Orders/OrdersViewModel.cs
new file mode 100644
index 000000000..f3f3b1ed6
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Orders/OrdersViewModel.cs
@@ -0,0 +1,63 @@
+using System.Collections.ObjectModel;
+using AIExtensions.Sample.Garden.Messages;
+using AIExtensions.Sample.Garden.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// Owns order history state, reorder, and clear actions.
+///
+public sealed partial class OrdersViewModel : ObservableObject, IRecipient
+{
+ private readonly IOrderArchive _archive;
+ private readonly CurrentCart _currentCart;
+
+ public OrdersViewModel(IOrderArchive archive, CurrentCart currentCart)
+ {
+ _archive = archive;
+ _currentCart = currentCart;
+
+ WeakReferenceMessenger.Default.Register(this);
+ Refresh();
+ }
+
+ public ObservableCollection Orders { get; } = [];
+
+ void IRecipient.Receive(ChatTurnCompletedMessage message)
+ => Refresh();
+
+ [RelayCommand]
+ private void Reorder(string? orderId)
+ {
+ if (string.IsNullOrWhiteSpace(orderId))
+ return;
+ _archive.Reorder(orderId, _currentCart);
+ }
+
+ [RelayCommand]
+ private void Clear()
+ {
+ _archive.Clear();
+ Refresh();
+ }
+
+ public void Refresh()
+ {
+ var source = _archive.Orders;
+ var sourceKeys = new HashSet(source.Select(o => o.Id));
+ for (int i = Orders.Count - 1; i >= 0; i--)
+ {
+ if (!sourceKeys.Contains(Orders[i].OrderId))
+ Orders.RemoveAt(i);
+ }
+ var existing = new HashSet(Orders.Select(v => v.OrderId));
+ foreach (var order in source)
+ {
+ if (!existing.Contains(order.Id))
+ Orders.Add(new OrderViewModel(order));
+ }
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Products/ProductDetailViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Products/ProductDetailViewModel.cs
new file mode 100644
index 000000000..0d9ff7cf1
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Products/ProductDetailViewModel.cs
@@ -0,0 +1,103 @@
+using System.Collections.ObjectModel;
+using AIExtensions.Sample.Garden.Models;
+using AIExtensions.Sample.Garden.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// View model for the product detail page.
+/// Accepts a sku query parameter and loads product info + reviews.
+///
+[QueryProperty(nameof(Sku), "sku")]
+public sealed partial class ProductDetailViewModel : ObservableObject
+{
+ private readonly CurrentCart _currentCart;
+ private readonly ReviewStore _reviewStore;
+
+ public ProductDetailViewModel(CurrentCart currentCart, ReviewStore reviewStore)
+ {
+ _currentCart = currentCart;
+ _reviewStore = reviewStore;
+ }
+
+ [ObservableProperty]
+ public partial string? Sku { get; set; }
+
+ [ObservableProperty]
+ public partial string Name { get; set; } = "";
+
+ [ObservableProperty]
+ public partial string Emoji { get; set; } = "";
+
+ [ObservableProperty]
+ public partial string Category { get; set; } = "";
+
+ [ObservableProperty]
+ public partial string PriceLabel { get; set; } = "";
+
+ [ObservableProperty]
+ public partial string RatingLabel { get; set; } = "No reviews yet";
+
+ [ObservableProperty]
+ public partial bool HasReviews { get; set; }
+
+ public ObservableCollection Reviews { get; } = [];
+
+ partial void OnSkuChanged(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return;
+
+ var product = ProductCatalog.FindByName(value);
+ if (product is null)
+ return;
+
+ Name = product.Name;
+ Emoji = product.Emoji;
+ Category = product.Category;
+ PriceLabel = product.Price.ToString("C");
+ RefreshReviews(value);
+ }
+
+ public void RefreshReviews(string? sku = null)
+ {
+ sku ??= Sku;
+ if (string.IsNullOrWhiteSpace(sku))
+ return;
+
+ var reviews = _reviewStore.GetProductReviews(sku);
+ Reviews.Clear();
+ foreach (var r in reviews)
+ Reviews.Add(new ReviewViewModel(r));
+
+ HasReviews = reviews.Count > 0;
+ var avg = _reviewStore.AverageRating(sku);
+ RatingLabel = avg is not null
+ ? $"{avg:F1} ★ ({reviews.Count} review{(reviews.Count != 1 ? "s" : "")})"
+ : "No reviews yet";
+ }
+
+ [RelayCommand]
+ private void AddToCart()
+ {
+ if (!string.IsNullOrWhiteSpace(Sku))
+ _currentCart.AddItem(Sku);
+ }
+
+ [RelayCommand]
+ private async Task WriteReviewAsync()
+ {
+ if (!string.IsNullOrWhiteSpace(Sku))
+ await Shell.Current.GoToAsync($"review?sku={Sku}");
+ }
+}
+
+public sealed class ReviewViewModel(ProductReview review)
+{
+ public string Stars => new string('★', review.Rating) + new string('☆', 5 - review.Rating);
+ public string Comment => review.Comment ?? "";
+ public bool HasComment => !string.IsNullOrWhiteSpace(review.Comment);
+ public string Date => review.CreatedAt.ToString("MMM d, yyyy");
+}
diff --git a/samples/AIExtensions.Sample.Garden/ViewModels/Products/ProductReviewViewModel.cs b/samples/AIExtensions.Sample.Garden/ViewModels/Products/ProductReviewViewModel.cs
new file mode 100644
index 000000000..f5e7b2deb
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/ViewModels/Products/ProductReviewViewModel.cs
@@ -0,0 +1,58 @@
+using AIExtensions.Sample.Garden.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace AIExtensions.Sample.Garden.ViewModels;
+
+///
+/// View model for the product review modal.
+/// Accepts a sku query parameter.
+///
+[QueryProperty(nameof(Sku), "sku")]
+public sealed partial class ProductReviewViewModel : ObservableObject
+{
+ private readonly ReviewStore _reviewStore;
+
+ public ProductReviewViewModel(ReviewStore reviewStore)
+ {
+ _reviewStore = reviewStore;
+ }
+
+ [ObservableProperty]
+ public partial string? Sku { get; set; }
+
+ [ObservableProperty]
+ public partial string ProductName { get; set; } = "";
+
+ [ObservableProperty]
+ public partial int Rating { get; set; } = 5;
+
+ [ObservableProperty]
+ public partial string? Comment { get; set; }
+
+ partial void OnSkuChanged(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return;
+
+ var product = ProductCatalog.FindByName(value);
+ if (product is not null)
+ ProductName = product.Name;
+ }
+
+ [RelayCommand]
+ private async Task SubmitAsync()
+ {
+ if (string.IsNullOrWhiteSpace(Sku))
+ return;
+
+ _reviewStore.Submit(Sku, Rating, Comment);
+ await Shell.Current.GoToAsync("..");
+ }
+
+ [RelayCommand]
+ private async Task CancelAsync()
+ {
+ await Shell.Current.GoToAsync("..");
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Views/CartPane.xaml b/samples/AIExtensions.Sample.Garden/Views/CartPane.xaml
new file mode 100644
index 000000000..0b5018322
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Views/CartPane.xaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Views/CartPane.xaml.cs b/samples/AIExtensions.Sample.Garden/Views/CartPane.xaml.cs
new file mode 100644
index 000000000..d660c8b4a
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Views/CartPane.xaml.cs
@@ -0,0 +1,9 @@
+namespace AIExtensions.Sample.Garden.Views;
+
+public partial class CartPane : ContentView
+{
+ public CartPane()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Views/CartView.xaml b/samples/AIExtensions.Sample.Garden/Views/CartView.xaml
new file mode 100644
index 000000000..6fc9d190f
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Views/CartView.xaml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Views/CartView.xaml.cs b/samples/AIExtensions.Sample.Garden/Views/CartView.xaml.cs
new file mode 100644
index 000000000..e1d020656
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Views/CartView.xaml.cs
@@ -0,0 +1,9 @@
+namespace AIExtensions.Sample.Garden.Views;
+
+public partial class CartView : ContentView
+{
+ public CartView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Views/CatalogView.xaml b/samples/AIExtensions.Sample.Garden/Views/CatalogView.xaml
new file mode 100644
index 000000000..87df4c2dd
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Views/CatalogView.xaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Views/CatalogView.xaml.cs b/samples/AIExtensions.Sample.Garden/Views/CatalogView.xaml.cs
new file mode 100644
index 000000000..d70201d5b
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Views/CatalogView.xaml.cs
@@ -0,0 +1,21 @@
+namespace AIExtensions.Sample.Garden.Views;
+
+public partial class CatalogView : ContentView
+{
+ public CatalogView()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnProductTapped(object? sender, TappedEventArgs e)
+ {
+ if (e.Parameter is string sku && !string.IsNullOrWhiteSpace(sku))
+ await Shell.Current.GoToAsync($"product?sku={sku}");
+ }
+
+ private async void OnProductDetailClicked(object? sender, EventArgs e)
+ {
+ if (sender is Button btn && btn.CommandParameter is string sku && !string.IsNullOrWhiteSpace(sku))
+ await Shell.Current.GoToAsync($"product?sku={sku}");
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Views/ChatMessageTemplateSelector.cs b/samples/AIExtensions.Sample.Garden/Views/ChatMessageTemplateSelector.cs
new file mode 100644
index 000000000..46901e4c8
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Views/ChatMessageTemplateSelector.cs
@@ -0,0 +1,27 @@
+using AIExtensions.Sample.Garden.ViewModels;
+
+namespace AIExtensions.Sample.Garden.Views;
+
+///
+/// Picks a message DataTemplate based on .
+/// Templates are defined in MainPage.xaml resources.
+///
+public sealed class ChatMessageTemplateSelector : DataTemplateSelector
+{
+ public DataTemplate? UserTemplate { get; set; }
+ public DataTemplate? AssistantTemplate { get; set; }
+ public DataTemplate? ToolTemplate { get; set; }
+ public DataTemplate? SystemTemplate { get; set; }
+ public DataTemplate? ErrorTemplate { get; set; }
+
+ protected override DataTemplate OnSelectTemplate(object item, BindableObject container) =>
+ ((ChatMessageViewModel)item).Kind switch
+ {
+ ChatMessageKind.User => UserTemplate!,
+ ChatMessageKind.Assistant => AssistantTemplate!,
+ ChatMessageKind.Tool => ToolTemplate!,
+ ChatMessageKind.System => SystemTemplate!,
+ ChatMessageKind.Error => ErrorTemplate!,
+ _ => AssistantTemplate!,
+ };
+}
diff --git a/samples/AIExtensions.Sample.Garden/Views/ChatView.xaml b/samples/AIExtensions.Sample.Garden/Views/ChatView.xaml
new file mode 100644
index 000000000..8b1c0e10f
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Views/ChatView.xaml
@@ -0,0 +1,366 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Views/ChatView.xaml.cs b/samples/AIExtensions.Sample.Garden/Views/ChatView.xaml.cs
new file mode 100644
index 000000000..5b62d7b40
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Views/ChatView.xaml.cs
@@ -0,0 +1,22 @@
+using AIExtensions.Sample.Garden.Messages;
+using CommunityToolkit.Mvvm.Messaging;
+
+namespace AIExtensions.Sample.Garden.Views;
+
+public partial class ChatView : ContentView, IRecipient
+{
+ public ChatView()
+ {
+ InitializeComponent();
+ WeakReferenceMessenger.Default.Register(this);
+ }
+
+ void IRecipient.Receive(ChatMessageAddedMessage message)
+ {
+ Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(50), () =>
+ {
+ try { MessagesView.ScrollTo(message.Message, position: ScrollToPosition.End, animate: true); }
+ catch { /* item may have been removed */ }
+ });
+ }
+}
diff --git a/samples/AIExtensions.Sample.Garden/Views/OrdersView.xaml b/samples/AIExtensions.Sample.Garden/Views/OrdersView.xaml
new file mode 100644
index 000000000..52de7b453
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Views/OrdersView.xaml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Garden/Views/OrdersView.xaml.cs b/samples/AIExtensions.Sample.Garden/Views/OrdersView.xaml.cs
new file mode 100644
index 000000000..0961ed77e
--- /dev/null
+++ b/samples/AIExtensions.Sample.Garden/Views/OrdersView.xaml.cs
@@ -0,0 +1,15 @@
+namespace AIExtensions.Sample.Garden.Views;
+
+public partial class OrdersView : ContentView
+{
+ public OrdersView()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnOrderTapped(object? sender, TappedEventArgs e)
+ {
+ if (e.Parameter is string orderId && !string.IsNullOrWhiteSpace(orderId))
+ await Shell.Current.GoToAsync($"order?orderId={orderId}");
+ }
+}
diff --git a/samples/AIExtensions.Sample.Hello/AIExtensions.Sample.Hello.csproj b/samples/AIExtensions.Sample.Hello/AIExtensions.Sample.Hello.csproj
new file mode 100644
index 000000000..c5200363d
--- /dev/null
+++ b/samples/AIExtensions.Sample.Hello/AIExtensions.Sample.Hello.csproj
@@ -0,0 +1,30 @@
+
+
+
+ Exe
+ net10.0
+ AIExtensions.Sample.Hello
+ enable
+ enable
+ ai-attributes-secrets
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AIExtensions.Sample.Hello/Program.cs b/samples/AIExtensions.Sample.Hello/Program.cs
new file mode 100644
index 000000000..fd0e9491c
--- /dev/null
+++ b/samples/AIExtensions.Sample.Hello/Program.cs
@@ -0,0 +1,109 @@
+using System.ClientModel;
+using System.ComponentModel;
+using Azure.AI.OpenAI;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Maui.AI.Attributes;
+
+// Smallest possible end-to-end example of Microsoft.Maui.AI.Attributes:
+// - WeatherService -> instance method, resolved from DI
+// - GreetingService -> pure static method, no DI needed
+// The source generator auto-discovers all [ExportAIFunction] methods in the
+// assembly and creates AIExtensionsSampleHelloToolContext with every tool.
+
+var configuration = new ConfigurationBuilder()
+ .AddUserSecrets()
+ .Build();
+
+var apiKey = configuration["AI:ApiKey"];
+var endpoint = configuration["AI:Endpoint"];
+var deployment = configuration["AI:DeploymentName"];
+
+if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(endpoint) || string.IsNullOrEmpty(deployment))
+{
+ Console.Error.WriteLine("""
+ AI:Endpoint, AI:ApiKey and AI:DeploymentName must be set. Configure user-secrets:
+
+ dotnet user-secrets --id ai-attributes-secrets set "AI:Endpoint" ""
+ dotnet user-secrets --id ai-attributes-secrets set "AI:ApiKey" ""
+ dotnet user-secrets --id ai-attributes-secrets set "AI:DeploymentName" ""
+
+ (shared across all AI.Attributes samples)
+ """);
+ return 1;
+}
+
+var services = new ServiceCollection();
+services.AddSingleton();
+
+var azure = new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey));
+IChatClient innerClient = azure.GetChatClient(deployment).AsIChatClient();
+services.AddSingleton(innerClient);
+
+var root = services.BuildServiceProvider();
+
+var chat = new ChatClientBuilder(root.GetRequiredService())
+ .UseFunctionInvocation()
+ .Build(root);
+
+var tools = AIExtensionsSampleHelloToolContext.Default.Tools;
+var options = new ChatOptions { Tools = [.. tools] };
+
+Console.WriteLine($"{tools.Count} tool(s) registered:");
+foreach (var t in tools)
+ Console.WriteLine($" - {t.Name}: {t.Description}");
+Console.WriteLine();
+Console.WriteLine("Try: \"What's the forecast in Tokyo?\" or \"Say hello to Ada.\"");
+Console.WriteLine("Ctrl+C to exit.");
+Console.WriteLine();
+
+var history = new List
+{
+ new(ChatRole.System, "You are a helpful assistant. Use the available tools when relevant.")
+};
+
+while (true)
+{
+ Console.Write("> ");
+ var input = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(input))
+ continue;
+
+ history.Add(new ChatMessage(ChatRole.User, input));
+
+ var response = await chat.GetResponseAsync(history, options);
+ history.AddMessages(response);
+
+ Console.WriteLine(response.Text);
+ Console.WriteLine();
+}
+
+///
+/// Instance service resolved from DI. The generated tool calls
+/// provider.GetRequiredService<WeatherService>() to get this instance.
+///
+public class WeatherService
+{
+ [Description("Gets a short weather forecast for a city.")]
+ [ExportAIFunction("get_forecast")]
+ public string GetForecast(
+ [Description("The city name")] string city,
+ [Description("Number of days (1-7). Defaults to 3.")] int days = 3)
+ {
+ return $"{days}-day forecast for {city}: mostly pleasant with a high of {Random.Shared.Next(15, 35)}°C.";
+ }
+}
+
+///
+/// Pure-static service — no instance, no DI. The generator calls these methods
+/// directly without touching .
+///
+public static class GreetingService
+{
+ [Description("Greets someone by name and gives them a lucky number.")]
+ [ExportAIFunction("say_hello")]
+ public static string SayHello(
+ [Description("The name of the person to greet")] string name)
+ => $"Hello, {name}! Your lucky number is {Random.Shared.Next(1, 100)}.";
+}
diff --git a/samples/AIExtensions.Sample.Hello/README.md b/samples/AIExtensions.Sample.Hello/README.md
new file mode 100644
index 000000000..0ac3ac328
--- /dev/null
+++ b/samples/AIExtensions.Sample.Hello/README.md
@@ -0,0 +1,46 @@
+# Hello — minimal console sample
+
+The smallest possible AI Extensions app: one DI-bound
+service, one static service, one console REPL.
+
+## What this demonstrates
+
+- `[ExportAIFunction]` on regular instance methods (resolved via DI) **and**
+ on `static` methods (no DI required).
+- Multiple `[AIToolSource]` attributes on a single `partial class : AIToolContext`
+ — the source generator merges them into one context.
+- `HelloTools.Default.Tools` as the headline API for getting the tool list.
+ No registration ceremony.
+- `ChatClientBuilder.UseFunctionInvocation().Build(sp)` wiring the service
+ provider through to each tool invocation.
+
+## Run
+
+All `AIExtensions.Sample.*` apps share one `UserSecretsId`
+(`ai-attributes-secrets`), so you configure the endpoint once:
+
+```bash
+dotnet user-secrets --id ai-attributes-secrets set "AI:Endpoint" "https://.openai.azure.com"
+dotnet user-secrets --id ai-attributes-secrets set "AI:ApiKey" ""
+dotnet user-secrets --id ai-attributes-secrets set "AI:DeploymentName" ""
+
+dotnet run --project samples/AIExtensions.Sample.Hello
+```
+
+Type a prompt like `What's the forecast in Paris?` and the model will call the
+`get_forecast` tool.
+
+## When to look at this sample
+
+You are new to the library and want to see the smallest end-to-end wiring.
+Move on to one of the other samples once you want to see approval flows or
+DI parameter binding.
+
+## Inspecting the generated source
+
+This csproj sets `EmitCompilerGeneratedFiles=true` so you can see exactly
+what the source generator emits. After a build, look under:
+
+```
+artifacts/obj////generated/Microsoft.Maui.AI.Attributes.Generators/Microsoft.Maui.AI.Attributes.Generators.AIToolContextGenerator/*.g.cs
+```
diff --git a/src/AIExtensions/AIExtensions.slnf b/src/AIExtensions/AIExtensions.slnf
new file mode 100644
index 000000000..5c2e2c4a0
--- /dev/null
+++ b/src/AIExtensions/AIExtensions.slnf
@@ -0,0 +1,13 @@
+{
+ "solution": {
+ "path": "../../MauiLabs.slnx",
+ "projects": [
+ "src\\AIExtensions\\Microsoft.Maui.AI.Attributes\\Microsoft.Maui.AI.Attributes.csproj",
+ "src\\AIExtensions\\Microsoft.Maui.AI.Attributes.Generators\\Microsoft.Maui.AI.Attributes.Generators.csproj",
+ "src\\AIExtensions\\Microsoft.Maui.AI.Navigation\\Microsoft.Maui.AI.Navigation.csproj",
+ "tests\\AIExtensions\\Microsoft.Maui.AI.Attributes.Tests\\Microsoft.Maui.AI.Attributes.Tests.csproj",
+ "tests\\AIExtensions\\Microsoft.Maui.AI.Attributes.Generators.Tests\\Microsoft.Maui.AI.Attributes.Generators.Tests.csproj",
+ "tests\\AIExtensions\\Microsoft.Maui.AI.Navigation.Tests\\Microsoft.Maui.AI.Navigation.Tests.csproj"
+ ]
+ }
+}
diff --git a/src/AIExtensions/Microsoft.Maui.AI.Attributes.Generators/AIToolContextGenerator.cs b/src/AIExtensions/Microsoft.Maui.AI.Attributes.Generators/AIToolContextGenerator.cs
new file mode 100644
index 000000000..cf675eeca
--- /dev/null
+++ b/src/AIExtensions/Microsoft.Maui.AI.Attributes.Generators/AIToolContextGenerator.cs
@@ -0,0 +1,133 @@
+using System.Collections.Immutable;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Microsoft.Maui.AI.Attributes.Generators;
+
+///
+/// Incremental source generator that emits a fully-typed AIFunction subclass per
+/// [ExportAIFunction] method on each type referenced by an
+/// [AIToolSource(typeof(...))] attribute on an AIToolContext partial class.
+///
+///
+/// The emitted class:
+///
+/// - Overrides Name, Description, JsonSchema, ReturnJsonSchema.
+/// - Resolves its backing service from AIFunctionArguments.Services per invocation — no
+/// AIFunctionFactory.Create, no MethodInfo.Invoke.
+/// - Binds each parameter at compile time:
+/// CancellationToken/IServiceProvider/AIFunctionArguments get special cases;
+/// [FromServices]/[FromKeyedServices] parameters resolve from DI; everything else
+/// binds from the argument dictionary.
+///
+///
+[Generator(LanguageNames.CSharp)]
+public sealed class AIToolContextGenerator : IIncrementalGenerator
+{
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var contextDeclarations = context.SyntaxProvider
+ .ForAttributeWithMetadataName(
+ SymbolAnalysis.AIToolSourceAttributeFullName,
+ predicate: static (node, _) => node is ClassDeclarationSyntax cds &&
+ cds.Modifiers.Any(SyntaxKind.PartialKeyword),
+ transform: static (ctx, ct) => SymbolAnalysis.GetContextModel(ctx, ct))
+ .Where(static m => m is not null)
+ .Select(static (m, _) => m!);
+
+ // Merge duplicate context entries (multiple [AIToolSource] attributes → multiple invocations).
+ var grouped = contextDeclarations
+ .Collect()
+ .SelectMany(static (items, _) =>
+ {
+ var dict = new Dictionary();
+ foreach (var item in items)
+ {
+ var key = item.FullyQualifiedName;
+ if (!dict.TryGetValue(key, out var existing))
+ {
+ dict[key] = item;
+ }
+ else
+ {
+ var merged = existing.WithAdditionalSourceTypes(item.SourceTypes);
+ dict[key] = merged;
+ }
+ }
+ return dict.Values.ToImmutableArray();
+ });
+
+ context.RegisterSourceOutput(grouped, static (spc, model) =>
+ {
+ var source = CodeEmitter.GenerateContextSource(model);
+ var hintName = model.ContainingTypes.Length > 0
+ ? $"{string.Join("_", model.ContainingTypes.Select(c => c.Name))}_{model.ClassName}.g.cs"
+ : $"{model.ClassName}.g.cs";
+ spc.AddSource(hintName, SourceText.From(source, Encoding.UTF8));
+
+ foreach (var diag in model.Diagnostics)
+ {
+ spc.ReportDiagnostic(diag.ToDiagnostic());
+ }
+ });
+
+ // ─── Assembly-wide ToolContext ───────────────────────────────
+ // Discovers ALL [ExportAIFunction] methods/properties in the assembly
+ // and emits a single .ToolContext class.
+ var allExportedMethods = context.SyntaxProvider
+ .ForAttributeWithMetadataName(
+ SymbolAnalysis.ExportAIFunctionAttributeFullName,
+ predicate: static (node, _) => node is MethodDeclarationSyntax or PropertyDeclarationSyntax,
+ transform: static (ctx, ct) => SymbolAnalysis.GetExportedMemberInfo(ctx, ct))
+ .Where(static m => m is not null)
+ .Select(static (m, _) => m!);
+
+ var assemblyWide = allExportedMethods
+ .Collect()
+ .Combine(context.CompilationProvider);
+
+ context.RegisterSourceOutput(assemblyWide, static (spc, pair) =>
+ {
+ var (members, compilation) = pair;
+ if (members.IsEmpty)
+ return;
+
+ var assemblyName = compilation.AssemblyName ?? "Assembly";
+ var rootNamespace = compilation.Options is CSharpCompilationOptions opts
+ ? SymbolAnalysis.GetRootNamespace(compilation)
+ : assemblyName;
+
+ // Sanitize assembly name for use as an identifier.
+ var safeAssemblyName = CodeEmitter.SanitizeIdentifier(assemblyName.Replace(".", ""));
+ var className = $"{safeAssemblyName}ToolContext";
+
+ // Group by containing type.
+ var sourceTypes = new Dictionary methods)>();
+ foreach (var m in members)
+ {
+ if (!sourceTypes.TryGetValue(m.ContainingTypeFQN, out var entry))
+ {
+ entry = (m.ContainingTypeFQN, m.ContainingTypeSimpleName, new List());
+ sourceTypes[m.ContainingTypeFQN] = entry;
+ }
+ entry.methods.Add(m.Method);
+ }
+
+ var model = new ContextModel(
+ rootNamespace,
+ className,
+ $"global::{rootNamespace}.{className}",
+ "internal",
+ [],
+ [.. sourceTypes.Values.Select(st => new SourceTypeModel(st.fqn, st.simpleName, [.. st.methods]))],
+ [],
+ EmitBaseClass: true);
+
+ var source = CodeEmitter.GenerateContextSource(model);
+ spc.AddSource($"{className}.g.cs", SourceText.From(source, Encoding.UTF8));
+ });
+ }
+}
diff --git a/src/AIExtensions/Microsoft.Maui.AI.Attributes.Generators/CodeEmitter.cs b/src/AIExtensions/Microsoft.Maui.AI.Attributes.Generators/CodeEmitter.cs
new file mode 100644
index 000000000..d9e95d5d5
--- /dev/null
+++ b/src/AIExtensions/Microsoft.Maui.AI.Attributes.Generators/CodeEmitter.cs
@@ -0,0 +1,310 @@
+using System.Text;
+
+namespace Microsoft.Maui.AI.Attributes.Generators;
+
+internal static class CodeEmitter
+{
+ internal static string GenerateContextSource(ContextModel model)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine("#pragma warning disable CS8019 // Unnecessary using directive");
+ sb.AppendLine("#pragma warning disable CS8604 // Possible null reference argument (JSON-deserialized values passed to non-nullable parameters)");
+ sb.AppendLine("#pragma warning disable CS1998 // Async method lacks 'await' operators (sync property getters/void tools are emitted as async)");
+ sb.AppendLine();
+ sb.AppendLine("using global::System;");
+ sb.AppendLine("using global::System.Collections.Generic;");
+ sb.AppendLine("using global::System.Text.Json;");
+ sb.AppendLine("using global::System.Threading;");
+ sb.AppendLine("using global::System.Threading.Tasks;");
+ sb.AppendLine("using global::Microsoft.Extensions.AI;");
+ sb.AppendLine("using global::Microsoft.Extensions.DependencyInjection;");
+ sb.AppendLine();
+
+ var indent = "";
+ if (!string.IsNullOrEmpty(model.Namespace))
+ {
+ sb.AppendLine($"namespace {model.Namespace}");
+ sb.AppendLine("{");
+ indent = " ";
+ }
+
+ // Open containing type declarations (outermost first).
+ foreach (var ct in model.ContainingTypes)
+ {
+ sb.AppendLine($"{indent}{ct.Accessibility} partial {ct.Keyword} {ct.Name}");
+ sb.AppendLine($"{indent}{{");
+ indent += " ";
+ }
+
+ // Emit the partial class body: Default + Tools.
+ var baseClause = model.EmitBaseClass ? " : global::Microsoft.Maui.AI.Attributes.AIToolContext" : "";
+ sb.AppendLine($"{indent}{model.Accessibility} partial class {model.ClassName}{baseClause}");
+ sb.AppendLine($"{indent}{{");
+ sb.AppendLine($"{indent} /// Gets the default singleton instance of this tool context.");
+ sb.AppendLine($"{indent} public static {model.ClassName} Default {{ get; }} = new {model.ClassName}();");
+ sb.AppendLine();
+
+ // Static JsonSerializerOptions — use the M.E.AI default options which include a
+ // pre-configured type info resolver. Schema creation and argument deserialization
+ // both go through these options so behavior is consistent with ReflectionAIFunction.
+ sb.AppendLine($"{indent} private static readonly global::System.Text.Json.JsonSerializerOptions s_jsonOptions = global::Microsoft.Extensions.AI.AIJsonUtilities.DefaultOptions;");
+ sb.AppendLine();
+
+ // Tools property — cached in a static field so repeated access returns the same instance.
+ sb.AppendLine($"{indent} private static readonly global::Microsoft.Extensions.AI.AITool[] s_tools = new global::Microsoft.Extensions.AI.AITool[]");
+ sb.AppendLine($"{indent} {{");
+ foreach (var st in model.SourceTypes)
+ {
+ foreach (var m in st.Methods)
+ {
+ sb.AppendLine($"{indent} {WrapApproval($"new {m.GeneratedClassName}()", m.ApprovalRequired)},");
+ }
+ }
+ sb.AppendLine($"{indent} }};");
+ sb.AppendLine();
+ sb.AppendLine($"{indent} /// ");
+ sb.AppendLine($"{indent} public override global::System.Collections.Generic.IReadOnlyList Tools => s_tools;");
+
+ // Emit tool classes as nested private classes inside the context class — avoids
+ // cross-context name collisions when the same service method is referenced from
+ // multiple [AIToolSource]-decorated contexts.
+ sb.AppendLine();
+ foreach (var st in model.SourceTypes)
+ {
+ foreach (var m in st.Methods)
+ {
+ EmitToolClass(sb, indent + " ", st, m);
+ sb.AppendLine();
+ }
+ }
+
+ sb.AppendLine($"{indent}}}");
+
+ // Close containing type declarations (innermost first).
+ for (var i = model.ContainingTypes.Length - 1; i >= 0; i--)
+ {
+ indent = indent.Substring(4);
+ sb.AppendLine($"{indent}}}");
+ }
+
+ if (!string.IsNullOrEmpty(model.Namespace))
+ {
+ sb.AppendLine("}");
+ }
+
+ return sb.ToString();
+ }
+
+ private static string WrapApproval(string inner, bool approvalRequired) =>
+ approvalRequired
+ ? $"new global::Microsoft.Extensions.AI.ApprovalRequiredAIFunction({inner})"
+ : inner;
+
+ private static void EmitToolClass(StringBuilder sb, string indent, SourceTypeModel st, MethodModel m)
+ {
+ var cls = m.GeneratedClassName;
+
+ // A tool requires a service provider when either:
+ // - the backing method is an instance method (we need to resolve the owning service), or
+ // - at least one parameter binds from DI (IServiceProvider / [FromServices] / [FromKeyedServices]).
+ bool needsServiceForInstance = !m.IsStatic;
+ bool anyParamNeedsServices = m.Parameters.Any(p =>
+ p.Kind is ParameterKind.ServiceProvider
+ or ParameterKind.FromServices
+ or ParameterKind.FromKeyedServices);
+ bool needsProvider = needsServiceForInstance || anyParamNeedsServices;
+
+ sb.AppendLine($"{indent}private sealed class {cls} : global::Microsoft.Extensions.AI.AIFunction");
+ sb.AppendLine($"{indent}{{");
+ sb.AppendLine($"{indent} private static readonly global::System.Lazy s_schema = new(BuildSchema);");
+ sb.AppendLine($"{indent} private static readonly global::System.Lazy s_returnSchema = new(BuildReturnSchema);");
+ sb.AppendLine();
+
+ sb.AppendLine($"{indent} public override string Name => {Escape(m.ToolName)};");
+ sb.AppendLine($"{indent} public override string Description => {EscapeOrEmpty(m.Description)};");
+ sb.AppendLine($"{indent} public override global::System.Text.Json.JsonElement JsonSchema => s_schema.Value;");
+ sb.AppendLine($"{indent} public override global::System.Text.Json.JsonElement? ReturnJsonSchema => s_returnSchema.Value;");
+ sb.AppendLine();
+
+ // Schema builder — uses the source-generated JsonSerializerOptions for AOT-safe schema creation.
+ // Parameters excluded from the schema (DI-injected, CancellationToken, etc.) are listed here.
+ var jsonParams = m.Parameters.Where(p => IncludeInSchema(p.Kind)).ToArray();
+
+ sb.AppendLine($"{indent} private static global::System.Text.Json.JsonElement BuildSchema()");
+ sb.AppendLine($"{indent} {{");
+ sb.AppendLine($"{indent} var properties = new global::System.Text.Json.Nodes.JsonObject();");
+ sb.AppendLine($"{indent} var required = new global::System.Text.Json.Nodes.JsonArray();");
+ foreach (var p in jsonParams)
+ {
+ sb.AppendLine($"{indent} properties[{Escape(p.Name)}] = global::System.Text.Json.Nodes.JsonNode.Parse(global::Microsoft.Extensions.AI.AIJsonUtilities.CreateJsonSchema(typeof({p.UnannotatedTypeName}), serializerOptions: s_jsonOptions).GetRawText());");
+ if (p.Description is not null)
+ {
+ sb.AppendLine($"{indent} if (properties[{Escape(p.Name)}] is global::System.Text.Json.Nodes.JsonObject {SanitizeIdentifier(p.Name)}Obj)");
+ sb.AppendLine($"{indent} {SanitizeIdentifier(p.Name)}Obj[\"description\"] = {Escape(p.Description)};");
+ }
+ if (!p.HasDefault && !p.IsNullable)
+ {
+ sb.AppendLine($"{indent} required.Add({Escape(p.Name)});");
+ }
+ }
+ sb.AppendLine($"{indent} var schema = new global::System.Text.Json.Nodes.JsonObject");
+ sb.AppendLine($"{indent} {{");
+ sb.AppendLine($"{indent} [\"type\"] = \"object\",");
+ sb.AppendLine($"{indent} [\"properties\"] = properties,");
+ sb.AppendLine($"{indent} }};");
+ sb.AppendLine($"{indent} if (required.Count > 0)");
+ sb.AppendLine($"{indent} schema[\"required\"] = required;");
+ sb.AppendLine($"{indent} schema[\"additionalProperties\"] = false;");
+ sb.AppendLine($"{indent} return global::System.Text.Json.JsonSerializer.SerializeToElement(schema);");
+ sb.AppendLine($"{indent} }}");
+ sb.AppendLine();
+
+ sb.AppendLine($"{indent} private static global::System.Text.Json.JsonElement? BuildReturnSchema()");
+ sb.AppendLine($"{indent} {{");
+ if (m.ReturnInfo.Shape == ReturnShape.Void || m.ReturnInfo.Shape == ReturnShape.Task || m.ReturnInfo.Shape == ReturnShape.ValueTask)
+ {
+ sb.AppendLine($"{indent} return null;");
+ }
+ else
+ {
+ sb.AppendLine($"{indent} return global::Microsoft.Extensions.AI.AIJsonUtilities.CreateJsonSchema(typeof({m.ReturnInfo.TypeName!}), serializerOptions: s_jsonOptions);");
+ }
+ sb.AppendLine($"{indent} }}");
+ sb.AppendLine();
+
+ // InvokeCoreAsync
+ sb.AppendLine($"{indent} protected override async global::System.Threading.Tasks.ValueTask