<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Rahmat Afridi]]></title><description><![CDATA[Rahmat Afridi]]></description><link>https://rahmatafridi.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 23:42:48 GMT</lastBuildDate><atom:link href="https://rahmatafridi.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Observability and Evaluation for Microsoft Agent Framework Agents in ASP.NET Core]]></title><description><![CDATA[Instrument tool-using local agents with OpenTelemetry and verify behavior before they touch production traffic.]]></description><link>https://rahmatafridi.hashnode.dev/observability-evaluation-microsoft-agent-framework-aspnet-core</link><guid isPermaLink="true">https://rahmatafridi.hashnode.dev/observability-evaluation-microsoft-agent-framework-aspnet-core</guid><category><![CDATA[dotnet]]></category><category><![CDATA[Aspnetcore]]></category><category><![CDATA[OpenTelemetry]]></category><category><![CDATA[AI]]></category><category><![CDATA[ai agents]]></category><dc:creator><![CDATA[Rahmat Afridi]]></dc:creator><pubDate>Wed, 21 Oct 2026 05:09:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6abe915fbb839ec844c03eaf/439e14ed-a033-4fdc-a6b2-a2f3d1d6cda7.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Microsoft Agent Framework is now generally available and is the recommended path for new agent work in .NET. It sits on top of Microsoft.Extensions.AI, so the same IChatClient you already use with Ollama or Azure OpenAI becomes an AIAgent with one extension method. The practical problem that appears the moment you move beyond a console demo is the same one every production team hits: the agent calls tools, consumes tokens, and sometimes invents answers, yet you have almost no visibility into what actually happened.</p>
<p>This article shows a minimal, current pattern that adds OpenTelemetry instrumentation and a simple local evaluation loop around an Agent Framework agent hosted in ASP.NET Core. The agent runs against a local Ollama model and has one ordinary C# tool. The same registration works if you later swap the chat client for Azure OpenAI or OpenAI.</p>
<h2>What the Agent Framework actually emits</h2>
<p>Agent Framework builds on the Microsoft.Extensions.AI middleware pipeline. When you call UseOpenTelemetry on an IChatClientBuilder or on an AIAgent builder, the framework emits traces and metrics that follow the OpenTelemetry GenAI semantic conventions. You get spans for the overall agent invocation, for each chat completion, and for every tool execution. Token counts, latencies, and (optionally) sensitive content appear as attributes.</p>
<p>You do not have to write your own ActivitySource for the common path. You do have to subscribe to the right source names and decide whether sensitive data is allowed in your environment.</p>
<h2>Packages and project setup</h2>
<p>Create a minimal API project targeting .NET 8 or later (Agent Framework 1.x works on the supported .NET versions). Install the packages that are current as of late 2026:</p>
<pre><code class="language-bash">dotnet add package Microsoft.Agents.AI
dotnet add package Microsoft.Extensions.AI
dotnet add package OllamaSharp
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.Console
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
</code></pre>
<p>OllamaSharp is the recommended way to obtain an IChatClient for a local Ollama instance. Microsoft.Extensions.AI.Ollama is deprecated.</p>
<h2>Register the instrumented agent</h2>
<p>In Program.cs the registration is short. Add the OpenTelemetry middleware to the chat client and to the agent. You don't need to add function invocation yourself: the agent wraps the chat client in Microsoft.Extensions.AI's function-invocation middleware automatically, so each model call and each tool execution gets its own span under the agent run.</p>
<pre><code class="language-csharp">using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OllamaSharp;
using OpenTelemetry.Trace;
using System.ComponentModel;

var builder = WebApplication.CreateBuilder(args);

const string TelemetrySource = "MyApp.Agent";

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =&gt; tracing
        .AddSource(TelemetrySource)
        .AddConsoleExporter()
        // Uncomment for an OTLP collector or Aspire dashboard
        // .AddOtlpExporter(o =&gt; o.Endpoint = new Uri("http://localhost:4317"))
    );

builder.Services.AddSingleton&lt;IChatClient&gt;(sp =&gt;
{
    var ollama = new OllamaApiClient(new Uri("http://localhost:11434"), "llama3.2");
    return ollama
        .AsBuilder()
        .UseOpenTelemetry(sourceName: TelemetrySource, configure: cfg =&gt; cfg.EnableSensitiveData = false)
        .Build();
});

builder.Services.AddSingleton&lt;AIAgent&gt;(sp =&gt;
{
    var chatClient = sp.GetRequiredService&lt;IChatClient&gt;();
    return chatClient.AsAIAgent(
        instructions: "You are a helpful assistant. Use the GetWeather tool when the user asks about weather. Never invent weather data.",
        name: "WeatherAgent",
        tools: [AIFunctionFactory.Create(GetWeather)])
        .AsBuilder()
        .UseOpenTelemetry(sourceName: TelemetrySource, configure: cfg =&gt; cfg.EnableSensitiveData = false)
        .Build();
});

