CardRunner, ein Agent Loop in .NET mit Microsoft.Extensions.AI

15. Oktober 2026 · aktualisiert 25. Oktober 2026

Agentic AI, Microsoft.Extensions.AI, MCP, C#, UI Automation

Vor einigen Tagen habe ich CardRunner geschrieben, eine .NET-Konsolenanwendung, mit der ein KI-Modell eine Kanban-Karte in einer Anwendung mit Windows Presentation Foundation (WPF) bearbeitet. Das Modell liest die Karte, ändert den Code, baut die Anwendung, prüft die Änderung in der laufenden Anwendung und committet. Der vorherige Artikel hat dasselbe mit Claude Code gezeigt. Diesmal wird die Schleife selbst in C# mit Microsoft.Extensions.AI umgesetzt.

CardsToLoop ist eine kleine Demo-App für eine kartengesteuerte Entwicklungsschleife, der Quellcode steht auf GitHub →. Eine Kanban-Karte beschreibt in wenigen Zeilen eine Fehlerbehebung oder eine neue Funktion sowie die Abnahmekriterien. Ein Agent, ein großes Sprachmodell (engl. Large Language Model, LLM), das in einer Schleife Tools aufruft, setzt die Karte in einer echten WPF-Anwendung um. Vor dem Commit wird das Ergebnis in der laufenden Anwendung geprüft.

Ein Tool ist eine Funktion mit Namen, Beschreibung und Parametern. Aufgerufen wird sie vom Modell über den Namen, z. B. um eine Datei zu lesen oder einen Button zu klicken.

Im vorherigen Artikel wurde eine Karte von einem PowerShell-Skript an Claude Code übergeben, und Claude Code hat das Modell gewählt, die Schleife ausgeführt und die Tools für Dateien und die Shell bereitgestellt. Dasselbe Muster läuft bei Peakboard in einem UI-Testsystem für den Peakboard Designer. In beiden Fällen ist die Schleife selbst in Claude Code verborgen, CardRunner ersetzt Claude Code durch etwa 600 Zeilen C#. Dadurch wird die Schleife sichtbar, mit allem, was dazugehört.

Die Demo besteht aus fünf Teilen:

  • InvoiceApp, ein kleiner WPF-Rechnungseditor mit drei absichtlichen Lücken, eine je Karte
  • UiMcpServer, ein MCP-Server mit acht Tools, die die InvoiceApp über Windows UI Automation bedienen
  • drei Karten, je eine Markdown-Datei
  • run.ps1, der Runner des vorherigen Artikels, der Claude Code für eine Karte startet
  • CardRunner, der neue Runner, der die Schleife selbst ausführt

Im Model Context Protocol (MCP) ist festgelegt, wie ein Agent die Tools eines anderen Prozesses listet und aufruft. UI Automation ist die Windows-Schnittstelle, die die Steuerelemente eines Fensters an andere Programme meldet. Jedes Steuerelement der InvoiceApp hat eine AutomationId, einen festen Namen, über den das Steuerelement von den Tools des UiMcpServers gefunden wird. Damit kann das Modell einen Rabatt eingeben, einen Button klicken und die Gesamtsumme lesen, ohne Screenshot.

Die folgende Feature-Karte verlangt eine Rückfrage, bevor eine Zeile gelöscht wird:

---
title: Deleting an item should ask first
state: New
category: Improvement
---

"Delete selected" removes the row immediately. One misplaced click and the
line is gone.

**Acceptance criteria**

- Clicking "Delete selected" with a row selected shows a confirmation dialog
  naming the item.
- "Yes" removes the row and updates the totals; "No" leaves everything as is.
- With no row selected the button does nothing, as today.

Abnahmekriterien sind prüfbare Bedingungen, die das Ergebnis erfüllen muss, bevor die Karte erledigt ist. Jedes Kriterium nennt eine Aktion in der Anwendung sowie das erwartete Ergebnis. Der Status wechselt von „New“ zu „In Work“ und anschließend zu „Done“ oder „Failed“.

Der folgende Screenshot zeigt die InvoiceApp nach dem Lauf dieser Karte, der Button „Delete selected“ öffnet jetzt einen Dialog mit dem Namen der Position.

InvoiceApp nach dem Lauf der Löschkarte, ein Dialog fragt, ob die Position Gadget gelöscht werden soll

Der Artikel zeigt, wie die Schleife im Code gebaut wird und welche Tools der Runner benötigt. Dazu kommen die Grenzen, das Log sowie die Kosten von drei Läufen.

