In Part 1 of this multi-part series, I laid out my goal to migrate the Python agentics program from the previous series to C#. To do this migration I’m going to work my way down through my Python script and refactor it breaking out classes and refactoring to use Microsoft Agent Framework.
Note: to make sense of this code, you’ll want to start with the Python example. The code for that begins here.
We begin with bringing in the config.json file. We’ll use the identical file, and bring it into Program.cs
const string fileName = "config.json";
using var stream = File.OpenRead(fileName);
using var document = JsonDocument.Parse(stream);
JsonElement config = document.RootElement;
string? GetValue(string key) =>
config.TryGetProperty(key, out JsonElement value) ? value.GetString() : null;
Environment.SetEnvironmentVariable("OPENAI_API_KEY", GetValue("API_KEY"));
Environment.SetEnvironmentVariable("OPENAI_BASE_URL", GetValue("OPENAI_API_BASE"));
Environment.SetEnvironmentVariable("TAVILY_API_KEY", GetValue("TAVILY_API_KEY"));
string modelName = "gpt-4o-mini";
var openAIClient = new OpenAIClient(
new ApiKeyCredential(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!),
new OpenAIClientOptions
{
Endpoint = new Uri(Environment.GetEnvironmentVariable("OPENAI_BASE_URL")!)
});
Next, we set up the Tavily search tool:
var tavilyHttpClient = new HttpClient { BaseAddress = new Uri("https://api.tavily.com/") };
tavilyHttpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("TAVILY_API_KEY"));
AIFunction tavilyTool = AIFunctionFactory.Create(
async (string query) =>
{
var request = new
{
query,
max_results = 5,
topic = "general",
include_answer = false,
include_raw_content = false,
search_depth = "basic"
};
using HttpResponseMessage response = await tavilyHttpClient.PostAsJsonAsync("search", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
},
name: "tavily_search",
description: "A search engine optimized for comprehensive, accurate, and trusted results.");
A little more verbose, but we’re doing a bit of extra work along the way. Let’s go down to where we create the ResearchState. To do that, we’ll create ResearchState.cs:
namespace BlogMigration;
/// <summary>State for the research workflow.</summary>
public class ResearchState
{
public string MainTask { get; set; } = "";
public List<string> ResearchFindings { get; set; } = [];
public string Draft { get; set; } = "";
public string ReviewNotes { get; set; } = "";
public int RevisionNumber { get; set; }
public string NextStep { get; set; } = "";
public string CurrentSubTask { get; set; } = "";
}
In the next blog post we’ll create the first of our agents: Blogger. We’ll mimic BloggerChain, passing in the llm (IChatClient) and the chat options.
TL;DR: The hard part of a message pump is not reading bytes from a queue. The hard part is deciding what happens when message handling writes to a database, sends more messages, fails halfway through, moves to another broker, or runs in a cloud service with different transaction semantics. That is where a small infrastructure project becomes a platform commitment.
The funny part is that none of this looked scary at the beginning.
It looked like a loop. Read from a queue. Deserialize a message. Find the handler. Call it. Add bounded concurrency when load arrives. Add graceful shutdown when deployments get rough.
Then the real requirements appear.
Every message needs logging. Failed messages need exception details. Successful messages need auditing. Handlers need dependency injection scopes. Some operations must run in a transaction. Some messages produce more messages. The code that looked like a loop starts to look like middleware.
That is the moment to be careful. You are no longer writing a helper. You are defining the failure behavior of the system.
Business handlers rarely run alone. They need cross-cutting behavior around them. That behavior starts with logging and error handling, but it usually grows into auditing, tracing, metrics, retries, dependency injection scopes, and transaction boundaries.
The sample repository has a deliberately compact “how it probably looks like in reality” version. It creates a transaction, reads the message inside that transaction, deserializes the payload, creates a child service provider, builds middleware, runs the middleware, completes the transaction, and releases the concurrency slot.
The complete version with middleware is in ThePumpHowItProbablyLooksLikeInReality.cs.
async Task FetchAndHandleAndReleaseWithMiddleware(SemaphoreSlim semaphore, CancellationToken token = default) {
using (var transaction = CreateTransaction()) {
var (payload, headers) = await ReadFromQueue(token, transaction).ConfigureAwait(false);
var message = Deserialize(payload, headers);
using (var childServiceProvider = CreateChildServiceProvider()) {
try {
var middlewareFuncs = new Func<HandlerContext, Func<HandlerContext, CancellationToken, Task>, CancellationToken, Task>[] { Middleware1, Middleware2 };
var middleware = FlextensibleMiddleware(childServiceProvider, middlewareFuncs, token);
await middleware(message).ConfigureAwait(false);
transaction.Complete();
}
catch (Exception) {
// Just log?
}
finally {
semaphore.Release();
}
}
}
}
The comment in the catch block is doing a lot of work. “Just log?” is not an implementation detail. It is a policy decision. Should the message be retried? Moved to an error queue? Acknowledged and skipped? Rolled back? How many times? With which delay? Who gets alerted?
Those answers belong to the message pump because the pump owns the boundary between broker state and application side effects.
In the original system, Microsoft Message Queuing and SQL Server could participate in distributed transactions through the Distributed Transaction Coordinator. With a transaction scope and a two-phase commit protocol, the system could coordinate the receive, database work, and outgoing sends.
That gives a strong guarantee. Either the receive, database writes, and outgoing messages commit together, or they do not commit.
It also adds coordination cost. The transaction manager has to talk to the resources involved, and each resource has to agree on the outcome. That extra round trip and coordination can reduce throughput sharply.
This is the first trade-off many teams hit. They want the safety of a coordinated transaction and the speed of a simpler broker operation. The broker choice decides how much of that combination is available.
Some brokers do not coordinate the receive, database update, and outgoing sends into one transaction. RabbitMQ, Amazon Simple Queue Service, and Azure Storage Queues are common examples of systems where the receive side has its own acknowledgement model.
That model can be perfectly valid. It is also different.
Suppose a handler receives a message, writes to the database, and then fails before acknowledging the broker message. The broker redelivers the message later. The database write already happened. If the handler is not idempotent, the second attempt can create the same business effect twice.
The reverse can also happen. The handler sends a message to one destination, then fails before finishing the rest of the work. A downstream consumer can observe a message that represents work the original handler did not fully complete.
People often call those ghost messages. The name is informal, but the risk is real: a message escapes from a business operation that later rolls back or fails.
Cloud brokers add another layer of semantics. Some queues use a visibility timeout. When the pump receives a message, the broker hides that message for a period. If the handler does not finish and delete the message before the timeout expires, the broker can make it visible again.
That means long-running handlers need renewal logic. The pump may have to extend the visibility timeout while business code is still running. Renew too aggressively and failures take longer to recover. Renew too little and duplicate processing appears while the first handler is still working.
Other brokers provide features that help with outgoing messages. Azure Service Bus, for example, has transaction support for sending messages through the incoming entity by using send-via behavior. That can reduce ghost-message risk for outgoing broker messages.

It does not make your database write magically part of the same broker operation. You still need idempotent business logic where your handler changes application state.
On-premises queues often make teams think in throughput, disk, memory, and operations work. Cloud queues add a direct cost model. Sends, receives, renewals, management calls, topic operations, and connections can all show up in billing or capacity limits depending on the provider and tier.
The exact prices change, so the pump should not bake in assumptions from a slide or a blog post. But the design pressure is stable: every broker operation has latency, and many broker operations have cost.
Batching becomes a design concern. A loop that sends one outgoing message per broker call is easier to write, but it pays latency and operation cost for every send. A batching sender can group messages, but then it has to respect payload limits, destinations, transaction rules, and failure behavior.
That batching logic becomes transport-specific fast. The rules for one broker are not the rules for another.
Sending commands to a known queue is the simpler case. Publish/subscribe adds topology.
Some brokers have topics and subscriptions as first-class concepts. Some have only queues and require another service, table, or convention to map a published event to subscribers. Once the pump supports publish/subscribe, it also needs routing rules, subscription storage, topology deployment, and a way to evolve those rules without losing messages.
That is not only code. It is documentation and operations. Someone has to know which event goes where, who owns the subscription, what happens during deployment, how to monitor dead-letter queues, and how to answer the uncomfortable question: can production handle the next customer?
The pump became the platform.
A note on perspective: The events described in this post happened before I joined Particular Software. Today, I work on NServiceBus, so I have certainly developed opinions about the operational costs of owning messaging infrastructure. Building a message pump myself was still one of the most educational experiences of my career. I do not regret doing it. It simply gave me a better appreciation for the trade-offs involved when deciding whether to build or adopt an existing solution.
Writing a message pump taught me more about distributed systems than most books I had read at the time. I would still recommend building one as an exercise, even if you never intend to run it in production. Build a small one against your favorite broker. Make it read, deserialize, dispatch, retry, limit concurrency, shut down cleanly, and publish a follow-up message.
Then break it.
Kill the process during a handler. Drop the database connection after the write but before acknowledgement. Send a batch that exceeds the broker payload limit. Let a visibility timeout expire. Add a second subscriber. Watch what happens.
The question eventually becomes whether messaging infrastructure differentiates your business. Most teams benefit from using proven solutions for common infrastructure problems and focusing their effort elsewhere. That is the argument behind Use What Works, and message pumps fit it well. The first loop is easy. The years of edge cases, operational behavior, documentation, and support are the expensive part.
After that, the make-or-buy decision becomes more honest. If your business needs unusual semantics and your team can own the complexity for years, building may be reasonable. If you need retries, routing, topology, observability, auditing, error queues, transport quirks, and documentation while the team is also expected to ship domain features, off-the-shelf infrastructure starts to look less expensive.
The key is not whether the first loop is easy. It is whether the team is ready to own every failure mode behind that loop.
Further reading:
Every time I set up Neovim on a fresh WSL instance, I hit the same wall: yanking text inside Neovim and pasting it into a Windows app (or vice versa) just doesn't work. "+y does nothing, and Neovim greets you with Clipboard: No provider, try :checkhealth. Nothing flows in or out of the clipboard, not even between files inside WSL.
The root cause is that WSL's Neovim can't talk to the Windows clipboard at all. The fix is a tiny Windows executable called win32yank that speaks the Windows clipboard API from the command line.
I've done this enough times now that I'm writing it down so I never have to search for it again. If you're here for the same reason, this one's for you.
Grab the latest release from github.com/equalsraf/win32yank. Download win32yank-x64.zip and extract it to get win32yank.exe.
sudo mv /mnt/d/win32yank.exe /usr/local/bin/
Adjust the source path to wherever your browser downloaded it (usually /mnt/c/Users/<you>/Downloads/win32yank.exe).
Add this block to ~/.config/nvim/init.lua:
if vim.fn.has("wsl") == 1 then
vim.g.clipboard = {
name = 'win32yank-wsl',
copy = {
['+'] = 'win32yank.exe -i --crlf',
['*'] = 'win32yank.exe -i --crlf',
},
paste = {
['+'] = 'win32yank.exe -o --lf',
['*'] = 'win32yank.exe -o --lf',
},
cache_enabled = 0,
}
vim.opt.clipboard = 'unnamedplus'
end
Now y, "+y, "+p, right-click copy/paste — all of it flows through the Windows clipboard as you'd expect.
Next time I (or you) need this on a fresh box, run this single script. It downloads win32yank, installs it, and appends the config:
#!/usr/bin/env bash
set -euo pipefail
WIN32YANK_PATH="/usr/local/bin/win32yank.exe"
NVIM_CONFIG="${HOME}/.config/nvim/init.lua"
TMP_DIR=$(mktemp -d)
# Get the latest release tag from GitHub
echo "==> Fetching latest win32yank release..."
LATEST_TAG=$(curl -s https://api.github.com/repos/equalsraf/win32yank/releases/latest \
| grep '"tag_name"' \
| cut -d'"' -f4)
echo "==> Downloading win32yank ${LATEST_TAG}..."
curl -fsSL "https://github.com/equalsraf/win32yank/releases/download/${LATEST_TAG}/win32yank-x64.zip" \
-o "${TMP_DIR}/win32yank-x64.zip"
echo "==> Extracting..."
unzip -q "${TMP_DIR}/win32yank-x64.zip" -d "${TMP_DIR}"
sudo cp "${TMP_DIR}/win32yank.exe" "$WIN32YANK_PATH"
sudo chmod +x "$WIN32YANK_PATH"
rm -rf "$TMP_DIR"
echo "==> Appending clipboard config to ${NVIM_CONFIG}..."
mkdir -p "$(dirname "$NVIM_CONFIG")"
cat >> "$NVIM_CONFIG" << 'LUA'
-- win32yank clipboard for WSL
if vim.fn.has("wsl") == 1 then
vim.g.clipboard = {
name = 'win32yank-wsl',
copy = {
['+'] = 'win32yank.exe -i --crlf',
['*'] = 'win32yank.exe -i --crlf',
},
paste = {
['+'] = 'win32yank.exe -o --lf',
['*'] = 'win32yank.exe -o --lf',
},
cache_enabled = 0,
}
vim.opt.clipboard = 'unnamedplus'
end
LUA
echo "==> Done! Restart Neovim and yank away."
Save it as setup-wsl-clipboard.sh, run chmod +x setup-wsl-clipboard.sh and then ./setup-wsl-clipboard.sh.
...it needs an AI learning system. This episode argues that the Fable 5 disruption exposed a deeper enterprise problem: companies can’t treat AI as a vendor strategy. The real advantage will come from building learning systems that capture institutional judgment, workflow traces, private evals, and model-portable IP. In the headlines: could Anthropic and the White House be headed for a resolution?
Register for our new enterprise-grade AI training programs: http://training.besuper.ai/
The AI Daily Brief helps you understand the most important news and discussions in AI.
Subscribe to the podcast version of The AI Daily Brief wherever you listen: https://pod.link/1680633614
Get it ad free at http://patreon.com/aidailybrief
Learn more about the show https://aidailybrief.ai/