var app = builder.Build();

app.MapPost("/chat", async (ChatRequest request, AIAgent agent) =&gt;
{
    var result = await agent.RunAsync(request.Message);
    return Results.Ok(new { answer = result.Text });
});

app.Run();

[Description("Get the current weather for a city.")]
static string GetWeather([Description("City name")] string city)
    =&gt; $"The weather in {city} is 18Â°C and partly cloudy.";

record ChatRequest(string Message);
</code></pre>
<p>EnableSensitiveData is deliberately false in the example. Turn it on only in development or behind a private collector; otherwise prompts and tool results land in your telemetry store.</p>
<h2>What the traces look like</h2>
<p>When a request hits /chat, you see a parent span for the agent run. Inside it sits a chat span, and if the model decides to call GetWeather you see a child execute_tool span. The attributes include the model name, token usage, duration, and the tool name. This is enough to answer the usual production questions: Did the agent call the tool it was supposed to call? How many tokens did this turn cost? Was the latency dominated by the model or by the tool?</p>
<p>If you point the OTLP exporter at the Aspire dashboard or at Jaeger, the same spans appear with the parent-child relationship intact. No extra custom instrumentation is required for the basic path.</p>
<h2>Local evaluation before the agent leaves the machine</h2>
<p>Traces tell you what happened. Evaluation tells you whether the behavior is acceptable, and you can start with plain assertions that run against your local model without a cloud bill.</p>
<p>A practical starting point is a small console harness (or an integration test) that exercises the same agent registration and checks two things: the final answer contains expected keywords, and the required tool was actually invoked.</p>
<pre><code class="language-csharp">using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

// Assume the same AIAgent registration as above, resolved from a test host or built directly.

AIAgent agent = BuildWeatherAgent(); // same chat client + AsAIAgent registration as Program.cs

var evalResult = await agent.RunAsync("What is the weather in Seattle?");

// Simple local checks
bool containsExpected = evalResult.Text.Contains("Seattle", StringComparison.OrdinalIgnoreCase)
                        &amp;&amp; evalResult.Text.Contains("18", StringComparison.OrdinalIgnoreCase);

// The response includes the intermediate tool-call messages, so check that GetWeather was really invoked
bool toolWasCalled = evalResult.Messages
    .SelectMany(m =&gt; m.Contents)
    .OfType&lt;FunctionCallContent&gt;()
    .Any(call =&gt; call.Name == "GetWeather");

