Build a Multi-Model AI Chat Bot in .NET with ChatGPT and Neon Postgres Branching
Learn to build a multi-model AI chat bot in .NET 10 using ChatGPT and Neon serverless Postgres branching, with simple steps a beginner can follow.
Imagine you run a small tea stall. You have one notebook where you write down every order. Now suppose a new helper wants to try a different way of taking orders, but you do not want them messing up your real notebook. So you make a quick photocopy of the notebook, hand it over, and say "play with this copy". If their idea works, you bring the changes back into your real notebook. If it fails, you just throw the copy away. Nothing breaks.
That photocopy is exactly what database branching in Neon Postgres gives you. And the smart helper who answers customer questions is our AI chat bot, powered by ChatGPT. In this guide we will build both: a chat bot in .NET that can switch between different AI models, and a database that you can branch like a notebook copy.
What we are building
We will build a small chat bot service in .NET 10 (the current LTS release). It will:
- Talk to OpenAI's ChatGPT models through a single, clean interface.
- Let you swap models (like
gpt-4o-miniandgpt-4o) with a config change. - Save every chat message into Neon serverless Postgres.
- Use Neon branches to test safely without touching production data.
By the end you will understand both halves: the AI part and the database part. Let us look at the big picture first.
Part 1: Why "multi-model" matters
A few years ago, if you wanted to use a different AI model, you often had to rewrite your code for each one. That was painful. Today .NET gives us a shared abstraction called Microsoft.Extensions.AI. It defines one interface, IChatClient, that every model provider can implement.
Think of it like a power socket. Your phone charger, your fan, and your lamp all use the same socket shape. You do not rewire your house for each device. In the same way, your chat code talks to one socket (IChatClient), and you plug different models into it.
Here is a quick comparison of the two GPT models we will use in this guide.
| Model | Best for | Speed | Relative cost |
|---|---|---|---|
| gpt-4o-mini | Everyday chat, FAQs, short replies | Fast | Low |
| gpt-4o | Hard reasoning, long answers, tricky questions | Slower | Higher |
The idea is simple. Use the cheap, fast model for most messages. Send only the hard questions to the bigger, stronger model. This saves money and keeps the bot snappy.
Setting up the project
Create a new web API project and add the AI packages. Open a terminal and run these commands.
// In your terminal (not C#, but shown for clarity of steps):
// dotnet new webapi -n ChatBot
// cd ChatBot
// dotnet add package Microsoft.Extensions.AI
// dotnet add package Microsoft.Extensions.AI.OpenAI
// dotnet add package NpgsqlNow let us register the AI client. The Microsoft.Extensions.AI.OpenAI package gives us a way to turn an OpenAI chat client into an IChatClient. We read the model name and API key from configuration so we never hard-code secrets.
using Microsoft.Extensions.AI;
using OpenAI.Chat;
var builder = WebApplication.CreateBuilder(args);
// Read settings safely from configuration / environment variables.
string apiKey = builder.Configuration["OpenAI:ApiKey"]!;
string modelName = builder.Configuration["OpenAI:Model"] ?? "gpt-4o-mini";
// Wrap the OpenAI client in the shared IChatClient interface.
IChatClient chatClient = new ChatClient(modelName, apiKey).AsIChatClient();
builder.Services.AddSingleton(chatClient);
var app = builder.Build();Notice the key line. new ChatClient(modelName, apiKey).AsIChatClient() takes the OpenAI-specific client and hands you back the shared IChatClient. From now on, the rest of your code only knows about IChatClient. It does not care that ChatGPT is behind it.
Picking a model at runtime
To support multiple models, we keep a small "router". For short questions we use the mini model. For long or hard questions we use the bigger model. A real router can be as smart as you like; ours stays simple so it is easy to read.
public sealed class ModelRouter
{
// Pick a model based on how long and how hard the question looks.
public string ChooseModel(string userMessage)
{
bool looksHard = userMessage.Length > 280
|| userMessage.Contains("explain", StringComparison.OrdinalIgnoreCase)
|| userMessage.Contains("why", StringComparison.OrdinalIgnoreCase);
return looksHard ? "gpt-4o" : "gpt-4o-mini";
}
}This is just a starting point. You could route based on the user's plan, the topic, or even the time of day. The point is that swapping models is now a small, calm decision, not a big rewrite.
How a message flows through the bot
Steps
Receive
User sends a message
Route
Pick mini or full model
Ask AI
Call IChatClient
Save
Store in Postgres
Reply
Return the answer
Part 2: Talking to ChatGPT
Now let us actually send a message. The IChatClient has a GetResponseAsync method. We pass it a list of messages. Each message has a role: System (instructions for the bot), User (what the person said), or Assistant (what the bot said before).
using Microsoft.Extensions.AI;
public sealed class ChatService(IChatClient client)
{
public async Task<string> AskAsync(string userMessage)
{
var messages = new List<ChatMessage>
{
new(ChatRole.System, "You are a friendly helper for a tea stall."),
new(ChatRole.User, userMessage)
};
ChatResponse response = await client.GetResponseAsync(messages);
return response.Text;
}
}The System message is like the rule book you give a new helper on their first day. It shapes how the bot behaves. The User message is the customer's actual question. The model reads both and replies. response.Text holds the answer as plain text.
To make the bot feel alive, you can stream the reply word by word using GetStreamingResponseAsync. That is how ChatGPT looks like it is "typing". For now we will keep it simple and return the whole answer at once.
A note on conversation memory
A chat bot needs memory. If a customer asks "how much is masala chai?" and then asks "and ginger?", the bot must remember the first question to answer the second. We get this memory by saving the whole message list and sending it back each time. That is where our database comes in.
Part 3: Storing chats in Neon Postgres
Neon is serverless Postgres. That means it is normal PostgreSQL, but it runs in the cloud and can grow or shrink on its own. It even scales to zero when nobody is using it, so you do not pay for an idle database. Because it is real Postgres, your usual .NET tools like Npgsql and Entity Framework Core work without any tricks.
We will use two simple tables.
| Table | What it holds | Key columns |
|---|---|---|
| conversations | One row per chat session | id, user_id, created_at |
| messages | Every message in a chat | id, conversation_id, role, content, model, created_at |
Here is the SQL to create them. You can run this in the Neon SQL editor in your browser.
// SQL run in Neon (shown here for the steps):
// CREATE TABLE conversations (
// id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
// user_id TEXT NOT NULL,
// created_at TIMESTAMPTZ DEFAULT now()
// );
// CREATE TABLE messages (
// id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
// conversation_id BIGINT REFERENCES conversations(id),
// role TEXT NOT NULL,
// content TEXT NOT NULL,
// model TEXT,
// created_at TIMESTAMPTZ DEFAULT now()
// );Connecting from .NET with Npgsql
Neon gives you a connection string. Keep it in an environment variable or a secret store, never in your code. A Neon connection string looks like postgres://user:[email protected]/dbname?sslmode=require. Notice sslmode=require at the end. Neon always wants a secure connection, which is a good thing.
using Npgsql;
public sealed class MessageStore(string connectionString)
{
public async Task SaveMessageAsync(
long conversationId, string role, string content, string? model)
{
await using var conn = new NpgsqlConnection(connectionString);
await conn.OpenAsync();
const string sql = @"INSERT INTO messages
(conversation_id, role, content, model)
VALUES (@cid, @role, @content, @model);";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("cid", conversationId);
cmd.Parameters.AddWithValue("role", role);
cmd.Parameters.AddWithValue("content", content);
cmd.Parameters.AddWithValue("model", (object?)model ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync();
}
}Notice we use parameters (@cid, @role, and so on) instead of gluing strings together. This protects us from SQL injection, which is when a sneaky user tries to slip database commands into a text box. Always use parameters. It is a small habit that keeps you safe.
One tip from the Neon docs: for normal app traffic, use the pooled connection string. But when you run migrations (creating or changing tables), use the direct (non-pooled) string. Pooled connections can confuse migration tools.
Part 4: The magic of database branching
Now we reach the part that makes Neon special, and it ties right back to our tea stall notebook.
Suppose you want to test a new bot feature. Maybe you want to add a sentiment column to the messages table to track if customers are happy. You do not want to run that change on your live production database first. If it breaks, real customers feel it.
With Neon you create a branch. A branch is a copy-on-write clone of your data. It starts as an exact copy of main, but it is fully separate. You can break it, fix it, or delete it. Your production main branch never feels a thing. And because Neon separated storage from compute, creating a branch is almost instant and adds no load to production.
A real workflow: a branch per pull request
Here is where branching shines for a team. Every time a developer opens a pull request (a proposed code change), your CI pipeline can create a fresh Neon branch automatically. The tests run against that branch using its own connection string. When the pull request is merged or closed, the branch is deleted. No leftover test data, no shared mess.
This means two developers can test two different database changes at the exact same time without stepping on each other. Each has their own private copy.
| Without branching | With Neon branching |
|---|---|
| One shared test database | A private database per task |
| Tests collide with each other | Tests run in isolation |
| Hard to reset to a clean state | Delete and recreate in seconds |
| Risky schema changes on shared data | Safe to break a throwaway branch |
In .NET, switching to a branch is just switching the connection string. Your code does not change at all. You read it from configuration.
// Each environment points to a different Neon branch.
// appsettings.Production.json -> main branch connection string
// appsettings.Preview.json -> preview branch connection string
string connectionString =
builder.Configuration.GetConnectionString("NeonPostgres")!;
builder.Services.AddSingleton(new MessageStore(connectionString));Because the only thing that changes is one setting, the same compiled app can run against production, a preview branch, or a test branch. That is the whole point: less risk, less surprise.
Branch-per-pull-request flow
Steps
Open PR
Developer pushes a change
Create branch
CI clones the database
Migrate
Apply schema changes
Test
Run tests in isolation
Delete
Remove branch when done
Part 5: Putting it all together
Let us trace one full request through the finished system.
- A user sends a message to your
/chatendpoint. - The
ModelRouterlooks at the message and picksgpt-4o-miniorgpt-4o. - The
ChatServiceloads past messages from Postgres so the bot remembers context. - It calls
IChatClient.GetResponseAsyncwith the full conversation. - ChatGPT returns an answer.
- The
MessageStoresaves both the question and the answer, including which model was used. - The answer goes back to the user.
Because every message records its model column, you can later run a simple query to see how often each model was used and how much each costs you. That is the kind of insight that helps you tune the router over time.
A short word on libraries: be aware that some popular .NET packages have changed their licensing. MediatR and MassTransit are now commercially licensed for many uses. None of them are required for this chat bot, so we happily skip them. Our stack stays simple and free: Microsoft.Extensions.AI, Npgsql, and Neon.
Keeping it safe and tidy
A few good habits before you ship:
- Store the OpenAI key and Neon connection string in environment variables or a secret manager, never in code or git.
- Always use SQL parameters, as shown, to block injection attacks.
- Use a pooled connection for normal traffic and a direct connection for migrations.
- Put a sensible
Systemmessage so the bot stays polite and on-topic. - Set a reasonable max token limit so a long answer does not surprise your bill.
References and further reading
- Microsoft.Extensions.AI libraries (Microsoft Learn)
- Introducing Microsoft.Extensions.AI (.NET Blog)
- Neon database branching docs
- Get started with branching (Neon Docs)
- Connect a .NET (C#) application to Neon Postgres
- Connect an Entity Framework application to Neon
- What Is Neon Serverless Postgres? (Microsoft Learn)
- Microsoft.Extensions.AI.OpenAI (NuGet)
Quick recap
- A chat bot answers users; database branching keeps your data safe while you test, like photocopying a notebook.
Microsoft.Extensions.AIgives one interface,IChatClient, so you can swap GPT models with a config change instead of a rewrite.- A simple
ModelRoutersends easy questions to a cheap, fast model and hard ones to a stronger model. - Store chats in two tables, conversations and messages, using Npgsql with parameters to stay safe.
- Neon is real serverless Postgres that scales to zero, so idle costs stay low and your normal .NET tools just work.
- A Neon branch is an instant copy-on-write clone. Use a branch per pull request to test schema changes in isolation.
- Switching branches is only a connection string change, so the same app runs safely in production, preview, and test.
Related Posts
How to Sync Google and GitHub Logins to Your Database With Neon Auth for Free
Learn how Neon Auth syncs Google and GitHub logins into your Postgres database for free, with no webhooks, and how to join the data in ASP.NET Core EF Core.
Building Semantic Search With Amazon S3 Vectors and Semantic Kernel
A beginner-friendly guide to building semantic search in .NET using Amazon S3 Vectors for cheap storage and Semantic Kernel for embeddings.
What Is Vector Search? A Concise Guide for .NET Developers
A simple, friendly guide to vector search for .NET developers: embeddings, similarity, nearest neighbors, and how to build it with Microsoft.Extensions.VectorData.
Working With LLMs in .NET Using Microsoft.Extensions.AI
A beginner-friendly guide to calling large language models in .NET with Microsoft.Extensions.AI, using one simple IChatClient interface for any provider.
Top AI Instruments for .NET Developers in 2025
A friendly tour of the best AI tools for .NET developers in 2025: GitHub Copilot, Microsoft.Extensions.AI, Agent Framework, and more.
How to Extract Structured Data From Images Using Ollama in .NET
A beginner-friendly guide to reading text and fields from images using a local Ollama vision model in .NET, returning clean, typed JSON every time.