Knurz.Harness.Client (0.4.0-alpha.5)
Installation
dotnet nuget add source --name developer --username your_username --password your_token dotnet add package --source developer --version 0.4.0-alpha.5 Knurz.Harness.ClientAbout this package
.NET client for the harness host: typed run specs and events, an event stream that resumes after a dropped connection, caller tools and typed finish results.
Harness.Client
The .NET client of the harness host: typed run specs, messages and events, an event stream that resumes after a dropped connection, caller tools carried out in .NET, and typed finish results. .NET 10, no dependencies besides the base library.
The NuGet package is Knurz.Harness.Client in the Gitea feed; map it to that feed as the root README shows.
The host runs the agent loop (docs/contract.md); this library is how a .NET app drives it. The C# types follow the host's JSON schemas in contract, which a test in the host keeps current; the tests here compare every C# type with them, so a field cannot change on one side only.
A run with a caller tool and a typed result
using Harness.Client;
using var harness = HarnessClient.Create(new Uri("http://127.0.0.1:18090/"), token);
var lookup = CallerTool.Create<LookupArguments>(
"lookup", "Looks up the value of a key.",
async (arguments, ct) => await store.GetAsync(arguments.Key, ct),
mutates: false);
var spec = new RunSpec
{
Model = "qwen/qwen3-coder",
SystemPrompt = "You look things up and report them.",
Messages = [UserMessage.FromText("Look up key a and report it.")],
Limits = new RunLimits { MaxToolCalls = 20, MaxSeconds = 300 },
End = EndMode.FinishTool,
FinishTool = ToolSpec.For<Report>("report", "The result."),
CallerTools = CallerTool.Specs([lookup]),
};
var result = await new RunDriver(harness).RunAsync(spec, new RunHandlers
{
CallerTools = [lookup],
OnEvent = (ev, ct) => { Console.WriteLine(ev.Type); return ValueTask.CompletedTask; },
});
Report report = result.FinishAs<Report>(); // checked against the schema by the host (lesson L42)
record LookupArguments(string Key);
record Report(string Summary, int Count);
What it covers
| Need | API |
|---|---|
| Every host endpoint | HarnessClient: StartRunAsync, EventsAsync, SendToolResultsAsync, SendInputAsync, SendApprovalsAsync, SendMessageAsync (steer), CancelAsync, GetRunAsync, GetStateAsync, ResumeRunAsync, GetStatusAsync |
| A whole run with its pauses | RunDriver.RunAsync / FollowAsync with RunHandlers (events, caller tools, questions, approvals) |
| Caller tools with typed arguments | CallerTool.Create<T>: the schema comes from T; arguments that do not fit go back to the model as an error |
| Typed results | ToolSpec.For<T> as finish tool, RunResult.FinishAs<T>() |
| Store and resume a paused run | GetStateAsync → RunState.ToJson(); later RunState.Parse → ResumeRunAsync |
| The next turn from a run's messages | RunState.Messages into the next RunSpec.Messages |
| Host refusals | HarnessException with Status, Code (e.g. run_not_running) and Detail |
| Event types of newer hosts | arrive as UnknownEvent with their JSON |
Before it starts a run, RunDriver checks that the spec's caller tools, questions and approvals all have a handler. A dropped event stream is read on after the last sequence number seen (waits of 0, 0.5, 1, 2 and 5 seconds); a pause is never answered twice.
Build and test
dotnet test clients/dotnet/Harness.Client.slnx
HARNESS_DOTNET_TESTS=1 uv run pytest packages/harness-host/tests/test_dotnet_client.py
The second drives Harness.Client.Smoke against a real host with a scripted model. After a change of the host's types: uv run python -m harness_host.contract clients/dotnet/contract.
The package version equals the Python packages' version (0.4.0a3 is 0.4.0-alpha.3); a test keeps them equal.