- 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
424 lines
14 KiB
Markdown
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
|