- 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:
|
||
|---|---|---|
| .. | ||
| examples/Basic | ||
| src/Clawdforge | ||
| tests/Clawdforge.Tests | ||
| .gitignore | ||
| Clawdforge.sln | ||
| README.md | ||
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.HttpClient—IHttpClientFactoryfriendly. - JSON:
System.Text.Json(no Newtonsoft). - Async: every I/O method returns
Task<T>and accepts aCancellationToken. 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 /healthz → HealthStatus { Ok, ClaudePresent, ClaudeVersion }.
Bearer token optional; the server only enforces the global IP allowlist
on this endpoint.
RunAsync(RunRequest, CancellationToken)
POST /run → RunResult. 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 /sessions → Session 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 /sessions → IReadOnlyList<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 PythonanthropicSDK). RunRequest.TimeoutSecsis omitted whennull; server clamps to5..600.502from/runis just aForgeApiExceptionwithStatusCode = 502. The/runfailure body is preserved verbatim inex.Body; deserialize withJsonSerializerif you want structured access.HealthzAsyncdoes 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 —
BaseUrlmay be plainhttp://(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 inAuthorizationheaders; cleartext over an untrusted hop is a token leak. - Runtime: .NET
8.0.10or later is recommended. Two BCL CVEs (CVE-2024-30105, CVE-2024-43485) cover JSON DoS paths the SDK does not currently exercise (DeserializeAsyncEnumerableandJsonExtensionData), but pinning to a patched runtime is cheap belt-and-suspenders insurance.