# 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` 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 ``` ## 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 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(sp => { var http = sp.GetRequiredService().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(); // 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` 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 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