Console.WriteLine($"Content check: {containsExpected}");
Console.WriteLine($"Tool check: {toolWasCalled}");
</code></pre>
<p>When keyword checks stop being enough, the Microsoft.Extensions.AI.Evaluation libraries (Microsoft.Extensions.AI.Evaluation.Quality and .Reporting) add LLM-graded evaluators such as relevance, coherence, and groundedness, plus cached, repeatable reports you can run in CI. The important design point stays the same: run the checks against the live agent (or a recorded fixture) before the endpoint is exposed, and every time you change the prompt, the model, or a package version.</p>
<h2>Putting both pieces behind an ASP.NET Core endpoint</h2>
<p>The pattern that works in production is to keep the agent registration exactly as shown above and to surface only a thin endpoint. Health checks can verify that Ollama (or the cloud endpoint) is reachable. A separate internal endpoint or a background job can run a small evaluation suite on a schedule and emit a custom metric if the checks start failing.</p>
<pre><code class="language-csharp">app.MapGet("/health/agent", async (AIAgent agent) =&gt;
{
    try
    {
        var result = await agent.RunAsync("Say OK if you can use tools.");
        return Results.Ok(new { status = "healthy", preview = result.Text });
    }
    catch (Exception ex)
    {
        return Results.Problem(ex.Message);
    }
});
</code></pre>
<p>Keep this probe on a slow schedule or behind an internal route, because every call is a real model request. Because the OpenTelemetry middleware is already on the agent, every health-check call also appears in traces. You can alert on elevated error rates, missing tool spans, or sudden token spikes without writing extra logging code.</p>
<h2>Practical constraints</h2>
<p>Local models such as llama3.2 support tool calling reliably enough for this pattern, but smaller models sometimes skip the tool or hallucinate arguments. The evaluation checks catch that before users do. Sensitive data in traces is the other hard constraint: EnableSensitiveData = true is useful while you are debugging a single developer machine and dangerous once the collector is shared.</p>
<p>Agent Framework ships updates often, and the GenAI telemetry conventions are still evolving. Pin the packages you use and re-run the evaluation suite when you upgrade.</p>
<h2>Closing</h2>
<p>The difference between a working agent demo and an agent you can leave running is visibility and a repeatable check that the agent still does what you intended. OpenTelemetry on the Agent Framework builders gives you the traces and metrics. A short local evaluation loop gives you the quality gate. Both fit inside a normal ASP.NET Core host and stay provider-agnostic, so the same code works with Ollama today and with Azure OpenAI tomorrow.</p>
<hr />
<p><em>Rahmat Afridi is an AI Integration Developer and Senior .NET Developer based in Islamabad, Pakistan, building production .NET and AI-integrated systems since 2016. See his work and <a href="https://rahmatafridi.com/ai-integration/">AI integration services</a> at <a href="https://rahmatafridi.com">rahmatafridi.com</a>, or browse his code on <a href="https://github.com/rahmatafridi">GitHub</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Expose Your Existing ASP.NET Core APIs to AI Agents with a Secure MCP Server]]></title><description><![CDATA[Most .NET teams already have solid ASP.NET Core APIs that power their business logic, databases, and workflows. The new challenge is letting AI agents call…]]></description><link>https://rahmatafridi.hashnode.dev/expose-your-existing-asp-net-core-apis-to-ai-agents-with-a-secure-mcp-server</link><guid isPermaLink="true">https://rahmatafridi.hashnode.dev/expose-your-existing-asp-net-core-apis-to-ai-agents-with-a-secure-mcp-server</guid><category><![CDATA[mcp]]></category><category><![CDATA[ASP .NET Core]]></category><category><![CDATA[AI]]></category><category><![CDATA[.NET]]></category><category><![CDATA[C#]]></category><dc:creator><![CDATA[Rahmat Afridi]]></dc:creator><pubDate>Tue, 20 Oct 2026 16:50:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6abe915fbb839ec844c03eaf/2858222a-fbfa-4d9d-94da-dd741f8b7639.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most .NET teams already have solid ASP.NET Core APIs that power their business logic, databases, and workflows. The new challenge is letting AI agents call those same capabilities without giving them unrestricted access or forcing a full rewrite. The Model Context Protocol (MCP) solves this by providing a standard way for agents to discover and invoke tools. With the official ModelContextProtocol C# SDK, you can host an MCP server directly in ASP.NET Core, wrap your existing services as tools, and control exactly what agents can see and do.</p>
<p>This article shows a practical pattern: take a typical order-management API, expose a small set of read-only tools over MCP, lock it down with authentication and authorization, and verify the surface with an MCP client. The approach works for any existing service layer you already trust.</p>
<h2>Why a Separate MCP Surface Beats Auto-Exposing Every Endpoint</h2>
<p>Agents are not ordinary API clients. They decide which tools to call based on descriptions and schemas, then chain those calls. Exposing every controller action as a tool quickly produces a noisy, dangerous surface. A better design is a thin MCP server that sits beside your application (or inside the same host) and offers only the operations that are safe and useful for agents.</p>
<p>Start with read-only lookups that answer familiar questions: “What is the status of order 12345?” or “List open orders for customer C-789.” Write clear, specific descriptions so the model chooses the right tool. Later you can add carefully guarded write tools that require explicit human approval or elevated roles. The official SDK (ModelContextProtocol.AspNetCore 2.2.0 at the time of writing) makes this straightforward with attribute-based tools and first-class ASP.NET Core integration.</p>
<h2>Setting Up the MCP Server</h2>
<p>Create or open an ASP.NET Core web project and add the package:</p>
<pre><code class="language-bash">dotnet add package ModelContextProtocol.AspNetCore --version 2.2.0
</code></pre>
<p>In <code>Program.cs</code> register the server. The SDK’s HTTP transport is stateless by default in v2, which is ideal for load-balanced deployments:</p>
<pre><code class="language-csharp">using ModelContextProtocol.Server;

var builder = WebApplication.CreateBuilder(args);

// Your existing services
builder.Services.AddScoped&lt;IOrderService, OrderService&gt;();

builder.Services.AddMcpServer()
    .WithHttpTransport()
    .AddAuthorizationFilters()
    .WithToolsFromAssembly();

var app = builder.Build();

app.MapMcp("/mcp");

app.Run();
</code></pre>
<p><code>WithToolsFromAssembly</code> discovers every class marked <code>[McpServerToolType]</code> and every method marked <code>[McpServerTool]</code>. You can also register individual types with <code>.WithTools&lt;OrderTools&gt;()</code> if you prefer explicit control.</p>
<h2>Wrapping Existing Services as Tools</h2>
<p>Keep business logic in the services you already have. The tool methods simply call them and return results the agent can use. Inject the service (or any other DI-registered dependency) directly:</p>
<pre><code class="language-csharp">using Microsoft.AspNetCore.Authorization;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Security.Claims;

[McpServerToolType]
public class OrderTools
{
    private readonly IOrderService _orders;

    public OrderTools(IOrderService orders) =&gt; _orders = orders;

