
Building an Agentic System in .NET, Part 1 — Anatomy of an Agent Harness
A chat wrapper is not an agent harness. This article walks through every structural component a real .NET 10 agent host needs — the turn loop, tool dispatch, persistence, and safety layer — and shows the minimal C# skeleton that survives a crash.
The Gap Between a Chat Wrapper and an Agent Harness
Most "agent" demos are glorified chat wrappers: send a message, stream a reply, render Markdown. That works until the process crashes mid-tool-call, the MCP server changes its wire format, or you need to replay a session for debugging. A real agent harness is a different class of software.
This is Part 1 of a series. We will build a production-grade agentic system in .NET 10, piece by piece:
- Part 1 (this article): Turn loop, tool dispatch, transport, persistence, and the safety layer.
- Part 2: Tool composition with MCP (Model Context Protocol, C# SDK).
- Part 3: Durable memory with Postgres and pgvector.
- Part 4: Keeping that memory true: decay, dedupe, contradiction.
- Part 5: An agent bus in ASP.NET Core: hand-offs and delivery guarantees.
- Part 6: Redaction, audit and the safety layer.
Let's be precise about what each component actually is.
The Five Structural Layers
The Turn Loop
An agent turn is a unit of work: read input → call the model → inspect the response → either return a final answer or dispatch a tool and loop. The loop is not optional decoration; it is the mechanism that makes the model's tool calls actually execute. A chat wrapper has no loop — it sends and receives once. An agent harness runs until the model signals it is done (usually a finish_reason of stop with no pending tool calls).
Tool Dispatch
The model returns a structured tool-call request. Something must resolve that to a function, invoke it with validated arguments, and feed the result back as a tool role message. In the Microsoft Agent Framework (announced at .NET Conf 2025 and built on top of Microsoft.Extensions.AI and Semantic Kernel), AIFunctionFactory.Create() handles registration, and the AIAgent type handles dispatch. The transport for remote tools is MCP — now at v2.0, implementing the 2026-07-28 spec revision, which makes HTTP transport stateless by default. This matters: a BackgroundService that assumed a persistent SSE connection to an MCP server needs to be redesigned around per-request HTTP calls.
Transport
Local tools run in-process. Remote tools go over MCP (ModelContextProtocol NuGet package, v2.0 stable). The 2026 spec introduced stateless HTTP, multi-round-trip requests, and caching hints. Down-level interop is supported — a v2.0 host negotiates with servers that still advertise 2025-11-25 or even the original 2024-11-05 protocol version. But the tool-result envelope structure differs between eras, which is exactly why your persistence layer cannot assume a fixed schema.
Persistence
Every turn — inputs, model response, tool calls, tool results — must be written to durable storage before the loop advances. JSONL (one JSON object per line) is the natural format: append-only, streamable, simple to tail. The catch is that the schema drifts. MCP wire format has evolved across three protocol versions; a log from 2025 looks structurally different from a 2026 log. Your reader must be tolerant.
The Safety Layer
Redaction, rate limiting, output filtering, and audit — these are not add-ons. They are seams you design from day one, even if the initial implementation is a pass-through. The series covers this in depth in Part 5.
The Minimal .NET 10 Host
The host is a BackgroundService registered in a minimal API application. .NET 10 (LTS, released November 2025) is the target; it ships with JIT improvements that measurably reduce tail latency and execution-time jitter in minimal API pipelines, making it a good baseline for an agent host that needs predictable response times.
Note: .WithOpenApi() is removed in .NET 10. Use the new built-in OpenAPI generator if you need API docs.
The packages:
dotnet add package Microsoft.Extensions.AI
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.Agents.AI.OpenAI --prerelease
dotnet add package ModelContextProtocolMicrosoft.Agents.AI.OpenAI remains --prerelease as of mid-2026. Pin to an explicit version — never a floating * — and expect SKEXP-style diagnostic suppressions in your build output.
The Turn Loop Skeleton
// AgentWorker.cs — runs inside a BackgroundService
public sealed class AgentWorker(
AIAgent agent,
ITurnStore store,
ILogger<AgentWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var request in store.ReadPendingAsync(ct))
{
var sessionId = request.SessionId;
var messages = await store.LoadHistoryAsync(sessionId, ct);
messages.Add(new ChatMessage(ChatRole.User, request.Content));
var turn = new TurnRecord
{
SessionId = sessionId,
TurnId = Guid.NewGuid(),
StartedAt = DateTimeOffset.UtcNow,
InputJson = JsonSerializer.Serialize(messages),
};
try
{
// The agent loop: model call + tool dispatch until stop
AgentResponse? response = null;
while (true)
{
response = await agent.InvokeAsync(messages, ct);
messages.AddRange(response.NewMessages);
if (response.IsComplete) break;
// Tool calls were dispatched internally by AIAgent;
// results are already appended to messages.
logger.LogDebug("Continuing turn {TurnId} after tool dispatch",
turn.TurnId);
}
turn.OutputJson = JsonSerializer.Serialize(response!.NewMessages);
turn.CompletedAt = DateTimeOffset.UtcNow;
await store.PersistTurnAsync(turn, ct);
}
catch (Exception ex)
{
logger.LogError(ex, "Turn {TurnId} failed", turn.TurnId);
turn.Error = ex.Message;
await store.PersistTurnAsync(turn, ct); // persist failure too
throw;
}
}
}
}A few decisions baked in here:
- Persist before you advance. The turn record is written whether the call succeeds or fails. On restart,
LoadHistoryAsyncreplays from the last persisted state. ITurnStoreis the seam. The initial implementation writes JSONL to disk. Later parts swap it for PostgreSQL with pgvector.AIAgent.InvokeAsyncinternally handles the tool-dispatch sub-loop. You see one call; the framework handles multi-turn tool execution transparently.
Persisting Turns as JSONL
JSONL is the right default: each turn is one line, the file is append-only, and tail -f is your free real-time monitor. The seam between sessions is captured by writing one record per turn with SessionId as the correlation key.
The problem is tolerance. A log written when MCP protocolVersion was 2024-11-05 has different tool-result envelope fields than one written under 2025-11-25 or 2026-07-28. If your reader calls JsonSerializer.Deserialize<TurnRecord>(line) and throws on unknown fields, you break every time the protocol evolves.
The right approach: parse with JsonDocument, extract what you understand, and store the raw element in a jsonb column as a fallback. In the flat-file version, store the raw line alongside the parsed fields.
Tolerant JSONL Parser
public static class TurnLogReader
{
public static async IAsyncEnumerable<ParsedTurn> ReadAsync(
Stream jsonl,
[EnumeratorCancellation] CancellationToken ct = default)
{
using var reader = new StreamReader(jsonl, leaveOpen: true);
string? line;
int lineNumber = 0;
while ((line = await reader.ReadLineAsync(ct)) is not null)
{
lineNumber++;
// Guard against terminal escape sequences prepended to valid JSON
// (a known noise issue in early stdio/SSE MCP transport logs).
line = line.TrimStart('\x1b', '[', ';', '0', '1', 'm');
if (string.IsNullOrWhiteSpace(line)) continue;
ParsedTurn parsed;
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
parsed = new ParsedTurn
{
LineNumber = lineNumber,
RawJson = line, // jsonb fallback
SessionId = TryGetString(root, "sessionId"),
TurnId = TryGetGuid(root, "turnId"),
StartedAt = TryGetDateTimeOffset(root, "startedAt"),
CompletedAt = TryGetDateTimeOffset(root, "completedAt"),
// Protocol version: handle all three eras
ProtocolVersion = TryGetString(root, "protocolVersion"),
IsPartial = false,
};
}
catch (JsonException ex)
{
// Malformed line — store raw, mark partial, continue
parsed = new ParsedTurn
{
LineNumber = lineNumber,
RawJson = line,
IsPartial = true,
ParseError = ex.Message,
};
}
yield return parsed;
}
}
private static string? TryGetString(JsonElement e, string key)
=> e.TryGetProperty(key, out var v) ? v.GetString() : null;
private static Guid? TryGetGuid(JsonElement e, string key)
=> e.TryGetProperty(key, out var v) && v.TryGetGuid(out var g) ? g : null;
private static DateTimeOffset? TryGetDateTimeOffset(JsonElement e, string key)
=> e.TryGetProperty(key, out var v) &&
v.TryGetDateTimeOffset(out var d) ? d : null;
}Two principles at work:
- Never throw on unknown fields.
TryGetPropertyonJsonElementreturns false for fields that do not exist; it never throws. Add new protocol fields without a migration. - Preserve the raw line. The
RawJsonfield maps to ajsonbcolumn in PostgreSQL (or a raw string on disk). When you need a field that the strongly-typed model did not anticipate — say, a"elicitation"block introduced in MCP2026-07-28— you can query it fromjsonbwithout a schema migration.
The escape-sequence strip on line read is not hypothetical: early stdio/SSE MCP transport prepended terminal control characters to valid JSON, causing entire log files to appear malformed.
What the Rest of the Series Builds
The skeleton above has four deliberate gaps — blank interface implementations that will be filled in:
| Gap | Filled in | Topic |
|---|---|---|
| Remote tool transport | Part 2 | MCP v2.0 (stateless HTTP, multi-round-trip) |
ITurnStore durable backend |
Part 3 | pgvector + Npgsql |
| Cross-agent correlation | Part 4 | Message bus, session routing |
| Output safety | Part 5 | Redaction pipeline |
The structural point is that each capability is a seam, not an afterthought. The turn loop does not know whether tools run in-process or over MCP. The store does not know whether the backend is a file or PostgreSQL. That isolation is what makes the system testable and upgradeable.
Closing Note on Stability
The Microsoft.Agents.AI.OpenAI package is still prerelease. The MCP SDK v2.0 is stable, but the 2026-07-28 protocol revision is recent enough that ecosystem tooling is catching up. Pin every package to an explicit version, run your JSONL reader against a corpus of logs from all three protocol eras in CI, and treat the jsonb fallback column as load-bearing infrastructure rather than a debug convenience. The next parts of this series build on all of this without apology.
Sources
- The New Features and Enhancements in .NET 10
- New in .NET 10 and C# 14: Enhancements in APIs Request/Response Pipeline
- .NET
- .NET 10: What You Need to Know (LTS Release, Coming November 2025) | ABP.IO
- ASP.NET Core in .NET 10: Major Updates across Blazor, APIs, and OpenAPI - InfoQ
- .NET 10 - Release · dotnet/core · Discussion #10157
- What's new in .NET 10 | Microsoft Learn
- NET 10 Released: Complete Guide to New Features and ...
Keep reading

June 11, 2026 · 6 min
Vertical Slice Architecture in ASP.NET Core: Features as First-Class Citizens
Vertical Slice Architecture flips the organizational instinct of layered systems — instead of grouping code by technical concern, you group it by use case. Here's how to structure, share, test, and migrate to slices in a real ASP.NET Core codebase.
Read
June 26, 2026 · 6 min
Two Bugs Hiding in Our OpenTelemetry Pipeline: CORS Preflights and the NUL Byte That Killed a Whole Batch
Two silent production bugs in an OTLP-based observability pipeline — one blocking all browser telemetry clients, one dropping entire log batches — exposed how quickly boundary gaps become blind spots. Here's the root cause, the fix, and the repro for each.
Read
July 16, 2026 · 6 min
Graph-Native Data Structures in C#, Part 4 — Graphs & Adjacency with a Social Follow Network
Part 4 of the series models a directed social follow network in NebulaGraph, then surfaces neighbour queries, mutual connections, friend-of-friend suggestions, and super-node protection as typed C# async methods.
Read