clawdforge/clients/csharp
Kayos 692b48a6b2 clients/csharp: v0.2 multi-turn Session API
- Session implements IAsyncDisposable; await using is the canonical form
- Interlocked.CompareExchange idempotency on CloseAsync (rollback on transient)
- ForgeClient.CreateSessionAsync / ListSessionsAsync / GetSessionAsync
- TurnResult.Text() helper, records throughout
- Session.ToString redacts internal _client (no bearer leak)
- SessionTests.cs: 12 tests covering await-using/idempotency/rollback/exception-still-closes/list/state/cross-token-404/redaction/regression
- README "Multi-turn / Sessions (v0.2)" section
- csproj bumped to 0.2.0

v0.1 surface unchanged.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
2026-04-29 06:59:45 -07:00
..
examples/Basic clients/csharp: apply audit findings — JSON depth caps + stream lifecycle (09aca58 → new) 2026-04-28 23:22:58 -07:00
src/Clawdforge clients/csharp: v0.2 multi-turn Session API 2026-04-29 06:59:45 -07:00
tests/Clawdforge.Tests clients/csharp: v0.2 multi-turn Session API 2026-04-29 06:59:45 -07:00
.gitignore clients/csharp: initial C# SDK for clawdforge 2026-04-28 22:53:09 -07:00
Clawdforge.sln clients/csharp: initial C# SDK for clawdforge 2026-04-28 22:53:09 -07:00
README.md clients/csharp: v0.2 multi-turn Session API 2026-04-29 06:59:45 -07:00

clawdforge C# / .NET SDK

C# / .NET 8 SDK for the LAN-only clawdforge HTTP service — a thin REST wrapper around claude -p subprocess invocations.

  • Target framework: net8.0 (LTS).
  • HTTP: System.Net.Http.HttpClientIHttpClientFactory friendly.
  • JSON: System.Text.Json (no Newtonsoft).
  • Async: every I/O method returns Task<T> and accepts a CancellationToken. No sync wrappers.
  • Nullable reference types: enabled. Warnings as errors.
  • License: MIT.

Install

The package isn't published to nuget.org (LAN-only service). You have two local install paths.

Option A — local NuGet feed via dotnet pack

From this directory:

dotnet pack src/Clawdforge/Clawdforge.csproj -c Release -o ./dist

That drops Clawdforge.0.2.0.nupkg (and .snupkg symbols) under ./dist/. Add it as a source on the consumer machine:

dotnet nuget add source /path/to/clawdforge/clients/csharp/dist \
  --name clawdforge-local
dotnet add package Clawdforge --version 0.2.0

Option B — project reference

For sibling checkouts inside the same monorepo:

<ItemGroup>
  <ProjectReference Include="../../path/to/clawdforge/clients/csharp/src/Clawdforge/Clawdforge.csproj" />
</ItemGroup>

Quickstart

using System.Text.Json;
using Clawdforge;
using Clawdforge.Models;

using var client = new ForgeClient(new ForgeOptions {
    BaseUrl = "http://192.168.0.5:8800",
    Token   = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN"),
});

// 60s wall-clock cap via cancellation, NOT HttpClient.Timeout.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));

var h = await client.HealthzAsync(cts.Token);
Console.WriteLine($"claude: {h.ClaudeVersion}");

var res = await client.RunAsync(new RunRequest {
    Prompt      = "Reply with JSON: {\"hello\": \"world\"}",
    Model       = "sonnet",
    TimeoutSecs = 60,
}, cts.Token);

Console.WriteLine($"duration: {res.DurationMs}ms");

// res.Result is JsonElement — inspect ValueKind, then narrow.
if (res.Result.ValueKind == JsonValueKind.Object) {
    Console.WriteLine(res.Result.GetProperty("hello").GetString());
}

Multi-turn / Sessions (v0.2)