    [McpServerTool, Description(
        "Gets the current status and key details of a single order by its ID. " +
        "Use this when the user asks about a specific order number.")]
    public async Task&lt;string&gt; GetOrderStatus(
        [Description("The unique order identifier")] string orderId)
    {
        var order = await _orders.GetByIdAsync(orderId);
        if (order is null)
            return $"Order {orderId} was not found.";

        return $"Order {order.Id}: Status={order.Status}, Total={order.Total:C}, Customer={order.CustomerId}";
    }

    [McpServerTool, Description(
        "Lists open orders for a given customer. Returns at most 20 recent open orders.")]
    public async Task&lt;string&gt; ListOpenOrders(
        [Description("The customer identifier")] string customerId)
    {
        var orders = await _orders.GetOpenOrdersAsync(customerId, limit: 20);
        if (orders.Count == 0)
            return $"No open orders found for customer {customerId}.";

        return string.Join("\n", orders.Select(o =&gt;
            $"{o.Id}: {o.Status} - {o.Total:C}"));
    }
}
</code></pre>
<p>The <code>[Description]</code> attributes become the tool descriptions the agent sees. Write them the way you would write a good API comment—clear purpose, expected inputs, and any limits. The SDK generates the JSON schema from the method signature automatically.</p>
<h2>Adding Authentication and Authorization</h2>
<p>Treat the MCP endpoint as a protected API. Configure JWT bearer authentication the same way you already do for your other APIs, then require authorization on the MCP mapping:</p>
<pre><code class="language-csharp">using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =&gt;
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = builder.Configuration["Auth:Audience"];
        options.TokenValidationParameters.ValidateIssuer = true;
        options.TokenValidationParameters.ValidateAudience = true;
    });

builder.Services.AddAuthorization();

// ... AddMcpServer as before ...

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapMcp("/mcp").RequireAuthorization();
</code></pre>
<p>For finer control, apply <code>[Authorize]</code> attributes to individual tools. Because we called <code>AddAuthorizationFilters()</code>, the SDK honors them: unauthorized tools disappear from <code>tools/list</code> results, and direct calls return a forbidden error.</p>
<pre><code class="language-csharp">[McpServerTool, Description("Cancels an order. Admin role required.")]
[Authorize(Roles = "Admin")]
public async Task&lt;string&gt; CancelOrder(
    ClaimsPrincipal user,
    [Description("The order to cancel")] string orderId,
    [Description("Reason for cancellation")] string reason)
{
    // Audit who called it
    var caller = user.Identity?.Name ?? "unknown";
    await _orders.CancelAsync(orderId, reason, caller);
    return $"Order {orderId} cancelled by {caller}.";
}
</code></pre>
<p>The SDK injects <code>ClaimsPrincipal</code> automatically when the parameter is present; it never appears in the tool schema the agent sees. This gives you both declarative role checks and the ability to log or further filter inside the method.</p>
<p>You can also set a policy on the endpoint itself:</p>
<pre><code class="language-csharp">app.MapMcp("/mcp").RequireAuthorization("McpToolsPolicy");
</code></pre>
<p>where the policy requires an authenticated user plus a specific scope claim:</p>
<pre><code class="language-csharp">builder.Services.AddAuthorization(options =&gt;
{
    options.AddPolicy("McpToolsPolicy", policy =&gt; policy
        .RequireAuthenticatedUser()
        .RequireClaim("scope", "mcp.tools"));
});
</code></pre>
<h2>Limiting What Agents Can Call</h2>
<p>Several complementary techniques keep the surface small:</p>
<ul>
<li><p>Only mark the methods you want with <code>[McpServerTool]</code>. Everything else stays private to your normal API.</p>
</li>
<li><p>Prefer read-only tools first. Write operations should carry <code>[Authorize]</code> and, for high-risk actions, an extra confirmation step or human-in-the-loop flow.</p>
</li>
<li><p>Use precise descriptions and parameter constraints so the model is less likely to invent calls.</p>
</li>
<li><p>Consider separate MCP endpoints or tool groups for different agent roles if your organization needs stronger isolation.</p>
</li>
<li><p>Log every tool invocation (the SDK’s request filters or ordinary ASP.NET Core logging make this easy) so you can audit what agents actually did.</p>
</li>
</ul>
<p>Because the MCP server calls your existing service layer, all of the validation, business rules, and database constraints you already trust remain in force.</p>
<h2>Testing with an MCP Client</h2>
<p>You can verify the server without an LLM by using the SDK’s client. In a separate console project (or a test):</p>
<pre><code class="language-csharp">using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;

var transport = new HttpClientTransport(new HttpClientTransportOptions
{
    Endpoint = new Uri("http://localhost:5000/mcp")
    // Add authentication headers here if required
});

await using var client = await McpClient.CreateAsync(transport);

