All articles
AI/September 18, 2026/7 min read

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 ModelContextProtocol

Microsoft.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, LoadHistoryAsync replays from the last persisted state.
  • ITurnStore is the seam. The initial implementation writes JSONL to disk. Later parts swap it for PostgreSQL with pgvector.
  • AIAgent.InvokeAsync internally 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:

  1. Never throw on unknown fields. TryGetProperty on JsonElement returns false for fields that do not exist; it never throws. Add new protocol fields without a migration.
  2. Preserve the raw line. The RawJson field maps to a jsonb column 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 MCP 2026-07-28 — you can query it from jsonb without 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

  1. The New Features and Enhancements in .NET 10
  2. New in .NET 10 and C# 14: Enhancements in APIs Request/Response Pipeline
  3. .NET
  4. .NET 10: What You Need to Know (LTS Release, Coming November 2025) | ABP.IO
  5. ASP.NET Core in .NET 10: Major Updates across Blazor, APIs, and OpenAPI - InfoQ
  6. .NET 10 - Release · dotnet/core · Discussion #10157
  7. What's new in .NET 10 | Microsoft Learn
  8. NET 10 Released: Complete Guide to New Features and ...
Share