Building a .NET Dev Agent, Part 1: Teach It Your Actual Workflow
The agent worked on the first try, which should have been my first warning.
I had wired Claude to the three commands I run all day — git status, dotnet build, dotnet test — handed it the output, and asked why my build was red. It answered correctly. Then, out of habit, I counted what I had actually sent. The build reported 70 warnings. The solution only has 35. I had been shipping Claude a verbatim second copy of every diagnostic, and Claude had been dutifully reading both.
MSBuild prints every diagnostic twice: once inline as the compiler reaches it, and once more in the summary block after Build FAILED.. Human eyes skip that second copy so completely that I had never noticed it in years of reading build output. A language model skips nothing. It reads the duplicate, you pay for the duplicate, and the model reasons about a problem that now looks twice its real size.
This post is about the unglamorous half of a dev agent: not the loop that calls the model, but what you put in front of it. The loop is about twenty lines and I have written it before. Working out what your toolchain's output actually means — and dropping the large fraction of it that means nothing — is what separates an agent that helps from one that merely bills you.
What this post assumes
The tool_use loop itself — declare tools, get a tool_use block back, execute it, return a tool_result, repeat until the model stops asking — was built step by step in Build a Claude Tool-Use Agent in C#. I am not rebuilding it here. If you have never written one, start there; everything below plugs into it.
All the examples assume .NET 10 and the official SDK:
dotnet add package Anthropic
Three tools, and why none of them is bash
The obvious move is to hand the model a single bash tool and let it run whatever it likes. That is genuinely the right call sometimes — it is what Claude Code does, and breadth is the whole point there. It is the wrong call here, for two reasons.
The first is that a bash tool hands your harness an opaque string. Every action looks identical from the outside, so there is nothing to inspect, gate, or count. A typed dotnet_build tool gives you a hook with real arguments: you can decide that a build is safe to run unattended and that anything touching the network is not.
The second is scope. git status, dotnet build and dotnet test are all read-only. That is what makes it reasonable to point this thing at your own working copy on a Friday afternoon. The moment you add bash, the blast radius becomes "anything my user account can do," and you are one confidently-worded suggestion away from a bad afternoon.
Start with bash when you want reach. Promote to typed tools when you want control. For a daily-driver agent on your own machine, control wins.
Ask for the machine-readable output, not the pretty one
Both of the tools that matter here have a second output format designed for programs, and in both cases the human-facing default is the wrong thing to parse. This is the single highest-leverage decision in the whole post, and it shows up twice.
git status prints something friendly and unstable. git status --porcelain=v2 prints a documented format with an explicit compatibility promise. dotnet test prints a readable summary; dotnet test --logger trx writes structured XML.
The counterintuitive part: the machine-readable format is usually bigger. The TRX file for my 91-test suite is 149,020 bytes, against 2,291 bytes of console output. That is fine, because you are never going to send it. You query it and emit a summary. Parsing a stable contract and discarding 99% of it beats regexing a pretty format that changes between SDK releases.
Tool one: git_status
Porcelain v2 gives one line per entry. Changed files start with 1 (ordinary) or 2 (renamed), untracked files with ?, and the --branch flag adds # branch.* headers with the ahead/behind counts.
static async Task<string> GitStatusAsync(string repo, CancellationToken ct)
{
string output = await RunAsync("git", ["status", "--porcelain=v2", "--branch"], repo, ct);
string branch = "(detached)";
int ahead = 0, behind = 0;
List<string> changed = [], untracked = [];
foreach (string raw in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
string line = raw.TrimEnd('\r');
if (line.StartsWith("# branch.head "))
{
branch = line["# branch.head ".Length..];
}
else if (line.StartsWith("# branch.ab "))
{
string[] ab = line["# branch.ab ".Length..].Split(' ');
ahead = int.Parse(ab[0]); // "+2"
behind = int.Parse(ab[1]); // "-0"
}
else if (line.StartsWith("1 "))
{
// 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
string[] f = line.Split(' ', 9);
changed.Add($"{f[1]} {f[8]}");
}
else if (line.StartsWith("2 "))
{
// renames carry an extra score field, and the path field is "new<tab>old"
string[] f = line.Split(' ', 10);
string[] paths = f[9].Split('\t');
changed.Add($"{f[1]} {paths[0]} (was {paths[1]})");
}
else if (line.StartsWith("? "))
{
untracked.Add(line[2..]);
}
}
StringBuilder summary = new();
summary.AppendLine($"branch {branch}, {ahead} ahead, {behind} behind");
summary.AppendLine($"{changed.Count} changed, {untracked.Count} untracked");
foreach (string entry in changed.Take(40)) summary.AppendLine($" {entry}");
foreach (string entry in untracked.Take(20)) summary.AppendLine($" ?? {entry}");
return summary.ToString();
}
The two-character XY code survives into the summary because it is the difference between "staged" and "not staged," and the model uses it. Everything else on the line — the three file modes and two object hashes — is for tooling that needs to diff, not for answering "what am I working on."
Tool two: dotnet_build, where the duplication lives
Here is what a failing build actually looks like on a three-project solution, measured rather than guessed:
87 lines, 20,681 bytes
70 warnings printed
35 warnings that exist
Every diagnostic appears exactly twice. On top of that, each printed line carries the absolute path twice — once as the file location and once again in the trailing [...csproj] marker — so a 90-character message arrives inside a 500-character line.
The fix is a regex and a dictionary. Deduplicate on the tuple that actually identifies a diagnostic — file, line, column, code — and make paths relative to the repository root:
// A source-generated regex, so the pattern is compiled at build time rather than
// at startup. It lives in a class because a file-based app has no room for a
// field: anything declared alongside top-level statements has to be a local, and
// `static readonly` there is a CS0106.
static partial class BuildOutput
{
[GeneratedRegex(@"^(?<file>.+?)\((?<line>\d+),(?<col>\d+)\): (?<severity>error|warning) (?<code>[A-Za-z]+\d+): (?<message>.+?)(?: \[[^\]]+\])?$")]
public static partial Regex DiagnosticLine();
}
static async Task<string> DotnetBuildAsync(string repo, string? project, CancellationToken ct)
{
string[] args = project is null
? ["build", "--nologo"]
: ["build", "--nologo", project];
string output = await RunAsync("dotnet", args, repo, ct);
// MSBuild emits each diagnostic inline and then again in the summary block.
Dictionary<(string, int, int, string), Diagnostic> unique = [];
foreach (string raw in output.Split('\n'))
{
Match match = BuildOutput.DiagnosticLine().Match(raw.TrimEnd('\r'));
if (!match.Success) continue;
string file = match.Groups["file"].Value.Trim();
// Diagnostics anchored in the SDK's own .targets files are infrastructure
// failures, not code failures. Keep them, but don't pretend they're yours.
string display = file.StartsWith(repo, StringComparison.OrdinalIgnoreCase)
? Path.GetRelativePath(repo, file).Replace('\\', '/')
: file;
Diagnostic diagnostic = new(
display,
int.Parse(match.Groups["line"].Value),
int.Parse(match.Groups["col"].Value),
match.Groups["severity"].Value,
match.Groups["code"].Value,
match.Groups["message"].Value.Trim());
unique.TryAdd((diagnostic.File, diagnostic.Line, diagnostic.Column, diagnostic.Code), diagnostic);
}
List<Diagnostic> errors = [.. unique.Values.Where(d => d.Severity == "error")];
List<Diagnostic> warnings = [.. unique.Values.Where(d => d.Severity == "warning")];
if (errors.Count == 0 && warnings.Count == 0) return "Build succeeded with no diagnostics.";
StringBuilder summary = new();
summary.AppendLine($"{errors.Count} error(s), {warnings.Count} warning(s).");
// Errors first: a warning is rarely why the build is red.
foreach (Diagnostic d in errors.Concat(warnings).Take(50))
summary.AppendLine($"{d.File}({d.Line},{d.Column}): {d.Severity} {d.Code}: {d.Message}");
int total = errors.Count + warnings.Count;
if (total > 50) summary.AppendLine($"... {total - 50} more suppressed.");
return summary.ToString();
}
Diagnostic is record Diagnostic(string File, int Line, int Column, string Severity, string Code, string Message);, declared after the last top-level statement along with BuildOutput — in a file-based app every type goes at the end of the file or you get a CS8803.
That takes the same build from 20,681 bytes to 4,849 — about a quarter of the original, with nothing of value lost. The cap matters too: a solution where one broken interface produces 900 diagnostics does not need all 900 in the context window to be diagnosed. The first fifty say the same thing.
The mistake I made first
Before any of the above, my instinct was that the agent needed more context, not less. So I raised the verbosity:
dotnet build -v n
That build is 187,412 bytes — nine times the default output, for the same three projects and the same errors. The longest single line is 25,635 characters: the complete csc invocation, every reference, every analyzer, in full. None of it explains a compile error, and all of it crowds out the three lines that do.
Verbosity flags are written for a human scrolling to find one thing. An agent reads all of it, every turn. Turning it up was the most expensive thing I did to this project, and it made the answers worse — which at least made the problem easy to spot.
Tool three: dotnet_test, via TRX
Modern dotnet test is already restrained on the console — 89 passing tests produce no output at all. But the failures repeat the same pattern: each one is announced twice, once as an [xUnit.net] line and once in the detail block.
The bigger problem is stack traces. Across my two failing tests there were six frames, and only two pointed at my code. The other four look like this:
at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
That is the test runner reflecting its way into your test method. It is identical for every failure in every suite ever written, and it tells the model precisely nothing. Two-thirds of the stack trace is boilerplate.
TRX gives you all of this as XML with a stable schema, so you can take the three fields that matter and leave the rest on disk:
static async Task<string> DotnetTestAsync(string repo, CancellationToken ct)
{
string resultsDir = Path.Combine(Path.GetTempPath(), $"agent-trx-{Guid.NewGuid():N}");
try
{
await RunAsync("dotnet",
["test", "--nologo", "--logger", "trx;LogFileName=run.trx", "--results-directory", resultsDir],
repo, ct);
string trx = Path.Combine(resultsDir, "run.trx");
if (!File.Exists(trx)) return "No TRX produced — the test project probably failed to build.";
XNamespace ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
XDocument doc = XDocument.Load(trx);
XElement? counters = doc.Descendants(ns + "Counters").FirstOrDefault();
StringBuilder summary = new();
summary.AppendLine(
$"{counters?.Attribute("passed")?.Value ?? "?"}/{counters?.Attribute("total")?.Value ?? "?"} passed, " +
$"{counters?.Attribute("failed")?.Value ?? "?"} failed.");
foreach (XElement result in doc.Descendants(ns + "UnitTestResult")
.Where(r => (string?)r.Attribute("outcome") == "Failed")
.Take(15))
{
string name = ((string?)result.Attribute("testName") ?? "(unknown)").Split('.')[^1];
string message = result.Descendants(ns + "Message").FirstOrDefault()?.Value.Trim() ?? "";
// Keep only frames in the project's own code.
IEnumerable<string> frames = (result.Descendants(ns + "StackTrace").FirstOrDefault()?.Value ?? "")
.Split('\n')
.Select(f => f.Trim())
.Where(f => f.Length > 0 && !f.StartsWith("at System."))
.Take(3);
summary.AppendLine();
summary.AppendLine($"FAILED {name}");
foreach (string line in message.Split('\n')) summary.AppendLine($" {line.TrimEnd('\r')}");
foreach (string frame in frames) summary.AppendLine($" {frame}");
}
return summary.ToString();
}
finally
{
if (Directory.Exists(resultsDir)) Directory.Delete(resultsDir, recursive: true);
}
}
Filtering at System. is a blunt rule that would hide a genuine failure inside the BCL. In three months of daily use that has not happened once, and if it does, the assertion message still names what went wrong. Blunt and cheap beats clever and fragile here.
The numbers, end to end
Measured on a three-project .NET 10 solution with 91 xUnit tests. Reproduce any row with wc -c:
| Source | Raw | Filtered | Ratio |
|---|---|---|---|
| Failing build, 35 diagnostics | 20,681 B / 87 lines | 4,849 B / 37 lines | 4.3x |
| Failing tests, 2 of 91 | 2,291 B / 31 lines | 878 B / 17 lines | 2.6x |
| TRX file for the same run | 149,020 B | 878 B | 170x |
Build at -v n
|
187,412 B | — | don't |
Four times smaller is not the headline. The headline is that roughly half of what I was sending was a byte-for-byte duplicate, and I could not see it until I counted.
Running the process safely
All three tools go through one helper. Two details in it are not optional.
static async Task<string> RunAsync(string file, string[] args, string cwd, CancellationToken ct)
{
ProcessStartInfo psi = new()
{
FileName = file,
WorkingDirectory = cwd,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
// ArgumentList escapes each value individually. Never concatenate a command
// string from model-supplied input.
foreach (string arg in args) psi.ArgumentList.Add(arg);
using Process process = Process.Start(psi)
?? throw new InvalidOperationException($"Could not start {file}.");
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromMinutes(5));
Task<string> stdout = process.StandardOutput.ReadToEndAsync(timeout.Token);
Task<string> stderr = process.StandardError.ReadToEndAsync(timeout.Token);
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
throw new TimeoutException($"{file} did not finish within five minutes.");
}
return await stdout + await stderr;
}
ArgumentList rather than a joined string means a project path the model invented cannot smuggle in a second command. And a hung dotnet test has to die on its own timer, or your agent waits forever while the model sits there billing you for patience.
The system prompt is where your workflow actually lives
The tools are generic. What makes the agent yours is the paragraph describing the repository — and it is the part everyone skips.
List<TextBlockParam> system =
[
new()
{
Text = """
You are a development assistant for the ClaudeReviewBot repository.
Three projects. src/ClaudeReviewBot.Core holds the logic and is the one
that matters. src/ClaudeReviewBot is a thin CLI wrapper. tests/ is xUnit.
TreatWarningsAsErrors is on, so any warning fails the build. CS1591
(missing XML comment) is noise we have never cared about. Nullable
warnings are not noise -- treat those as real defects.
Check state before answering. If the question is about the build, build.
If it is about what changed, call git_status. Do not speculate.
""",
CacheControl = new CacheControlEphemeral(),
},
];
That block is byte-identical on every turn, which makes it the ideal cache breakpoint. CacheControlEphemeral means you write it once and read it at a fraction of the price for the rest of the session — and since it sits ahead of the conversation in the prefix, the whole history behind it stays cached too.
Note what the text is doing. "CS1591 is noise, nullable warnings are not" is not a technical fact about C#; it is a judgement call from your team, and there is nowhere else for the model to learn it. That sentence is the entire premise of the series in one line.
MessageCreateParams parameters = new()
{
Model = "claude-opus-5",
MaxTokens = 8000,
System = system,
Tools = [.. tools],
Messages = messages,
};
The first useful thing it did
Not a toy. I renamed a property on a record used across three projects, forgot a usage, and asked:
why is the build red
The agent called dotnet_build, got three deduplicated CS1061 errors all naming ChangedFile.Patch, then called git_status unprompted and saw that ChangedFile.cs was the only staged file. It connected the two: the rename was applied at the declaration and at two of the three call sites.
That second tool call is the interesting part. Nothing in my prompt said "correlate build errors with the working tree." It had a tool that could tell it what changed, a question that implied something recently broke, and enough room in the context to think — because the context was not full of duplicated warnings and csc command lines.
When not to reach for this
If you can read the compiler error yourself, read the compiler error yourself. It is faster and free. A single CS1061 on a file you just touched does not need a round trip to a language model.
This earns its place when the question spans sources — a test failing for reasons the build output only hints at, a change whose blast radius is not obvious, the Monday-morning "what was I even doing." And it is worth remembering the lesson from the PR review bot: sometimes the right number of tools is fewer than you think, and occasionally it is zero.
Key Takeaways
- MSBuild prints every diagnostic twice — once inline, once in the summary. 70 printed, 35 real. Deduplicate on file, line, column and code before anything reaches the model.
-
Ask for the machine-readable format.
git status --porcelain=v2anddotnet test --logger trxare stability contracts; console output is a UI, and UIs change between SDK releases. - Bigger on disk can mean smaller in context. The TRX file is 149,020 bytes and the summary worth sending is 878. You query it; you never forward it.
- Raising verbosity makes an agent worse, not better. Normal verbosity was nine times the payload for the same build, with single lines of 25,635 characters, and the answers degraded.
- The filtering rules are your workflow. Which warnings matter, which projects matter, what "done" means — that judgement has no home other than your tool code and your system prompt.
Next in the series: Building a .NET Dev Agent, Part 2: Give It a Voice — speech-to-text and text-to-speech so you talk to it instead of typing, why a spoken answer needs a different system prompt than a written one, and what happens to tool design when the reply has to be short enough to listen to.
Published on the 256th day of the year: 2^8, everything a single byte can hold. Seemed like the right day for a post that spends its whole length counting them.
01001000 01100001 01110000 01110000 01111001 00100000 01100100 01100101 01110110 01110011 00100001




Top comments (1)
One guardrail I’d add is to make exit status authoritative and parsing advisory. RunAsync currently drops process.ExitCode, so dotnet build missing.sln can emit a non-location MSB1009, leave unique empty, and reach “Build succeeded with no diagnostics.” I’d return a typed result containing ExitCode, parsed diagnostics, timeout state, and a bounded raw tail or artifact path. Only exit zero should map to success; nonzero with no matches should mean “tool failed—unparsed output.” A contract suite covering a missing project, restore failure, compiler error, test-host crash, timeout, and malformed TRX would keep the context filter from becoming a truth filter. Would you lock that schema down before adding more tools?