var tools = await client.ListToolsAsync();
foreach (var tool in tools)
{
    Console.WriteLine($"{tool.Name}: {tool.Description}");
}

var result = await client.CallToolAsync(
    "GetOrderStatus",
    new Dictionary&lt;string, object?&gt; { ["orderId"] = "ORD-1001" });

var text = result.Content.OfType&lt;TextContentBlock&gt;().FirstOrDefault()?.Text;
Console.WriteLine(text);
</code></pre>
<p>If you configured authentication, supply a valid bearer token on the <code>HttpClient</code> used by the transport. The tools returned by <code>ListToolsAsync</code> are <code>AIFunction</code> instances, so you can pass them to an <code>IChatClient</code> (Microsoft.Extensions.AI) through <code>ChatOptions.Tools</code> and let an agent invoke them automatically.</p>
<h2>Production Notes</h2>
<ul>
<li><p>Keep the MCP server stateless unless you specifically need server-to-client notifications.</p>
</li>
<li><p>Place it behind the same reverse proxy, rate limits, and WAF you use for the rest of your APIs.</p>
</li>
<li><p>Start with an internal-only deployment so you can observe real agent behavior before opening it more widely.</p>
</li>
<li><p>Monitor tool call volume and latency; agents can be chatty.</p>
</li>
<li><p>Version your tool contracts carefully—agents cache schemas and descriptions.</p>
</li>
</ul>
<p>The combination of a thin, explicitly curated tool surface, ordinary ASP.NET Core authentication, and per-tool authorization gives you a controlled way to let AI agents work with systems you already run. You do not need to rebuild your APIs; you only need to decide which capabilities are safe to describe and expose.</p>
<hr />
<p><strong>About the author</strong><br />Rahmat Afridi is an AI Integration Developer &amp; Senior .NET Developer based in Islamabad, Pakistan, with experience since 2016 building practical systems in C#, ASP.NET Core, Azure, and modern AI integration patterns.<br /><a href="https://rahmatafridi.com">https://rahmatafridi.com</a><br /><a href="https://github.com/rahmatafridi">https://github.com/rahmatafridi</a></p>
]]></content:encoded></item><item><title><![CDATA[Ollama ASP.NET Core: Running Local LLMs (and Switching to OpenAI with One Setting)]]></title><description><![CDATA[How to run local LLMs with Ollama behind ASP.NET Core and switch to OpenAI with one configuration setting, using a provider-agnostic IAIGateway.]]></description><link>https://rahmatafridi.hashnode.dev/ollama-aspnet-core-local-llms-openai</link><guid isPermaLink="true">https://rahmatafridi.hashnode.dev/ollama-aspnet-core-local-llms-openai</guid><category><![CDATA[dotnet]]></category><category><![CDATA[asp.net core]]></category><category><![CDATA[ollama]]></category><category><![CDATA[AI]]></category><category><![CDATA[C#]]></category><dc:creator><![CDATA[Rahmat Afridi]]></dc:creator><pubDate>Thu, 01 Oct 2026 17:33:17 GMT</pubDate><content:encoded><![CDATA[<p>My Ollama ASP.NET Core setup starts with a boundary: business services should not know where a language model runs. They should request an answer or an embedding through an interface, while configuration selects the provider.</p>
<p>I use that separation in SupportAgent.NET, my ASP.NET Core and React customer-support copilot. It retrieves company knowledge from SQL Server, calls tools such as <code>GetCustomer</code>, <code>GetOrderStatus</code>, and <code>SearchKnowledgeBase</code>, and saves replies as drafts for human review. Running locally should change the model endpoint, not permissions or the support workflow.</p>
<h2>When local models make sense</h2>
<p>A local LLM .NET deployment is useful when prompts and retrieved documents must stay inside a controlled environment. It also supports offline development after models and application dependencies have been downloaded.</p>
<p>I distinguish local inference from automatic privacy. SQL Server, application logs, backups, and monitoring still contain potentially sensitive information. Both chat and embedding requests need to remain local when that is the requirement.</p>
<p>Ollama also supports cloud models. For an explicitly local deployment, I select downloaded models and disable cloud features using <code>OLLAMA_NO_CLOUD=1</code>, as described in <a href="https://docs.ollama.com/faq">Ollama's FAQ</a>.</p>
<p>Local inference removes a hosted inference bill, not hardware, electricity, maintenance, or capacity costs. I would choose hosted inference when suitable hardware is unavailable, workloads are unpredictable, or the local model does not meet the application's quality requirements. I would not silently fall back to OpenAI for privacy-sensitive workloads.</p>
<h2>Installing Ollama and checking the API</h2>
<h3>Native installation</h3>
<p>Ollama provides native installers for Windows and macOS. On Linux, its installation guide provides this command:</p>
<pre><code class="language-bash">curl -fsSL https://ollama.com/install.sh | sh
</code></pre>
<p>I review installation scripts before executing them. Start the installed service, or run <code>ollama serve</code> when no server is already running. Then download separate chat and embedding models:</p>
<pre><code class="language-bash">ollama pull llama3.1:8b
ollama pull nomic-embed-text