For workflows that need context across turns (step-by-step builds, iterative debugging, long-running agent tasks) clawdforge v0.2 adds a /sessions/* surface backed by ACPX. Each session is a server-side handle with its own ledger row; turns against it are batched and returned as structured events.

The C# binding maps that to IAsyncDisposable — the canonical usage is await using so the session is closed (DELETE /sessions/{id}) the moment the scope exits, success or exception.

using Clawdforge;
using Clawdforge.Models;

await using var forge = new ForgeClient(new ForgeOptions {
    BaseUrl = "http://192.168.0.5:8800",
    Token   = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN"),
});

await using (var s = await forge.CreateSessionAsync(new CreateSessionOptions { Agent = "claude" }))
{
    TurnResult r1 = await s.TurnAsync("Read README.md and summarize");
    Console.WriteLine(r1.Text());

    TurnResult r2 = await s.TurnAsync("Now look at the auth flow", new TurnOptions {
        Files = new[] { "ff_xyz" },
        TimeoutSecs = 120,
    });

    foreach (var e in r2.Events)
    {
        if (e.Type == "tool_call") Console.WriteLine($"  tool: {e.Name}");
    }
}
// scope exit → DELETE /sessions/{id}

Lifecycle helpers

// Inspect or list sessions visible to the calling token (server enforces
// per-app isolation — token A cannot see token B's sessions; cross-token
// requests come back as ForgeApiException with StatusCode = 404).
SessionState state = await forge.GetSessionAsync("ses_abc");
IReadOnlyList<SessionState> all = await forge.ListSessionsAsync();

// Or fetch the live state from a Session you already hold:
var state2 = await s.StateAsync();

Idempotent close + transient rollback

Session.CloseAsync uses an Interlocked.CompareExchange flag, so calling it twice is safe — only the first call sends DELETE. If the DELETE itself fails (e.g. transient 5xx) the closed flag is rolled back so the caller can retry with a fresh CloseAsync call. DisposeAsync swallows close errors because the caller has already exited the using scope and there's no productive way to surface them.

var s = await forge.CreateSessionAsync();
try
{
    var r = await s.TurnAsync("...");
}
finally
{
    // Manual close path (instead of await using):
    await s.CloseAsync();   // sends DELETE
    await s.CloseAsync();   // no-op
}

Throws after close

Once a session is closed, TurnAsync throws InvalidOperationException synchronously (no HTTP round-trip):

await s.CloseAsync();
await s.TurnAsync("hi"); // -> InvalidOperationException("session ses_... is closed")

TurnResult.Text() helper

TurnResult.Events is the full structured stream — thinking, text, tool_call. For the common "just give me what the model said" path, Text() concatenates the Content of every text event:

var r = await s.TurnAsync("hi");
string answer = r.Text();           // "Hello! How can I help?"
int turnIdx   = r.TurnIndex;        // 0-based
long ms       = r.DurationMs;
string stop   = r.StopReason;       // "end_turn", "max_tokens", ...

Diagnostic redaction

Session.ToString() deliberately omits the embedded ForgeClient reference so a logged session can't surface the bearer token through field-walking. Output is Session(Id=..., Agent=..., Closed=...).

DI / IHttpClientFactory

Inject an external HttpClient so it shares the host's connection-pooling, retry, and proxy configuration:

services.AddHttpClient("clawdforge", c =>
{
    c.BaseAddress = new Uri("http://192.168.0.5:8800");
    c.Timeout = Timeout.InfiniteTimeSpan; // see "Timeouts" below
});

services.AddTransient<ForgeClient>(sp =>
{
    var http = sp.GetRequiredService<IHttpClientFactory>().CreateClient("clawdforge");
    return new ForgeClient(new ForgeOptions {
        BaseUrl = "http://192.168.0.5:8800",
        Token   = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN"),
    }, http);
});

When you pass an external HttpClient, ForgeClient does NOT dispose it.

API surface

The SDK mirrors the FastAPI surface in clawdforge/server.py 1:1.

new ForgeClient(ForgeOptions, HttpClient? = null)

ForgeOptions:

Property Type Notes
BaseUrl string Required. Trailing slash is trimmed.
Token string? Bearer token. Optional for /healthz.
HttpTimeout TimeSpan? Wall-clock cap on the SDK-owned HttpClient. Default: Infinite.

HealthzAsync(CancellationToken)

GET /healthzHealthStatus { Ok, ClaudePresent, ClaudeVersion }. Bearer token optional; the server only enforces the global IP allowlist on this endpoint.

RunAsync(RunRequest, CancellationToken)

POST /runRunResult. The Result field is a JsonElement because claude may return parsed JSON or plain text.

var res = await client.RunAsync(new RunRequest {
    Prompt      = "Sterilize: 'about 2 cups cooked white rice'",
    Model       = "sonnet",
    System      = "Reply ONLY with valid JSON.",
    Files       = new[] { "ff_abc123" },
    TimeoutSecs = 30,
});

RunResult helpers:

// Deserialize into a typed shape:
public record Ingredient(int Qty, string Unit, string Food);
var ing = res.AsJson<Ingredient>();

// Or pull a string regardless of underlying JSON kind:
string? text = res.AsText();

UploadFileAsync(string path, int ttlSecs = 0, CancellationToken)

POST /files — multipart upload from disk. Streams via StreamContent(FileStream); the file is not buffered into memory. ttlSecs == 0 uses the server default of 3600 (valid range 60..86400).

var ft = await client.UploadFileAsync("./recipe.png", ttlSecs: 3600);

await client.RunAsync(new RunRequest {
    Prompt = "extract recipe data",
    Files  = new[] { ft.Token },
});

UploadStreamAsync(Stream, string fileName, int ttlSecs = 0, CancellationToken)

Same as UploadFileAsync but takes any Stream, useful for in-memory blobs or piped data. The stream is disposed by the SDK once the request completes — this matches the standard HttpClient / MultipartFormDataContent / StreamContent chain (they all call Dispose() through to the underlying stream). If the caller needs to retain ownership of the stream, wrap it in a non-disposing adapter before passing it in.

CreateSessionAsync(CreateSessionOptions?, CancellationToken) (v0.2)

POST /sessionsSession handle (implements IAsyncDisposable). See Multi-turn / Sessions (v0.2) above.

GetSessionAsync(string id, CancellationToken) (v0.2)

GET /sessions/{id}SessionState. Cross-token access returns 404 ⇒ ForgeApiException(StatusCode = 404).

ListSessionsAsync(CancellationToken) (v0.2)

GET /sessionsIReadOnlyList<SessionState> for the calling token.

Admin (require admin bootstrap token)

using var admin = new ForgeClient(new ForgeOptions {
    BaseUrl = "http://192.168.0.5:8800",
    Token   = Environment.GetEnvironmentVariable("CLAWDFORGE_ADMIN_TOKEN"),
});

var t = await admin.CreateTokenAsync(new CreateTokenRequest {
    Name    = "petalparse",
    IpCidrs = new[] { "172.24.0.0/16" },
});
// t.Token is the plaintext — store it now, you can't see it again.

IReadOnlyList<AppToken> list = await admin.ListTokensAsync();

await admin.RevokeTokenAsync("petalparse");

Field naming — PascalCase ↔ snake_case

The clawdforge wire is snake_case (Python / Pydantic). The SDK uses PascalCase property names with [JsonPropertyName("...")] annotations to do the translation:

C# property Wire field
RunRequest.TimeoutSecs timeout_secs
RunResult.DurationMs duration_ms
RunResult.StopReason stop_reason
FileToken.Token file_token
FileToken.TtlSecs ttl_secs
AppToken.IpCidrs ip_cidrs
HealthStatus.ClaudePresent claude_present
HealthStatus.ClaudeVersion claude_version

null properties are omitted on the wire (configured at the serializer level), so optional fields behave correctly without manual checks.

Error handling

Three exception types, all derived from ForgeException:

Exception When
ForgeAuthException HTTP 401, 403 (subclass of ForgeApiException)
ForgeApiException Other HTTP >= 400 (incl. 502 /run failures)
ForgeTransportException DNS / connect / TLS / decode failures

Cancellation propagates as OperationCanceledException directly so callers can distinguish caller-cancellation from network failures.

try {
    var res = await client.RunAsync(req, ct);
}
catch (ForgeAuthException ex) {
    // 401 / 403 — bad bearer or IP not allowlisted.
}
catch (ForgeApiException ex) when (ex.StatusCode == 502) {
    // /run failure — clawdforge took the request but `claude` failed.
    // ex.Body has {"ok":false,"error":"...","stderr":"...","duration_ms":..., "stop_reason":"..."}
}
catch (ForgeApiException ex) {
    // Any other 4xx / 5xx.
}
catch (ForgeTransportException ex) {
    // DNS / connect refused / TLS / EOF / JSON decode.
}
catch (OperationCanceledException) {
    // Caller cancelled.
}

Timeouts

The SDK does NOT set a wall-clock timeout on its HttpClient by default — long claude runs (curate jobs, big recipe corpora) can legitimately take multiple minutes. Set per-call timeouts via cancellation:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSecs + 30));
var res = await client.RunAsync(new RunRequest { Prompt = "...", TimeoutSecs = timeoutSecs }, cts.Token);

The +30s margin over TimeoutSecs mirrors the upstream Python Forge client so the HTTP call doesn't bail while clawdforge is still working.

Build / test / pack

dotnet build -c Release
dotnet test -c Release
dotnet pack -c Release -o ./dist

The example app under examples/Basic/:

export CLAWDFORGE_URL=http://192.168.0.5:8800
export CLAWDFORGE_TOKEN=cf_...
dotnet run --project examples/Basic

Notes

  • The CLI wrapped server-side is @anthropic-ai/claude-code (not the Python anthropic SDK).
  • RunRequest.TimeoutSecs is omitted when null; server clamps to 5..600.
  • 502 from /run is just a ForgeApiException with StatusCode = 502. The /run failure body is preserved verbatim in ex.Body; deserialize with JsonSerializer if you want structured access.
  • HealthzAsync does not require a bearer token. The SDK still sends it if present — the server ignores it.
  • File uploads stream via StreamContent(FileStream) — the file body is not buffered into memory.
  • Transport security: the SDK does not enforce HTTPS — BaseUrl may be plain http:// (clawdforge is a LAN-only service in our deployment). For any non-localhost / non-LAN target, use HTTPS or tunnel the connection through WireGuard / a VPN. Bearer tokens travel in Authorization headers; cleartext over an untrusted hop is a token leak.
  • Runtime: .NET 8.0.10 or later is recommended. Two BCL CVEs (CVE-2024-30105, CVE-2024-43485) cover JSON DoS paths the SDK does not currently exercise (DeserializeAsyncEnumerable and JsonExtensionData), but pinning to a patched runtime is cheap belt-and-suspenders insurance.