Die Schleife

Der Runner ist eine .NET-8-Konsolenanwendung mit drei Paketen: Microsoft.Extensions.AI 10.11.0, dem Software Development Kit (SDK) für MCP in C#, ModelContextProtocol.Core 2.2.0, sowie dem Anthropic-SDK für C# in Version 12.58.0. Microsoft.Extensions.AI ist eine Bibliothek von Microsoft, die eine Schnittstelle für Chat-Modelle und eine Klasse für Tools definiert, dadurch funktioniert derselbe Code mit Modellen verschiedener Anbieter. Die Schnittstelle heißt IChatClient, die Klasse AIFunction.

Ein LLM beantwortet eine Liste von Nachrichten mit einer neuen Nachricht. Ohne Tools ist die Antwort Text, mit Tools kann das Modell stattdessen mit einem Funktionsaufruf antworten, dem Namen eines Tools und seinen Argumenten. Das heißt Tool Calling oder Function Calling.

Ausführen kann das Modell selbst nichts. Das Programm um das Modell herum führt das Tool aus, hängt das Ergebnis an die Nachrichten an und ruft das Modell erneut auf. Daraufhin liest das Modell das Ergebnis und ruft entweder das nächste Tool auf oder antwortet mit Text. Dieser Kreislauf ist der Agent Loop, und ein Aufruf des Modells ist eine Runde.

Der folgende Code zeigt die Schleife, von Hand mit Microsoft.Extensions.AI geschrieben:

static async Task<ChatResponse> RunAsync(IChatClient model, List<ChatMessage> messages, ChatOptions options, List<AIFunction> tools)
{
    while (true)
    {
        var response = await model.GetResponseAsync(messages, options);
        messages.AddMessages(response);

        var calls = response.Messages.SelectMany(m => m.Contents).OfType<FunctionCallContent>().ToList();
        if (calls.Count == 0)
            return response;

        foreach (var call in calls)
        {
            var tool = tools.First(t => t.Name == call.Name);
            var result = await tool.InvokeAsync(new AIFunctionArguments(call.Arguments));
            messages.Add(new ChatMessage(ChatRole.Tool, [new FunctionResultContent(call.CallId, result)]));
        }
    }
}

Mit der Methode GetResponseAsync wird die Liste an das Modell gesendet und die Antwort in die Liste aufgenommen. Enthält die Antwort kein FunctionCallContent, gibt die Schleife sie zurück. Andernfalls wird jedes Tool über seinen Namen gefunden, mit InvokeAsync ausgeführt und das Ergebnis als Nachricht mit der Rolle Tool angehängt.

Die Liste der Nachrichten wächst mit jeder Runde. Sie beginnt mit dem System-Prompt, der Anweisung für den ganzen Lauf, und der Karte als Benutzernachricht (engl. User Message), anschließend werden jede Antwort des Modells und jedes Tool-Ergebnis angehängt. In jeder Runde sieht das Modell die ganze Liste und weiß dadurch, was es bereits gelesen und getan hat. Beendet wird die Schleife, wenn das Modell ohne Funktionsaufruf antwortet.

Microsoft.Extensions.AI enthält diese Schleife als Klasse namens FunctionInvokingChatClient, der Name kommt im Code des Runners nicht vor.

Über den Aufruf UseFunctionInvocation unten wird eine Instanz dieser Klasse an den Chat-Client gehängt, der Lambda-Ausdruck setzt die maximale Zahl der Runden und schaltet detaillierte Fehler ein. Wirft ein Tool eine Exception, wird die Fehlermeldung von der Klasse als Ergebnis an das Modell gesendet, damit es einen neuen Versuch machen kann. Nach drei Fehlern in Folge bricht sie mit der Exception ab.

Der folgende Code zeigt den Kern des Runners:

var rounds = new RoundLogger(new AnthropicClient().AsIChatClient(model), log, maxTokens);

await using var uiServer = await UiServer.ConnectAsync(uiServerSetting, repoRoot);
var tools = new List<AITool>();
tools.AddRange((await uiServer.ListToolsAsync()).Select(tool => new LoggedFunction(tool, log)));
tools.AddRange(new LocalTools(repoRoot).All().Select(tool => new LoggedFunction(tool, log)));