curl --fail http://localhost:11434/api/tags

curl --fail http://localhost:11434/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"model":"llama3.1:8b","messages":[{"role":"user","content":"Say hello."}],"stream":false}'
</code></pre>
<p>The first request checks model availability; the second checks inference. These model names are setup examples, not a claim about comparative reliability.</p>
<h3>Docker alternative</h3>
<pre><code class="language-bash">docker run -d --name ollama \
  -p 127.0.0.1:11434:11434 \
  -v ollama:/root/.ollama \
  -e OLLAMA_NO_CLOUD=1 \
  ollama/ollama

docker exec ollama ollama pull llama3.1:8b
docker exec ollama ollama pull nomic-embed-text
</code></pre>
<p>The volume preserves models across container replacement. This follows Ollama's Docker setup, with host access restricted to loopback. GPU acceleration requires compatible host drivers and container configuration.</p>
<p>Use native installation or Docker on that port, not both. When ASP.NET Core also runs in Docker, <code>localhost</code> means its own container; use the Ollama service name on a private Docker network instead.</p>
<h2>An Ollama ASP.NET Core gateway abstraction</h2>
<p>The implementation is in <a href="https://github.com/rahmatafridi/SupportAgent.NET">SupportAgent.NET on GitHub</a>. The repository exposes <code>GenerateAsync</code> and a separate embedding service. Here I combine those responsibilities into a compact <code>IAIGateway</code> to demonstrate the wiring, using .NET 8-compatible C#.</p>
<pre><code class="language-csharp">using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;

public sealed record AiFunction(string Name, string Arguments);

public sealed record AiToolCall(
    string Id, string Type, AiFunction Function);

public sealed record AiMessage(
    string Role,
    string? Content,
    AiToolCall[]? ToolCalls = null,
    string? ToolCallId = null);

public interface IAIGateway
{
    Task&lt;AiMessage&gt; ChatAsync(
        AiMessage[] messages,
        JsonElement[] tools,
        CancellationToken ct = default);

    Task&lt;float[]&gt; EmbedAsync(
        string text,
        CancellationToken ct = default);
}
</code></pre>
<p>The response preserves native tool calls instead of flattening everything into text. Tool definitions contain application-owned function names and argument schemas. The RAG pipeline stores chunks and embeddings in SQL Server, combines semantic and keyword ranking, and sends only permitted, relevant passages to the gateway.</p>
<p>For OpenAI and Ollama in C#, I can share a small transport through <a href="https://docs.ollama.com/api/openai-compatibility">Ollama's OpenAI-compatible endpoints</a>. This is a shared API subset, not a guarantee of identical model behavior.</p>
<pre><code class="language-csharp">public abstract class HttpGateway(
    HttpClient http, EndpointOptions settings) : IAIGateway
{
    private static readonly JsonSerializerOptions Json = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };

    public async Task&lt;AiMessage&gt; ChatAsync(
        AiMessage[] messages,
        JsonElement[] tools,
        CancellationToken ct = default)
    {
        using var result = await PostAsync("v1/chat/completions", new
        {
            model = settings.Model,
            messages,
            stream = false,
            tools = tools.Length == 0 ? null : tools
        }, ct);

        return result.RootElement.GetProperty("choices")[0]
            .GetProperty("message").Deserialize&lt;AiMessage&gt;(Json)
            ?? throw new JsonException("Missing assistant message.");
    }

    public async Task&lt;float[]&gt; EmbedAsync(
        string text, CancellationToken ct = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(text);

        using var result = await PostAsync("v1/embeddings", new
        {
            model = settings.EmbeddingModel,
            input = text,
            encoding_format = "float"
        }, ct);

        var vector = result.RootElement.GetProperty("data")[0]
            .GetProperty("embedding").Deserialize&lt;float[]&gt;()
            ?? throw new JsonException("Missing embedding.");

        if (vector.Length == 0 || vector.All(x =&gt; x == 0) ||
            vector.Any(x =&gt; !float.IsFinite(x)))
            throw new JsonException("Invalid embedding.");

        return vector;
    }

    private async Task&lt;JsonDocument&gt; PostAsync(
        string path, object body, CancellationToken ct)
    {
        using var response =
            await http.PostAsJsonAsync(path, body, Json, ct);

        response.EnsureSuccessStatusCode();

        await using var stream =
            await response.Content.ReadAsStreamAsync(ct);

        return await JsonDocument.ParseAsync(
            stream, cancellationToken: ct);
    }
}

