Skip to main content
SEMastery
Data Accessintermediate

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.

13 min readUpdated April 16, 2026

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-mini and gpt-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.

The whole system at a glance

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.

ModelBest forSpeedRelative cost
gpt-4o-miniEveryday chat, FAQs, short repliesFastLow
gpt-4oHard reasoning, long answers, tricky questionsSlowerHigher

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 Npgsql

Now 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

Receive
Route
Ask AI
Save
Reply

Steps

1

Receive

User sends a message

2

Route

Pick mini or full model

3

Ask AI

Call IChatClient

4

Save

Store in Postgres

5

Reply

Return the answer

From the user's words to a saved reply

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.

A short conversation kept in memory

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.

TableWhat it holdsKey columns
conversationsOne row per chat sessionid, user_id, created_at
messagesEvery message in a chatid, 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.

Branching keeps production safe

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 branchingWith Neon branching
One shared test databaseA private database per task
Tests collide with each otherTests run in isolation
Hard to reset to a clean stateDelete and recreate in seconds
Risky schema changes on shared dataSafe 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

Open PR
Create branch
Migrate
Test
Delete

Steps

1

Open PR

Developer pushes a change

2

Create branch

CI clones the database

3

Migrate

Apply schema changes

4

Test

Run tests in isolation

5

Delete

Remove branch when done

A safe testing loop your CI can run automatically

Part 5: Putting it all together

Let us trace one full request through the finished system.

  1. A user sends a message to your /chat endpoint.
  2. The ModelRouter looks at the message and picks gpt-4o-mini or gpt-4o.
  3. The ChatService loads past messages from Postgres so the bot remembers context.
  4. It calls IChatClient.GetResponseAsync with the full conversation.
  5. ChatGPT returns an answer.
  6. The MessageStore saves both the question and the answer, including which model was used.
  7. 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 System message 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

Quick recap

  • A chat bot answers users; database branching keeps your data safe while you test, like photocopying a notebook.
  • Microsoft.Extensions.AI gives one interface, IChatClient, so you can swap GPT models with a config change instead of a rewrite.
  • A simple ModelRouter sends 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