var client = rounds.AsBuilder()
    .UseFunctionInvocation(configure: options =>
    {
        options.MaximumIterationsPerRequest = maxIterations;
        options.IncludeDetailedErrors = true;
    })
    .Build();

var messages = new List<ChatMessage>
{
    new(ChatRole.System, File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "instructions.md"))),
    new(ChatRole.User, "THE CARD\n--------\n" + card.Text),
};

var response = await client.GetResponseAsync(messages, new ChatOptions { Tools = tools, MaxOutputTokens = 4096 });
var result = ResultContract.Parse(response.Text);

In der ersten Zeile wird der Chat-Client für Claude erzeugt. Die Methode AsIChatClient ist Teil des Anthropic-SDK, das OpenAI-Paket bietet dieselbe Methode, ein OpenAI-Modell benötigt damit nur eine andere erste Zeile. Die Klasse RoundLogger → umhüllt den Client und zählt Runden und Tokens. Ein Token ist ein Textstück von etwa vier Zeichen, und die API rechnet die Eingabe und die Ausgabe jeder Runde je Token ab.

Der nächste Block verbindet den UiMcpServer und fasst seine acht UI-Tools und die sieben lokalen Tools in einer Liste zusammen. Mit dem Aufruf UseFunctionInvocation wird die Schleife mit einer Grenze von 60 Runden hinzugefügt. Als Nachrichten werden der System-Prompt aus einer Datei und die Karte als Benutzernachricht übergeben. Anschließend läuft die Methode GetResponseAsync, bis das Modell mit Text antwortet, und die letzte Zeile RESULT dieses Texts entscheidet wie beim PowerShell-Runner über den Status der Karte.

Die Klasse UiServer umhüllt den McpClient des MCP-SDK für C#, der den UiMcpServer als Kindprozess startet und über die Standardein- und -ausgabe mit ihm kommuniziert. Von der Methode ListToolsAsync werden die acht Tools als AIFunctions geliefert und landen dadurch in derselben Liste wie die lokalen Tools. Für das Modell gibt es keinen Unterschied.

Die Tools

Claude Code bringt eigene Tools für Dateien und eine Shell mit, der Runner muss sie selbst bereitstellen und tut das mit festen Befehlen statt einer Shell. Die sieben lokalen Tools sind:

  • list_files für die Dateien unter einem Ordner des Repositorys
  • read_file für eine Datei unter src oder .claude/rules
  • write_file für eine Datei unter src/InvoiceApp, immer mit dem vollständigen Inhalt
  • build_app für dotnet build der InvoiceApp
  • start_app und stop_app für den Prozess der InvoiceApp
  • git_commit für den Commit der Änderungen unter src/InvoiceApp

Aufrufen lassen sich nur diese Tools. Es gibt keinen Weg, ein anderes Programm aufzurufen oder außerhalb des Anwendungsordners zu schreiben, jeder Pfad wird vom Runner zunächst gegen die erlaubten Ordner geprüft. Eine Grenze, die der Code erzwingt, heißt Guardrail. Der folgende Code zeigt das Build-Tool aus der Klasse LocalTools →:

[Description("Builds src/InvoiceApp with dotnet build. Call stop_app first, the build fails while the exe runs. Returns the errors and warnings of the build.")]
public async Task<string> BuildAppAsync()
{
    var (exitCode, output) = await RunAsync("dotnet", $"build {AppProject}", BuildTimeoutMs);
    var lines = output.Split('\n').Select(line => line.TrimEnd()).Where(line => line.Length > 0).ToList();
    var relevant = lines.Where(line => line.Contains("error", StringComparison.OrdinalIgnoreCase)
        || line.Contains("warning", StringComparison.OrdinalIgnoreCase));
    var summary = exitCode == 0 ? "Build succeeded." : $"Build failed with exit code {exitCode}.";
    return summary + "\n" + string.Join("\n", relevant.Distinct().Take(BuildOutputLines));
}

Mit der Methode AIFunctionFactory.Create wird aus dieser Methode eine AIFunction mit einer Beschreibung ihrer Parameter in JSON. Das Description-Attribut sagt dem Modell, wann es das Tool aufruft und was vorher zu tun ist. Zurückgeliefert werden eine Zusammenfassungszeile sowie die Fehler und Warnungen, nicht die vollständige Ausgabe von MSBuild, die in jeder späteren Runde Tokens kostet. Context Engineering heißt, zu entscheiden, was das Modell in einem Aufruf sieht, und wie viel.

