clawdforge/clients/csharp/README.md
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

424 lines
14 KiB
Markdown

# 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``IHttpClientFactory` 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:
```sh
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:
```sh
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:
```xml
<ItemGroup>
<ProjectReference Include="../../path/to/clawdforge/clients/csharp/src/Clawdforge/Clawdforge.csproj" />
</ItemGroup>
```
## Quickstart
```csharp
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][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.
```csharp
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
```csharp
// 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.
```csharp
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):
```csharp
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:
```csharp
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=...)`.
[acpx]: https://github.com/openclaw/acpx
## DI / `IHttpClientFactory`
Inject an external `HttpClient` so it shares the host's
connection-pooling, retry, and proxy configuration:
```csharp
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.
```csharp
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:
```csharp
// 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).
```csharp
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)](#multi-turn--sessions-v02) 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)
```csharp
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.
```csharp
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:
```csharp
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
```sh
dotnet build -c Release
dotnet test -c Release
dotnet pack -c Release -o ./dist
```
The example app under `examples/Basic/`:
```sh
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][cve1], [CVE-2024-43485][cve2]) 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.
[cve1]: https://github.com/dotnet/announcements/issues/322
[cve2]: https://github.com/dotnet/announcements/issues/325