.NET9: Building a Multi-Agent AI Investment Advisor with .NET and Microsoft.Extensions.AI
In this article we build InvestIQ, a console application that acts as a personal Indian investment advisor. A user types their financial details in plain English and gets back a tailored investment plan — all powered by a local LLM running in Ollama, orchestrated through three specialist AI agents.
Disclaimer: InvestIQ provides AI-generated information for educational and demonstration purposes only. It is not financial advice. Always consult a registered investment advisor before making any investment decisions.
1. What is the Microsoft Agentic Framework?
The term Agentic Framework refers to the set of patterns, abstractions, and libraries that allow you to build AI agents — programs that use an LLM as their reasoning engine while having access to external tools, memory, and the ability to take actions.
Microsoft ships this capability in .NET through the
Microsoft.Extensions.AI package (often abbreviated MEAI).
It is not a separate product; it is an extension of the familiar
Microsoft.Extensions.* ecosystem that .NET developers already know.
Microsoft.Extensions.AI defines a
provider-agnostic abstraction (IChatClient) so that the same agent
code works whether the underlying model is OpenAI GPT-4o, Azure OpenAI, Google Gemini,
or a local Ollama model — you swap only the DI registration.
Core building blocks in Microsoft.Extensions.AI
| Abstraction | Purpose |
|---|---|
IChatClient | Unified interface to any LLM backend (OpenAI, Ollama, Azure, etc.) |
ChatMessage | Represents a single turn in a conversation (System / User / Assistant / Tool) |
ChatOptions | Per-call settings: tools list, temperature, max tokens, etc. |
AIFunction | A wrapped .NET method the LLM can call as a tool |
AIFunctionFactory | Creates AIFunction objects from plain C# methods via reflection |
ChatClientBuilder | Pipeline builder — adds middleware like automatic function invocation |
FunctionCallContent | Content piece in an assistant message that requests a tool call |
FunctionResultContent | The tool's return value, fed back as a Tool-role message |
2. Why Do We Need AI Agents?
A plain LLM call (prompt → response) is stateless and cannot access live data or execute computations reliably. Agents solve this through three capabilities:
- Tool Calling (Function Calling) — The LLM can decide to invoke a registered function, wait for the result, and incorporate it into its reasoning. This lets the AI call a tax calculation function rather than guessing the answer.
- Multi-step Reasoning (Agentic Loop) — The agent loops: think → act (call tool) → observe (get result) → think again until it has enough information to answer.
- Multi-Agent Orchestration — Complex tasks are broken into smaller sub-tasks, each handled by a focused specialist agent. An orchestrator coordinates them and synthesizes a final result.
3. The InvestIQ Application — Overview
InvestIQ is a .NET console application that accepts a user's financial details (income, expenses, age, investment goal) in plain English and returns a personalised Indian investment plan. It uses:
- Ollama running
llama3.2locally as the LLM backend - Microsoft.Extensions.AI for the agent abstractions and tool calling
- Three specialist AI agents — Income Analyzer, Risk Profiler, Recommender
- One orchestrator that sequences the agents and synthesises the final report
- Spectre.Console for a rich, coloured terminal UI
- Microsoft.Extensions.DependencyInjection for clean DI wiring
Project structure
InvestmentAdvisor/ ├── Program.cs // DI setup + main loop ├── InvestmentAdvisor.csproj ├── Models/ │ └── Models.cs // Data contracts (records + enums) ├── Agents/ │ └── AgentFactory.cs // Creates the 3 specialist agents ├── Services/ │ └── InvestmentAdvisorService.cs // Orchestrator — sequences the agents ├── Tools/ │ └── FinancialTools.cs // Pure C# tool functions for the LLM └── UI/ └── ConsoleUI.cs // Spectre.Console rendering helpers
Figure 1 — InvestIQ multi-agent architecture. User input flows through the DI container
into the Orchestrator, which runs three specialist agents sequentially, each backed by
Ollama via IChatClient. The synthesised report streams back through
Spectre.Console.
4. Setting Up Ollama on Your Local Machine
Ollama is a free, open-source tool that lets you download and run large language
models entirely on your own hardware — no cloud account, no API key, no internet connection
required after the initial download. It manages model files, handles GPU/CPU inference, and
exposes a local REST API that OllamaSharp connects to.
Step 1 — Download and Install Ollama
Go to the official Ollama website and download the installer for your operating system:
| Operating System | Download | Installer Type |
|---|---|---|
| Windows 10 / 11 | https://ollama.com/download/OllamaSetup.exe |
One-click .exe installer |
| macOS (Apple Silicon & Intel) | https://ollama.com/download/Ollama-darwin.zip |
Drag-to-Applications .zip |
| Linux | https://ollama.com/download/ollama-linux-amd64.tgz |
Binary archive or one-line install script |
On Linux, the fastest way to install is the official one-liner:
curl -fsSL https://ollama.com/install.sh | sh
Minimum 8 GB RAM | 4 GB free disk | Any modern CPU
Recommended: NVIDIA GPU with 6 GB+ VRAM (CUDA) for faster inference. Ollama automatically uses the GPU if available; falls back to CPU otherwise.
Step 2 — Verify the Installation
After installation, open a terminal and check the version:
ollama --version
# Expected output: ollama version 0.x.x
Start the Ollama background server (it also starts automatically on Windows/macOS after install):
ollama serve
# Server listens on: http://localhost:11434
Step 3 — Where Ollama Stores Model Files
When you pull a model, Ollama downloads it as .gguf quantised weight files and
stores them in a local directory. Knowing the path is useful when managing disk space.
| Operating System | Default Model Storage Path |
|---|---|
| Windows | C:\Users\<YourName>\.ollama\models\ |
| macOS | ~/.ollama/models/ |
| Linux | /usr/share/ollama/.ollama/models/ (system install)~/.ollama/models/ (user install) |
To use a custom path, set the OLLAMA_MODELS environment variable before starting the server:
# Windows (PowerShell) $env:OLLAMA_MODELS = "D:\AI\OllamaModels" ollama serve # Linux / macOS export OLLAMA_MODELS=/data/ollama/models ollama serve
Step 4 — Understanding the llama3.2 Model
Meta Llama 3.2 is Meta's third-generation open-weight language model family, released in September 2024. It comes in two sizes suited for local use:
| Variant | Parameters | Download Size | RAM Needed | Best For |
|---|---|---|---|---|
llama3.2:1b |
1 Billion | ~1.3 GB | 4 GB RAM | Very fast, low-resource devices |
llama3.2 (default) |
3 Billion | ~2.0 GB | 8 GB RAM | Best balance of speed and quality |
Key capabilities of llama3.2 (3B) relevant to this application:
- Tool / Function Calling — understands when to invoke a registered function and formats the call correctly, which is critical for our agentic pipeline.
- 128K context window — can hold long conversation histories; essential for follow-up questions that reference earlier plan details.
- Instruction following — reliably follows structured system prompts such as "always return a markdown table" and "use the GetInstrumentDetails tool before recommending".
- Multilingual — handles English prompts that mix Indian financial terms (PPF, ELSS, 80C, NPS) without fine-tuning.
- Quantization — Ollama ships the
Q4_K_Mquantised version by default, which is ~2 GB on disk instead of the full 6 GB FP16 weights, with minimal quality loss.
GGUF (GPT-Generated Unified Format) is the file format used by Ollama and llama.cpp. Quantisation reduces each model weight from 16-bit floats to 4-bit integers, cutting the file size by ~4x and RAM usage by the same factor. The
Q4_K_M variant keeps
accuracy within 1-2% of the original FP16 model.
Step 5 — Pull and Run llama3.2
# Download the 3B model (~2 GB, stored in the path shown in Step 3) ollama pull llama3.2 # Expected output: # pulling manifest # pulling 966de95ca8a6... 100% ▕████████████████████▏ 2.0 GB # pulling 38762ea7dc8e... 100% ▕████████████████████▏ 182 B # verifying sha256 digest # writing manifest # success
# Optional: test the model interactively in the terminal ollama run llama3.2 # Type a message and press Enter — you should get a response # Type /bye to exit the chat
# List all downloaded models on your machine ollama list # NAME ID SIZE MODIFIED # llama3.2:latest 8eeb52dfb3bb 2.0 GB 2 minutes ago
# Show model metadata (context length, parameters, quantization) ollama show llama3.2 # Model # architecture llama # parameters 3.2B # context length 131072 # embedding length 3072 # quantization Q4_K_M
Step 6 — Confirm the REST API is Responding
Our application connects to Ollama at http://localhost:11434.
Confirm it is up before running the .NET app:
# In a browser or terminal — should return: {"status":"Ollama is running"} curl http://localhost:11434 # List available models via the API curl http://localhost:11434/api/tags
ollama serve & to background it, or
configure it as a systemd service:
sudo systemctl enable ollama && sudo systemctl start ollama
5. 1 Project Setup — NuGet Packages
Create a new console project targeting .NET 9 (or .NET 10 preview):
dotnet new console -n InvestmentAdvisor cd InvestmentAdvisor dotnet add package Microsoft.Extensions.AI dotnet add package OllamaSharp dotnet add package Microsoft.Extensions.DependencyInjection dotnet add package Spectre.Console
The resulting .csproj file:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net9.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.Extensions.AI" Version="9.3.0" /> <PackageReference Include="OllamaSharp" Version="5.1.5" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" /> <PackageReference Include="Spectre.Console" Version="0.49.1" /> </ItemGroup> </Project>
OllamaApiClient which implements
IChatClient, so it plugs directly into the MEAI abstraction with zero
extra adapter code.
6. 2 Data Models
We start with the domain models. Using C# record types gives us
immutable, value-equality objects with minimal ceremony.
namespace InvestmentAdvisor.Models; // Captures everything the user tells us about their finances public record UserFinancialProfile( decimal GrossAnnualIncome, decimal MonthlyExpenses, int Age, string Goal, int InvestmentHorizonYears, decimal ExistingInvestments = 0 ); // Three risk tiers used throughout the recommendation logic public enum RiskLevel { Low, Medium, High } // A single instrument in the final allocation table public record InvestmentAllocation( string Instrument, decimal MonthlyAmount, string Rationale, bool TaxBenefit );
These records are the contracts between layers. Although the LLM returns plain text, having strongly-typed models prevents accidental shape drift if you later extend the app to a web API.
7. 3 Financial Tools — Giving the LLM Calculation Power
LLMs are unreliable at arithmetic. Instead of asking the model to calculate tax slabs, we write precise C# methods and register them as AI Tools. The LLM decides when to call a tool; .NET executes it and returns the result.
MEAI uses the [Description] attribute to generate the JSON schema that
the LLM sees in its system prompt. No manual schema writing needed.
using System.ComponentModel; namespace InvestmentAdvisor.Tools; public static class FinancialTools { // ── Tool 1: Income Analyzer ───────────────────────────────────────── // The [Description] attribute tells the LLM what the method does. // Parameter descriptions explain each argument — the LLM fills them in. [Description("Calculate disposable monthly income, tax estimate, " + "and 80C remaining limit for an Indian salaried investor.")] public static string AnalyzeIncome( [Description("Gross annual income in INR")] decimal grossAnnualIncome, [Description("Total monthly expenses in INR")] decimal monthlyExpenses) { var monthlyGross = grossAnnualIncome / 12; // Simplified new tax regime slabs (FY 2024-25) decimal estimatedAnnualTax = grossAnnualIncome switch { <= 300_000 => 0, <= 600_000 => (grossAnnualIncome - 300_000) * 0.05m, <= 900_000 => 15_000 + (grossAnnualIncome - 600_000) * 0.10m, <= 1_200_000 => 45_000 + (grossAnnualIncome - 900_000) * 0.15m, <= 1_500_000 => 90_000 + (grossAnnualIncome - 1_200_000) * 0.20m, _ => 150_000 + (grossAnnualIncome - 1_500_000) * 0.30m }; var monthlyTax = estimatedAnnualTax / 12; var disposable = monthlyGross - monthlyExpenses - monthlyTax; var safeInvestment = Math.Max(0, disposable * 0.40m); var section80CRoom = 150_000m; // full headroom return $""" Monthly gross income : ₹{monthlyGross:N0} Estimated monthly tax: ₹{monthlyTax:N0} Monthly expenses : ₹{monthlyExpenses:N0} Monthly disposable : ₹{disposable:N0} Safe monthly budget : ₹{safeInvestment:N0} (40% of disposable) 80C annual headroom : ₹{section80CRoom:N0} """; } // ── Tool 2: Risk Profiler ─────────────────────────────────────────── [Description("Calculate a risk score (Low/Medium/High) based on age, horizon, and goal.")] public static string ProfileRisk( [Description("Investor age in years")] int age, [Description("Investment horizon in years")] int horizonYears, [Description("Goal: retirement | child_education | wealth_creation | emergency_fund")] string goal) { goal = string.IsNullOrWhiteSpace(goal) ? "unknown" : goal; int score = 0; // Age: younger = higher tolerance if (age < 30) score += 3; else if (age < 45) score += 2; else score += 1; // Horizon: longer = higher tolerance if (horizonYears > 15) score += 3; else if (horizonYears > 7) score += 2; else score += 1; // Goal component score += goal.ToLower() switch { "wealth_creation" => 2, "retirement" => 2, "child_education" => 1, "emergency_fund" => 0, _ => 1 }; string level = score >= 7 ? "High" : score >= 4 ? "Medium" : "Low"; string guidance = level switch { "High" => "Equity-heavy (70%+ equity). ELSS, index funds, NPS Tier-I.", "Medium" => "Balanced (50% equity / 50% debt). ELSS + PPF + debt funds.", _ => "Debt-heavy (70%+ debt). PPF, FD, LIC, NPS Tier-II conservative." }; return $""" Risk score : {score}/8 Risk level : {level} Goal : {goal} Horizon : {horizonYears} years Guidance : {guidance} """; } // ── Tool 3: Instrument Details ────────────────────────────────────── [Description("Get returns, lock-in, tax benefits for a specific Indian investment instrument.")] public static string GetInstrumentDetails( [Description("Instrument: PPF | ELSS | SIP | LIC | NPS | FD | GOLD | RD")] string instrument) { return instrument.Trim().ToUpper() switch { "PPF" => "PPF: 7.1% p.a. tax-free, 15-yr lock-in, 80C up to ₹1.5L, sovereign.", "ELSS" => "ELSS: 12-15% avg, 3-yr lock-in (shortest 80C), equity risk.", "SIP" => "SIP (index MF): 10-12% long-term, no lock-in, flexible.", "LIC" => "LIC endowment: 5-6%, insurance + savings, 80C, guaranteed.", "NPS" => "NPS: 10-12% market-linked, retirement-locked, 80C + ₹50k 80CCD(1B).", "FD" => "FD: 6.5-7.5%, high safety, taxable (TDS). 5-yr tax-saving FD = 80C.", "GOLD" => "SGB: 2.5%+gold appreciation (~8-10% CAGR), 8-yr, tax-free at maturity.", "RD" => "RD: 6-7%, monthly installment, no lock-in, taxable.", _ => $"Unknown instrument '{instrument}'." }; } }
AIFunctionFactory.Create(FinancialTools.AnalyzeIncome).
MEAI reads the [Description] attributes via reflection and produces a
JSON schema that is appended to the system prompt so the LLM knows when and how to call
the function.
8. 4 AgentFactory — Creating Specialist Agents
Each specialist agent is an IChatClient configured with:
- A system prompt that focuses it on its one job.
- The
.UseFunctionInvocation()middleware so it automatically handles the tool-call / tool-result loop without extra code in the service layer.
using Microsoft.Extensions.AI; using InvestmentAdvisor.Tools; namespace InvestmentAdvisor.Agents; public class AgentFactory { private readonly IChatClient _chatClient; public AgentFactory(IChatClient chatClient) => _chatClient = chatClient; // ── Specialist 1: Tax + Income Analysis ────────────────────────── public IChatClient CreateIncomeAnalyzerAgent() => new ChatClientBuilder(_chatClient) .UseFunctionInvocation() // auto-calls tools when LLM requests them .Build(); public string IncomeAnalyzerSystemPrompt => """ You are an Indian income tax expert and financial planner. When given income and expense details, use the AnalyzeIncome tool to compute: - Disposable monthly income - Estimated tax under the new regime - Safe monthly investment budget (40% of disposable) - Remaining 80C headroom Return the tool output as-is. Be concise and factual. """; // ── Specialist 2: Risk Profiler ─────────────────────────────────── public IChatClient CreateRiskProfilerAgent() => new ChatClientBuilder(_chatClient) .UseFunctionInvocation() .Build(); public string RiskProfilerSystemPrompt => """ You are a risk assessment specialist for Indian retail investors. Use the ProfileRisk tool to determine Low/Medium/High risk tolerance based on age, investment horizon, and financial goal. Return a clear verdict with the numeric score and one-line portfolio guidance. """; // ── Specialist 3: Investment Recommender ───────────────────────── public IChatClient CreateRecommenderAgent() => new ChatClientBuilder(_chatClient) .UseFunctionInvocation() .Build(); public string RecommenderSystemPrompt => """ You are a SEBI-aware Indian investment advisor. Given a monthly budget, risk level, and 80C headroom, recommend a monthly allocation across Indian instruments (PPF, ELSS, SIP, LIC, NPS, FD, GOLD). Rules: 1. Use GetInstrumentDetails tool to verify rates before recommending. 2. Always fill 80C limit first using ELSS (aggressive) or PPF+LIC (conservative). 3. NPS earns an additional ₹50,000 deduction under 80CCD(1B). 4. Never recommend more than 5 instruments — keep it simple. 5. Present a clean markdown table: Instrument | Monthly ₹ | 80C? | Expected return | Rationale 6. Add estimated annual tax saving at the bottom. 7. Close with the SEBI disclaimer. """; }
Notice that all three agents share the same underlying
OllamaApiClient; they are just different pipelines wrapping it,
each with its own system prompt and tool set. This is extremely memory-efficient.
ChatClientBuilder pipeline:
Think of it like ASP.NET middleware. .UseFunctionInvocation() inserts a
middleware layer that intercepts FunctionCallContent responses from the LLM,
executes the corresponding .NET method, and re-sends the result automatically — your
code never sees the raw tool-call round-trips.
9. 5 InvestmentAdvisorService — The Orchestrator
The orchestrator is the heart of the application. It:
- Runs the Income Analyzer → gets disposable income & 80C room.
- Runs the Risk Profiler → gets a Low/Medium/High risk verdict.
- Runs the Recommender with the results from steps 1 & 2.
- Passes all three outputs to a synthesising orchestrator prompt and streams the final formatted report back to the UI.
- Persists the conversation history so follow-up questions work naturally.
using Microsoft.Extensions.AI; using InvestmentAdvisor.Agents; using InvestmentAdvisor.Tools; namespace InvestmentAdvisor.Services; public class InvestmentAdvisorService { private readonly AgentFactory _factory; private readonly IChatClient _chatClient; // Wrap plain C# methods as AIFunction objects the LLM can call private readonly AIFunction _analyzeIncomeFn; private readonly AIFunction _profileRiskFn; private readonly AIFunction _getInstrumentFn; // Persisted conversation history for follow-up support private List<ChatMessage> _orchestratorHistory = new(); public InvestmentAdvisorService(AgentFactory factory, IChatClient chatClient) { _factory = factory; _chatClient = chatClient; // AIFunctionFactory.Create() uses reflection + [Description] attrs // to generate the JSON schema the LLM receives _analyzeIncomeFn = AIFunctionFactory.Create(FinancialTools.AnalyzeIncome); _profileRiskFn = AIFunctionFactory.Create(FinancialTools.ProfileRisk); _getInstrumentFn = AIFunctionFactory.Create(FinancialTools.GetInstrumentDetails); } // ── Main Pipeline ──────────────────────────────────────────────── public async IAsyncEnumerable<string> AdviseAsync(string userMessage) { // Step 1: Income Analysis var incomeResult = await RunSpecialistAsync( systemPrompt : _factory.IncomeAnalyzerSystemPrompt, userMessage : $"Analyze this investor's income: {userMessage}", tools : [_analyzeIncomeFn] ); // Step 2: Risk Profiling var riskResult = await RunSpecialistAsync( systemPrompt : _factory.RiskProfilerSystemPrompt, userMessage : $"Profile the risk for this investor: {userMessage}", tools : [_profileRiskFn] ); // Step 3: Investment Recommendation // Pass step 1 + step 2 results as context var recommendContext = $""" Investor query: {userMessage} Income analysis result: {incomeResult} Risk profile result: {riskResult} Based on the above, generate a detailed investment allocation plan. """; var recommendResult = await RunSpecialistAsync( systemPrompt : _factory.RecommenderSystemPrompt, userMessage : recommendContext, tools : [_getInstrumentFn] ); // Step 4: Orchestrator synthesises and streams the formatted report const string orchestratorSystem = """ You are a senior financial planning coordinator. Synthesize results from three specialist analysts into a report: ## Financial Snapshot ## Risk Profile ## Recommended Monthly Investment Plan ## Summary ## Important Disclaimer """; var synthesisPrompt = $""" Income analysis: {incomeResult} Risk profile: {riskResult} Investment recommendation: {recommendResult} Synthesize these into the formatted investment plan report. """; _orchestratorHistory.Add(new ChatMessage(ChatRole.User, userMessage)); var orchestratorMessages = new List<ChatMessage> { new(ChatRole.System, orchestratorSystem), new(ChatRole.User, synthesisPrompt) }; var finalResponse = new System.Text.StringBuilder(); // Stream each token back to the UI as it arrives await foreach (var update in _chatClient.GetStreamingResponseAsync(orchestratorMessages)) { var text = update.Text ?? string.Empty; finalResponse.Append(text); yield return text; // <-- streamed to caller } _orchestratorHistory.Add(new ChatMessage(ChatRole.Assistant, finalResponse.ToString())); } // ── Follow-up with conversation history ────────────────────────── public async IAsyncEnumerable<string> FollowUpAsync(string userMessage) { _orchestratorHistory.Add(new ChatMessage(ChatRole.User, userMessage)); var messages = new List<ChatMessage> { new(ChatRole.System, """ You are a senior Indian investment advisor. The user has already received their investment plan. Answer their follow-up question using conversation history. Reference exact numbers from their plan. End with SEBI disclaimer if giving new investment advice. """) }; messages.AddRange(_orchestratorHistory); var reply = new System.Text.StringBuilder(); await foreach (var update in _chatClient.GetStreamingResponseAsync(messages)) { var text = update.Text ?? string.Empty; reply.Append(text); yield return text; } _orchestratorHistory.Add(new ChatMessage(ChatRole.Assistant, reply.ToString())); } public bool HasHistory => _orchestratorHistory.Count > 0; public void ResetSession() => _orchestratorHistory.Clear(); // ── Private: Run one specialist agent (agentic loop) ───────────── private async Task<string> RunSpecialistAsync( string systemPrompt, string userMessage, IList<AIFunction> tools) { var options = new ChatOptions { Tools = [.. tools] }; var messages = new List<ChatMessage> { new(ChatRole.System, systemPrompt), new(ChatRole.User, userMessage) }; // Agentic loop: keep running until the LLM stops requesting tools while (true) { var response = await _chatClient.GetResponseAsync(messages, options); foreach (var msg in response.Messages) messages.Add(msg); bool hasToolCalls = false; foreach (var msg in response.Messages) { foreach (var content in msg.Contents) { if (content is FunctionCallContent toolCall) { hasToolCalls = true; var result = await ExecuteToolAsync(toolCall, tools); messages.Add(new ChatMessage(ChatRole.Tool, [new FunctionResultContent(toolCall.CallId, result)])); } } } if (!hasToolCalls) { // No more tool calls — return the final text answer var last = messages.LastOrDefault(m => m.Role == ChatRole.Assistant); return last?.Text ?? string.Empty; } } } private static async Task<object?> ExecuteToolAsync( FunctionCallContent toolCall, IList<AIFunction> tools) { var fn = tools.FirstOrDefault(t => t.Name == toolCall.Name); if (fn is null) return $"Tool '{toolCall.Name}' not found."; try { return await fn.InvokeAsync( toolCall.Arguments is not null ? new AIFunctionArguments(toolCall.Arguments) : null); } catch (Exception ex) { return $"Tool error: {ex.Message}"; } } }
Understanding the agentic loop in RunSpecialistAsync
The while(true) loop is the classic ReAct pattern
(Reason + Act) used in all serious agent frameworks:
- Reason: Call the LLM with the current message list. It returns either
a text answer or one or more
FunctionCallContentrequests. - Act: If tool calls are present, execute each .NET method and add a
FunctionResultContentmessage (role = Tool) to the history. - Observe: The next LLM call now includes the tool output — the model "sees" the result and reasons again.
- Terminate: When the LLM produces a response with no tool calls, the loop exits and returns the final text.
.UseFunctionInvocation() on the
ChatClientBuilder, this entire loop is handled automatically by MEAI
middleware. The manual loop shown above gives you full control — useful when
you need to log each tool call, add rate limiting, or inspect intermediate results.
10. 6 ConsoleUI — Rich Terminal Output with Spectre.Console
All terminal rendering is isolated in ConsoleUI.cs. Using
Spectre.Console we get colours, panels, Figlet banners, and clean markup
without scattering ANSI escape codes throughout the codebase.
using Spectre.Console; namespace InvestmentAdvisor.UI; public static class ConsoleUI { public static void ShowBanner() { AnsiConsole.Clear(); // Renders "InvestIQ" as a large ASCII art banner in green AnsiConsole.Write(new FigletText("InvestIQ").Color(Color.Green)); AnsiConsole.MarkupLine("[grey]AI-Powered Indian Investment Advisor[/]"); AnsiConsole.MarkupLine("[yellow]⚠️ AI-generated guidance only. Consult a SEBI advisor.[/]"); } // Streams each text chunk directly to stdout as it arrives from the LLM public static async Task StreamResponseAsync( IAsyncEnumerable<string> stream, CancellationToken ct = default) { AnsiConsole.Markup("[blue]Advisor >[/] "); await foreach (var chunk in stream.WithCancellation(ct)) Console.Write(chunk); // raw write avoids Spectre escaping issues AnsiConsole.WriteLine(); } public static void ShowError(string message) => AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(message)}[/]"); public static void ShowStageHeader(string stage, string step) => AnsiConsole.MarkupLine($"[grey] {step} {stage}...[/]"); public static void ShowSessionReset() => AnsiConsole.Write(new Rule("[yellow]Session reset — starting fresh[/]")); }
The key method is StreamResponseAsync. It accepts an
IAsyncEnumerable<string> — the token stream from the LLM —
and writes each chunk immediately. This is what makes the response feel
live rather than appearing all at once after a long wait.
11. 7 Program.cs — Wiring It All Together with DI
The top-level file sets up the DI container, configures the Ollama client, and drives the main conversation loop.
using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using OllamaSharp; using InvestmentAdvisor.Agents; using InvestmentAdvisor.Services; using InvestmentAdvisor.UI; // ── 1. Configuration ───────────────────────────────────────────────────── // Change these two values to point to your Ollama instance and model. const string OllamaEndpoint = "http://localhost:11434/"; const string ModelName = "llama3.2"; // ── 2. DI Container Setup ──────────────────────────────────────────────── var services = new ServiceCollection(); // Register Ollama as the IChatClient implementation. // OllamaApiClient implements IChatClient — zero adapter code needed. services.AddSingleton<IChatClient>(_ => new OllamaApiClient(new Uri(OllamaEndpoint), ModelName)); services.AddSingleton<AgentFactory>(); services.AddSingleton<InvestmentAdvisorService>(); var sp = services.BuildServiceProvider(); var advisor = sp.GetRequiredService<InvestmentAdvisorService>(); // ── 3. UI — Display banner and help ────────────────────────────────────── ConsoleUI.ShowBanner(); ConsoleUI.ShowHelp(); // ── 4. Main Conversation Loop ──────────────────────────────────────────── while (true) { var input = ConsoleUI.GetUserInput(isFollowUp: advisor.HasHistory); if (string.IsNullOrWhiteSpace(input)) continue; // Built-in commands if (input.Equals("exit", StringComparison.OrdinalIgnoreCase) || input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { ConsoleUI.ShowGoodbye(); break; } if (input.Equals("reset", StringComparison.OrdinalIgnoreCase)) { advisor.ResetSession(); ConsoleUI.ShowSessionReset(); continue; } try { IAsyncEnumerable<string> stream; if (!advisor.HasHistory) { // First message → full 3-agent pipeline ConsoleUI.ShowStageHeader("Analyzing income", "1/3"); ConsoleUI.ShowStageHeader("Profiling risk", "2/3"); ConsoleUI.ShowStageHeader("Building investment plan", "3/3"); stream = advisor.AdviseAsync(input); } else { // Follow-up → single orchestrator pass with history stream = advisor.FollowUpAsync(input); } await ConsoleUI.StreamResponseAsync(stream); } catch (HttpRequestException) { ConsoleUI.ShowError( $"Cannot reach Ollama at {OllamaEndpoint}. Run: ollama serve"); } catch (Exception ex) { ConsoleUI.ShowError(ex.Message); } }
InvestmentAdvisorService takes
an IChatClient in its constructor, you can swap out
OllamaApiClient for a mock or any other provider (Azure OpenAI, Anthropic,
etc.) by changing only the DI registration — no code changes needed elsewhere.
12. Running the Application
Prerequisites
- Install Ollama and pull the model:
ollama pull llama3.2 ollama serve
- Install .NET 9 SDK or later.
Build and run
cd InvestmentAdvisor dotnet run
Example session
You > My annual salary is ₹12 lakhs. Monthly expenses ₹40,000. Age 32, want to retire in 25 years. Advisor > ## Financial Snapshot Monthly gross income : ₹1,00,000 Estimated monthly tax: ₹5,417 Monthly disposable : ₹54,583 Safe monthly budget : ₹21,833 (40% of disposable) 80C annual headroom : ₹1,50,000 ## Risk Profile Risk score : 7/8 → HIGH Equity-heavy portfolio (70%+ equity). ELSS, index funds, NPS Tier-I. ## Recommended Monthly Investment Plan | Instrument | Monthly ₹ | 80C? | Expected Return | Rationale | |------------|-----------|------|-----------------|------------------------| | ELSS | ₹8,000 | Yes | 12-15% | Max 80C benefit | | NPS | ₹4,000 | Yes* | 10-12% | Extra ₹50k 80CCD(1B) | | Index SIP | ₹6,000 | No | 10-12% | Long-term wealth | | Gold SGB | ₹3,833 | No | 8-10% | Inflation hedge | Estimated annual tax saving: ₹46,800 ⚠️ This is AI-generated guidance. Consult a SEBI-registered advisor.
13. Key Concepts Recap
| Concept | Where in the code | What it does |
|---|---|---|
IChatClient |
Program.cs DI registration |
Provider-agnostic LLM interface — swap Ollama for Azure OpenAI with one line change |
AIFunctionFactory.Create() |
InvestmentAdvisorService constructor |
Converts a plain C# method into an LLM-callable tool with auto-generated JSON schema |
ChatClientBuilder.UseFunctionInvocation() |
AgentFactory |
Middleware that handles the tool-call/tool-result loop automatically |
FunctionCallContent |
RunSpecialistAsync loop |
The LLM's request to call a registered tool |
FunctionResultContent |
ExecuteToolAsync |
The tool's return value fed back to the LLM as a Tool-role message |
GetStreamingResponseAsync() |
AdviseAsync |
Returns IAsyncEnumerable<string> — token-by-token streaming |
ChatMessage history |
_orchestratorHistory |
In-memory conversation turns that give the LLM context for follow-up questions |
The following video will show you the interaction with the agent.
Conclusion
We have built a production-grade, multi-agent AI application entirely in .NET — no Python, no third-party agent framework required. The key takeaways are:
-
Microsoft.Extensions.AI provides a clean, provider-agnostic abstraction
(
IChatClient) that works with any LLM backend — local (Ollama) or cloud (Azure OpenAI, OpenAI). -
AI Tools are just plain C# static methods decorated with
[Description].AIFunctionFactory.Create()does the rest. No boilerplate, no hand-crafted JSON schemas. -
The agentic loop (ReAct pattern) — reason, call tools, observe, reason
again — is just a
while(true)loop that inspectsFunctionCallContentin the LLM response. -
Multi-agent orchestration is achieved by composing multiple
RunSpecialistAsynccalls sequentially, passing outputs as context to the next agent. - DI + interfaces keep everything testable and swappable — the same code can run against a local Ollama model in development and Azure OpenAI in production.