public sealed class OllamaGateway(
    HttpClient http, EndpointOptions settings)
    : HttpGateway(http, settings) { }

public sealed class OpenAIGateway(
    HttpClient http, EndpointOptions settings)
    : HttpGateway(http, settings) { }
</code></pre>
<p>Both concrete gateways use the same implementation; configuration supplies their models, URLs, and authentication. The adapter follows the OpenAI Chat Completions message contract and returns one complete, non-streaming response. Embedding requests use the documented floating-point embedding format.</p>
<p>The orchestration layer remains responsible for executing validated tools, appending their results, and requesting another turn. In production, I would also limit response sizes and output tokens, handle truncation explicitly, and avoid logging prompts or credentials.</p>
<h2>Switching providers with one setting</h2>
<p>For the compact example, I configure both destinations without storing a key:</p>
<pre><code class="language-json">{
  "AI": {
    "Provider": "Ollama",
    "Ollama": {
      "BaseUrl": "http://localhost:11434/",
      "Model": "llama3.1:8b",
      "EmbeddingModel": "nomic-embed-text"
    },
    "OpenAI": {
      "BaseUrl": "https://api.openai.com/",
      "Model": "gpt-4o-mini",
      "EmbeddingModel": "text-embedding-3-small"
    }
  }
}
</code></pre>
<p>These are server-side settings. Neither provider configuration nor credentials belong in React.</p>
<pre><code class="language-csharp">public sealed class AIOptions
{
    public string Provider { get; set; } = "Ollama";
    public EndpointOptions Ollama { get; set; } = new();
    public EndpointOptions OpenAI { get; set; } = new();

    public EndpointOptions Selected =&gt;
        Provider == "Ollama" ? Ollama : OpenAI;
}

public sealed class EndpointOptions
{
    public string BaseUrl { get; set; } = string.Empty;
    public string Model { get; set; } = string.Empty;
    public string EmbeddingModel { get; set; } = string.Empty;
    public string? ApiKey { get; set; }
}
</code></pre>
<p>Through <a href="https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets">ASP.NET Core secret configuration</a>, the key can bind from user-secrets under <code>AI:OpenAI:ApiKey</code> or an environment variable named <code>AI__OpenAI__ApiKey</code>. It never belongs in <code>appsettings.json</code>.</p>
<p>In <code>Program.cs</code>, before building the app:</p>
<pre><code class="language-csharp">builder.Services.AddOptions&lt;AIOptions&gt;()
    .Bind(builder.Configuration.GetSection("AI"))
    .Validate(
        o =&gt; o.Provider is "Ollama" or "OpenAI",
        "Unknown provider.")
    .Validate(o =&gt;
        Uri.TryCreate(o.Selected.BaseUrl, UriKind.Absolute, out var uri)
        &amp;&amp; (uri.Scheme is "http" or "https")
        &amp;&amp; !string.IsNullOrWhiteSpace(o.Selected.Model)
        &amp;&amp; !string.IsNullOrWhiteSpace(o.Selected.EmbeddingModel),
        "A valid endpoint and model names are required.")
    .Validate(o =&gt; o.Provider != "OpenAI" ||
        (Uri.TryCreate(o.OpenAI.BaseUrl, UriKind.Absolute, out var uri)
        &amp;&amp; uri.Scheme == "https"
        &amp;&amp; !string.IsNullOrWhiteSpace(o.OpenAI.ApiKey)),
        "OpenAI requires HTTPS and a server-side key.")
    .ValidateOnStart();

builder.Services.AddHttpClient("AI", (services, client) =&gt;
{
    var options =
        services.GetRequiredService&lt;IOptions&lt;AIOptions&gt;&gt;().Value;

    client.BaseAddress =
        new Uri(options.Selected.BaseUrl.TrimEnd('/') + "/");

    client.Timeout = TimeSpan.FromMinutes(2);

    if (options.Provider == "OpenAI")
        client.DefaultRequestHeaders.Authorization =
            new("Bearer", options.OpenAI.ApiKey);
});

