How to Build a Local AI Chatbot in ASP.NET Core 10 Using Ollama and C# (2026)

A local chatbot can answer without sending every prompt to a hosted inference API. It still needs explicit bounds on conversation size, a way to signal interruption, and a deployment plan that keeps the model port private.

This guide builds a local Ollama chatbot in ASP.NET Core 10. It accepts bounded conversation history, calls Ollama’s /api/chat endpoint, and converts newline-delimited JSON into server-sent events.

How does the conversation reach Ollama?

ASP.NET Core validates a short conversation, reads Ollama NDJSON, and maps text and completion into SSE.
ASP.NET Core validates a short conversation, reads Ollama NDJSON, and maps text and completion into SSE.

The caller sends prior user and assistant messages plus a new user turn. The server checks each role and enforces a maximum of eight messages. Ollama receives that bounded history on each call. The API does not persist it. This is appropriate for a local single-user fixture. A shared service should load authorized history from server storage instead of letting a client spoof assistant history.

Concern Lab behavior Production decision
History Client supplies up to eight messages Scope persisted threads by owner
Transport Ollama NDJSON becomes SSE Test proxy buffering and terminal errors
Hardware Small configurable model Measure memory and latency on your device
Privacy Inference stays on loopback Review logs, model sources and external tools

Install Ollama and build the .NET app

Install .NET 10 and Ollama. Pull a model you have reviewed for license and hardware needs. The example defaults to gemma3:1b. Pull it first or configure Ollama__Model to match a tag on your machine.

Prepare the local model
ollama pull gemma3:1b
ollama list

Put the following files in a new directory. The fixed Ollama base URL is intentional. Avoid taking an arbitrary inference server URL from request input. Both application processes should listen only on loopback in this lab.

Lab.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
</Project>
Program.cs
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Text.Json;

var b = WebApplication.CreateBuilder(args);
var model = b.Configuration["Ollama:Model"] ?? "gemma3:1b";
b.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 16384);
b.Services.AddHttpClient("ollama", c =>
{
    c.BaseAddress = new Uri("http://127.0.0.1:11434/");
    c.Timeout = Timeout.InfiniteTimeSpan;
});
var app = b.Build();
app.MapPost("/chat", (Conversation c, IHttpClientFactory factory, HttpContext ctx) =>
{
    if (c.Messages is not { Length: >= 1 and <= 8 } ||
        c.Messages.Any(m => m is null || m.Role is not ("user" or "assistant") ||
            string.IsNullOrWhiteSpace(m.Content) || m.Content.Length > 2000) ||
        c.Messages[^1].Role != "user")
        return (IResult)Results.BadRequest("Send up to eight bounded turns ending in user.");
    ctx.Response.Headers.CacheControl = "no-store";
    return TypedResults.ServerSentEvents(Read(c.Messages, factory, model, ctx.RequestAborted));
});
await app.RunAsync();

static async IAsyncEnumerable<SseItem<string>> Read(
    Message[] messages, IHttpClientFactory factory, string model,
    [EnumeratorCancellation] CancellationToken aborted)
{
    using var stop = CancellationTokenSource.CreateLinkedTokenSource(aborted);
    stop.CancelAfter(TimeSpan.FromSeconds(90));
    using var http = factory.CreateClient("ollama");
    using var req = new HttpRequestMessage(HttpMethod.Post, "api/chat")
    {
        Content = JsonContent.Create(new { model, messages, stream = true,
            options = new { num_predict = 300 } })
    };
    using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, stop.Token);
    if (!res.IsSuccessStatusCode)
    { yield return new("Ollama rejected the request.", "error"); yield break; }
    await using var stream = await res.Content.ReadAsStreamAsync(stop.Token);
    using var lines = new StreamReader(stream);
    while (await lines.ReadLineAsync(stop.Token) is { } line)
    {
        using var json = JsonDocument.Parse(line);
        var evt = json.RootElement;
        if (evt.TryGetProperty("error", out _))
        { yield return new("Local inference failed.", "error"); yield break; }
        if (evt.TryGetProperty("message", out var message) &&
            message.TryGetProperty("content", out var body) &&
            body.GetString() is { Length: > 0 } delta)
            yield return new(delta, "delta");
        if (evt.TryGetProperty("done", out var done) && done.ValueKind == JsonValueKind.True)
        { yield return new("complete", "done"); yield break; }
    }
    yield return new("Stream ended early.", "error");
}
record Message(string Role, string Content);
record Conversation(Message[] Messages);

The app reads each NDJSON line as a separate JSON object and emits content deltas. A model completion marker becomes done. An error object or premature EOF becomes error. An unhandled connection or parse failure can close the HTTP stream without a terminal event. Treat that outcome as incomplete. The request-aborted token and 90-second budget flow through the provider call and stream reads. See Ollama chat API.

Run the chatbot and inspect the stream

Run the API and send a two-message fixture
dotnet run --no-launch-profile -- --urls http://127.0.0.1:5080

# In a separate terminal:
curl -NsS http://127.0.0.1:5080/chat -H 'Content-Type: application/json' \
  -d '{"messages":[{"role":"user","content":"My project is Cedar."},{"role":"assistant","content":"Understood."},{"role":"user","content":"What is my project name?"}]}'

Expect incremental delta events and a final done; reply text can vary. Try an unrecognized role and expect 400. Stop the client midstream and observe server cancellation. Test missing model, slow first token and an Ollama restart separately. See the ASP.NET PB RAG chatbot guide if the model also needs retrieved documents.

The C# project compiled on .NET 10. A mock Ollama endpoint produced delta and done SSE events, and an invalid role returned HTTP 400. Actual model inference and hardware behavior were not tested.

Docker, hardware and troubleshooting

If the .NET app runs in a container, its loopback address refers to that container, not your host. Use a private Docker network with an explicit Ollama service name, or your platform’s supported host gateway. Configure and validate that destination rather than exposing port 11434 publicly. Persist model data in a volume when running Ollama in a container. Review accelerator support for your OS and driver. See Ollama Docker documentation.

Memory use depends on model size, quantization, context, parallel requests and accelerator placement. Begin with one small model and bounded concurrency. Measure time to first token and response duration locally. Local inference can still expose prompts through logs, crash dumps, browser history or untrusted tools. Keep the host patched and separate the model process from application credentials.

Symptom Check
Connection refused Ollama process, host and network namespace
Model not found Installed tag and server configuration
Slow first token Model loading and hardware utilization
Midstream disconnect Request deadline and proxy behavior
Answer forgets details Caller-supplied history and context budget

FAQ and references

Does local inference require a GPU? Hardware support and throughput vary by model. Verify a suitable small model on your hardware before choosing a larger one.

Can I use the browser’s EventSource directly? This endpoint requires POST. Use streaming fetch or a terminal client that can parse SSE.

Does Ollama remember previous HTTP requests automatically? In this integration the application explicitly supplies the messages needed for each call.

Verify local streaming and model limits before adding persistence, retrieval or remote access.

Comments