clawdforge/clients/csharp
2026-04-28 22:53:09 -07:00
..
examples/Basic clients/csharp: initial C# SDK for clawdforge 2026-04-28 22:53:09 -07:00
src/Clawdforge clients/csharp: initial C# SDK for clawdforge 2026-04-28 22:53:09 -07:00
tests/Clawdforge.Tests clients/csharp: initial C# SDK for clawdforge 2026-04-28 22:53:09 -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: initial C# SDK for clawdforge 2026-04-28 22:53:09 -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.1.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.1.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());
}

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 not closed by the SDK.

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.