Eine Klasse namens LoggedFunction → umhüllt jedes UI-Tool und jedes lokale Tool und schreibt den Aufruf mit Argumenten, Dauer und Ergebnis in das Log. Anschließend wird das Ergebnis in reinen Text umgewandelt und über 4.000 Zeichen gekürzt. Das Ergebnis endet mit einem Hinweis auf die Kürzung, damit kann das Modell beim nächsten Mal weniger anfordern, z. B. die Elemente eines Fensters statt aller Fenster.

Der erste Lauf hat gezeigt, warum der reine Text wichtig ist. Vom MCP-Client wird ein Textblock als Objekt geliefert, und das Modell sah ein JSON-Objekt mit maskierten Anführungszeichen und Zeilenumbrüchen. Die maskierten Zeichen kosten in jeder späteren Runde Tokens.

Der Prompt, die Grenzen und das Log

Der System-Prompt ist der Prompt des PowerShell-Runners mit wenigen Änderungen. Die Toolnamen ersetzen die Shell-Befehle, die Karte ist die Benutzernachricht, und eine Zeile sagt: „There is no shell. If something is not possible with these tools, say so in the RESULT line.“ Der folgende Text zeigt die Schritte des System-Prompts in instructions.md →:

HOW TO WORK
-----------
1. Start the app with start_app, reproduce or inspect the behavior the card
   describes using the ui_* tools. Take a screenshot as evidence.
2. Implement the change in src/InvoiceApp. Keep it minimal and in the style of
   the existing code.
3. Stop the app with stop_app BEFORE rebuilding - the build fails while the exe
   runs. Rebuild with build_app.
4. Start the app again and verify every acceptance criterion of the card live
   through the ui_* tools: change values, click, read the results back.
   Take an after screenshot. A build that compiles proves nothing.
5. Stop the app. Commit ONLY the source changes with git_commit; the tool stages
   src/InvoiceApp and nothing else. The first line of the message is:
   card: <the card title>
   and the body says in two or three sentences what was changed and how it
   was verified live.

THE LAST LINE of your final message must be exactly one of:
RESULT: done
RESULT: failed: <one-line reason>

Der vierte Schritt endet mit dem Satz „A build that compiles proves nothing.“ Die letzte Zeile der abschließenden Antwort muss RESULT: done oder RESULT: failed mit einer Begründung sein. Anhand dieser Zeile wird die Karte vom Runner auf „Done“ oder „Failed“ gesetzt.

Zwei Grenzen beenden einen Lauf, der nicht von selbst endet. Nach 60 Runden wird die Schleife gestoppt, nach 400.000 Tokens stoppt RoundLogger sie. RoundLogger sitzt unterhalb der Schleife und sieht dadurch jeden Aufruf des Modells. Da die Eingabe einer Runde die gesamte bisherige Konversation ist, wächst die Summe der Eingabe-Tokens mit jeder Runde.

Ein Lauf, der an einer Grenze stoppt, lässt die Karte auf „Failed“. Der erste Lauf ist vor der ersten Runde gescheitert, da das Modell „claude-sonnet-5-5“ den Parameter temperature nicht akzeptiert. Seitdem sendet der Runner keinen.

Für jeden Lauf wird eine Logdatei mit einem JSON-Objekt je Zeile geschrieben. Die erste Zeile ist der Start mit der Toolliste, anschließend folgt je eine Zeile für jede Runde, jeden Tool-Aufruf und das Endergebnis. Die folgenden zwei Zeilen zeigen die erste Runde und den ersten Tool-Aufruf aus dem Lauf der Löschkarte:

{"time":"11:55:09.301","type":"round","data":{"round":1,"ms":2074,"inputTokens":3270,"outputTokens":76,"calls":["list_files","list_files"],"text":""}}
{"time":"11:55:09.312","type":"tool","data":{"name":"list_files","arguments":{},"ms":5,"result":"src/InvoiceApp/App.xaml\nsrc/InvoiceApp/App.xaml.cs\nsrc/InvoiceApp/InvoiceApp.csproj\nsrc/InvoiceApp/MainWindow.xaml\nsrc/InvoiceApp/MainWindow.xaml.cs\nsrc/InvoiceApp/OrderItem.cs"}}

Die erste Runde dauerte 2 Sekunden und verbrauchte 3.270 Eingabe-Tokens, dabei wurde die Dateiliste vom Modell zweimal angefordert. Der Tool-Aufruf dauerte 5 ms und lieferte sechs Dateinamen. Das Log ist die vollständige Aufzeichnung des Laufs, Claude Code schreibt mit seinem Ausgabeformat stream-json ein ähnliches Log.