builder.Services.AddScoped&lt;IAIGateway&gt;(services =&gt;
{
    var options =
        services.GetRequiredService&lt;IOptions&lt;AIOptions&gt;&gt;().Value;

    var client = services.GetRequiredService&lt;IHttpClientFactory&gt;()
        .CreateClient("AI");

    return options.Provider switch
    {
        "Ollama" =&gt; new OllamaGateway(client, options.Ollama),
        "OpenAI" =&gt; new OpenAIGateway(client, options.OpenAI),
        _ =&gt; throw new InvalidOperationException("Unknown provider.")
    };
});
</code></pre>
<p>Startup options validation catches configuration mistakes before requests arrive. The timeout is an example policy, not an expected inference duration.</p>
<p>With both destinations prepared and the key supplied, change <code>AI:Provider</code> to <code>OpenAI</code>, or override it through the environment:</p>
<pre><code class="language-bash">export AI__Provider=OpenAI
</code></pre>
<p>Restart the API under that setting. The business services do not change.</p>
<p>This Ollama ASP.NET Core sample switches chat and embeddings together. Existing vectors are not interchangeable across embedding models, even when dimensions match. Re-embed the stored chunks and generate query vectors with the same model before enabling semantic retrieval. I would track model identity, dimensions, and preprocessing with the index, following <a href="https://docs.ollama.com/capabilities/embeddings">Ollama's embeddings guidance</a> that indexing and querying use the same model.</p>
<p>The repository keeps embedding selection separate: changing only its chat provider can retain the existing Ollama embeddings.</p>
<h2>Tool calling with local models</h2>
<p>I am not presenting model-by-model reliability results here. Pulling <code>llama3.1:8b</code> is not evidence that it handles every support request correctly.</p>
<p>The failure I guard against is treating tool-shaped JSON inside ordinary assistant text as a native invocation. Only structured tool calls enter the executor.</p>
<p>I validate tool names, required arguments, types, and permitted values. User identity and tenant scope come from authenticated server context, never model arguments. <code>GetCustomer</code>, <code>GetOrderStatus</code>, and <code>SearchKnowledgeBase</code> continue through authorized .NET services.</p>
<p>The tool loop needs limits on rounds, total calls, and elapsed time. Repeated malformed arguments must end the operation rather than trigger endless retries.</p>
<p>For a plain-answer fallback, I would disable tools and use only evidence already retrieved successfully. When no lookup succeeded, the answer should acknowledge that limitation, not invent an order status. The repository stops with an error at its iteration limit; this fallback is a deliberate extension, not a claim about current behavior.</p>
<h2>Latency and quality trade-offs</h2>
<p>I evaluate Ollama ASP.NET Core latency as a complete workflow, not just text-generation speed.</p>
<p>Ollama reports loading, prompt evaluation, and generation timings separately. A cold request can include model loading. Prompt processing depends on context length, and generation depends on hardware, available memory, model size, and concurrent work.</p>
<p>First-token latency and total completion time are different measurements. Retrieval and tool round trips can postpone the final answer even when token generation itself is fast.</p>
<p>A larger advertised context window is not a reason to send every ticket message and document chunk. I keep evidence relevant and preserve space for tool results and the answer.</p>
<p>OpenAI adds network dependency and hosted-service limits while removing local model-serving operations. Reasoning quality is a model-and-task question: I would compare grounded answers, tool selection, and human corrections on the same support cases rather than declare a provider universally better.</p>
<p>The example buffers the answer. Streaming can improve perceived responsiveness, but it does not remove retrieval, tool execution, or model computation.</p>
<h2>Run SupportAgent.NET locally in 10 minutes</h2>
<p>Treat this as a short runbook after prerequisites, not a download-time guarantee. The current repository targets .NET 10. Install that SDK, Node.js/npm, SQL Server, and Ollama, then configure <code>ConnectionStrings:DefaultConnection</code> for your database.</p>
<p>To <a href="https://rahmatafridi.com/supportagent-net/">run the whole support copilot locally</a>, use the repository's configuration rather than replacing it with the compact example above. Its <code>AI__Provider</code> environment override takes precedence over the older provider <code>Use</code> flags.</p>
<pre><code class="language-bash">git clone https://github.com/rahmatafridi/SupportAgent.NET.git
cd SupportAgent.NET

ollama pull llama3.1:8b
ollama pull nomic-embed-text

export ASPNETCORE_ENVIRONMENT=Development
export AI__Provider=Ollama
export AI__Ollama__Model=llama3.1:8b
export AI__Embeddings__Model=nomic-embed-text

dotnet restore
dotnet run --project src/SupportAgent.Api
</code></pre>
<p>These commands use Bash. Development startup applies migrations and seeds development data; SQL Server must already be reachable.</p>
<p>In another terminal, from the repository root:</p>
<pre><code class="language-bash">cd src/SupportAgent.Web
npm install
npm run dev
</code></pre>
<p>Open the frontend address printed by Vite, sign in with an authorized staff account, and generate a ticket reply. It remains a draft for review.</p>
<h2>Conclusion</h2>
<p>An Ollama ASP.NET Core integration works best when the provider is replaceable but application rules are not. I keep permissions in .NET services, retrieval in the knowledge pipeline, and final replies under human review.</p>
<p>One setting can switch configured providers. It cannot make different models equally capable or make incompatible embeddings compatible. Those decisions still need explicit validation.</p>
<hr />
<p><em><a href="https://rahmatafridi.com/ai-integration/">Rahmat Afridi</a> is a senior .NET developer who adds AI to existing business applications.</em></p>
]]></content:encoded></item></channel></rss>