Drei Karten, drei Läufe

Jede der drei Karten wurde einmal mit dem Modell „claude-sonnet-5-5“ ausgeführt:

  • total-ignores-the-discount: 15 Runden, 31 Tool-Aufrufe, 194.000 Eingabe-Tokens, 47 Sekunden
  • delete-asks-for-confirmation: 20 Runden, 34 Tool-Aufrufe, 255.000 Eingabe-Tokens, 59 Sekunden
  • validate-quantity-and-price: 21 Runden, 40 Tool-Aufrufe, 327.000 Eingabe-Tokens, 68 Sekunden

Alle drei Läufe endeten mit RESULT: done und einem Commit mit einer Datei und folgen demselben Muster. Die Dateien werden zweimal gelistet, fünf oder sechs davon gelesen, eine Datei geschrieben und einmal gebaut. Dazu wird die Anwendung zweimal gestartet und gestoppt, es werden zwei Screenshots erstellt und einmal committet. Da ein LLM nicht deterministisch ist, kann ein zweiter Lauf derselben Karte andere Schritte gehen.

Die Aufrufe des Modells machen etwa 80 Prozent der Dauer aus, der Rest ist der Build und der Start der Anwendung. Im Repository markieren die Tags mit dem Suffix „-dotnet-done“ den Stand nach jedem Lauf, der Branch main enthält weiterhin die drei Lücken, damit jede Karte erneut laufen kann.

Die Löschkarte vom Anfang verlangte einen Bestätigungsdialog mit dem Namen der Position. Vom Modell wurde ein Dialogfeld mit den Buttons „Yes“ und „No“ ergänzt, beide Buttons in der laufenden Anwendung geprüft und die Änderung committet (siehe Screenshot oben).

Auf einem deutschen Windows sind die Buttons dieses Dialogs mit „Ja“ und „Nein“ beschriftet. Der erste Versuch des Modells, ein Fenster namens „Delete item“ zu finden, scheiterte, da der Dialog zum Hauptfenster gehört. Daraufhin wurden die Elemente des Hauptfensters gelistet, die zwei Buttons über ihre AutomationIds 6 und 7 gefunden und geklickt, beide Prüfungen waren erfolgreich.

In der Commit-Nachricht der Validierungskarte steht „88,70“, wo die Zwischensumme 88,80 war. Der Code ist richtig, der Text nicht. Eine Antwort, die richtig klingt und falsch ist, heißt Halluzination.

Mit Claude Code brauchte die Rabattkarte 200 Sekunden. Da Claude Code sein Modell selbst wählt und mehr Tools mitbringt, sind die beiden Zahlen nicht vergleichbar. Zusätzlich hat Claude Code Funktionen, die der Runner nicht hat:

  • das Kürzen der Konversation, wenn sie das Kontextfenster überschreitet, die maximale Zahl der Tokens, die das Modell in einem Aufruf liest (der Runner stoppt an der Token-Grenze)
  • das Fortsetzen eines Laufs mit seiner Konversation, die sog. Session Continuation
  • Subagenten, ein zweiter Agent, den der erste mit einer Aufgabe startet
  • einen Schritt für die Erlaubnis, in dem ein Mensch als Human in the Loop den Commit freigibt
  • ein Agent Memory, Notizen des Modells für spätere Läufe

Auf das Microsoft Agent Framework, das Sessions, Checkpoints und die Freigabe von Tools auf Microsoft.Extensions.AI aufsetzt, wird in diesem Artikel nicht eingegangen. Ebenso wenig wird gezeigt, wie der Runner in einem Container läuft. Mit einer HTTP-Adresse statt eines Programmpfads verbindet sich der Runner mit einem UiMcpServer auf einem anderen Rechner, der Server unterstützt dafür bereits Streamable HTTP. Allerdings benötigt UI Automation einen Desktop mit angemeldetem Benutzer, die getestete Anwendung bleibt damit außerhalb des Containers.

Das Repository enthält diesen Artikel auch als PDF →, auf Englisch.

Das war’s.

Bei Fragen gerne melden.

CardsToLoop auf GitHub →
Microsoft.Extensions.AI →
MCP C# SDK →
Anthropic C# SDK →
Microsoft Agent Framework →
Übersicht zu UI Automation →