# clawdforge-client (Java) Java SDK for [clawdforge](../../README.md) — a LAN-only HTTP service that wraps `claude -p` subprocess calls behind a bearer-token-gated REST API. - **Java 17+** (records, sealed-friendly, switch expressions) - **HTTP**: JDK built-in `java.net.http.HttpClient` — no Apache HttpClient, no OkHttp - **JSON**: Jackson (`jackson-databind` 2.x) - **Build**: Maven - **License**: MIT ## Install This SDK isn't published to Maven Central. Build from source and install to your local Maven repo: ```sh git clone http://192.168.0.5:3001/Sulkta-Coop/clawdforge.git cd clawdforge/clients/java mvn install ``` Then depend on it: ```xml com.clawdforge clawdforge-client 0.1.0 ``` ## Quickstart ```java import com.clawdforge.ForgeClient; import com.clawdforge.RunRequest; import com.clawdforge.RunResult; import com.fasterxml.jackson.databind.JsonNode; try (ForgeClient client = ForgeClient.builder() .baseUrl("http://192.168.0.5:8800") .token(System.getenv("CLAWDFORGE_TOKEN")) .build()) { RunResult res = client.run(RunRequest.builder() .prompt("Reply with JSON: {\"hello\": \"world\"}") .model("sonnet") .timeoutSecs(60) .build()); System.out.println(res.durationMs() + "ms"); JsonNode r = res.result(); if (r.isObject()) { System.out.println(r.get("hello").asText()); } } ``` `ForgeClient` is immutable and safe to share across threads — for long-lived multi-tenant deployments, construct once and reuse, rather than relying on `close()`. ## Multi-turn / Sessions (v0.2) For conversations that need context across turns — debugging back and forth, step-by-step builds, long-running agent tasks — use the `/sessions` API. The canonical Java form is **try-with-resources**: the session is auto-closed on the way out of the block, even if the body throws. ```java import com.clawdforge.ForgeClient; import com.clawdforge.Session; import com.clawdforge.SessionOptions; import com.clawdforge.TurnResult; import java.util.List; try (ForgeClient forge = ForgeClient.builder() .baseUrl("http://192.168.0.5:8800") .token(System.getenv("CLAWDFORGE_TOKEN")) .build()) { try (Session s = forge.createSession(SessionOptions.builder() .agent("claude") .build())) { TurnResult r1 = s.turn("Read README.md and summarize"); System.out.println(r1.text()); TurnResult r2 = s.turnWithFiles( "Now look at the auth flow", List.of("ff_uploaded_token")); System.out.println(r2.text()); } // session is closed (DELETE /sessions/) here } ``` For the all-defaults case the no-arg overload is enough: ```java try (Session s = forge.createSession()) { TurnResult r = s.turn("hello"); } ``` ### `Session` | Method | Endpoint | Returns | |---|---|---| | `id()` | — | `String` (server-assigned id) | | `agent()` | — | `String` (e.g. `"claude"`) | | `createdAt()` | — | `long` (unix seconds) | | `isClosed()` | — | `boolean` (client's view) | | `turn(String prompt)` | `POST /sessions//turn` | `TurnResult` | | `turnWithFiles(String, List)` | `POST /sessions//turn` | `TurnResult` | | `turn(String, TurnOptions)` | `POST /sessions//turn` | `TurnResult` | | `state()` | `GET /sessions/` | `SessionState` | | `close()` | `DELETE /sessions/` | `void` | `Session` implements `AutoCloseable` so try-with-resources just works. ### Idempotent close `Session.close()` is idempotent on the client: a second call short- circuits without an HTTP round-trip via an `AtomicBoolean` `compareAndSet`. The server's `DELETE /sessions/` is itself idempotent (returns `already_closed: true` on a re-close), so even a mix of explicit `close()` calls and try-with-resources auto-close is safe — there's at most one DELETE on the wire, and it's cheap if duplicated. If the DELETE fails on the first attempt the closed flag is cleared, so a subsequent `close()` retries. ### Listing & inspecting sessions ```java List sessions = forge.listSessions(); SessionState st = forge.getSession(sessions.get(0).sessionId()); System.out.println(st.turnCount() + " turns, last at " + st.lastTurnAt()); ``` A `404` from `getSession()` surfaces as `ApiException` with status `404` — note the server intentionally returns 404 (not 403) for a session belonging to a different token, to avoid leaking cross-token session existence. ### `Session.toString()` redaction `Session.toString()` deliberately omits the embedded `ForgeClient` reference (which holds the bearer token), mirroring the `AppToken.toString()` redaction. A typical render: ``` Session{id=sess_abc123, agent=claude, closed=false} ``` You can stuff a `Session` into a log line without leaking the bearer. ### Records you'll see ```java record TurnEvent(String type, String content, String name, Map args, Object result) {} record TurnResult(boolean ok, String sessionId, int turnIndex, List events, String stopReason, long durationMs) { public String text() { /* concatenates all "text" events */ } } record SessionState(String sessionId, String agent, String appName, long createdAt, Long lastTurnAt, int turnCount, Long closedAt) {} ``` `TurnResult.text()` is sugar for the common case — concatenates every `"text"` event's content in order. Callers who need the structured event stream (thinking, tool_call, etc.) iterate `events()` directly. ## Naming & wire format The clawdforge HTTP API uses **`snake_case`** on the wire (`timeout_secs`, `file_token`, `created_at`, ...). This SDK uses idiomatic Java **`camelCase`** everywhere; Jackson `@JsonProperty` annotations on each record handle the mapping. Examples: | Java accessor | Wire field | |---------------------------|--------------------| | `RunRequest#timeoutSecs` | `timeout_secs` | | `RunResult#durationMs` | `duration_ms` | | `RunResult#stopReason` | `stop_reason` | | `FileToken#fileToken` | `file_token` | | `FileToken#ttlSecs` | `ttl_secs` | | `HealthStatus#claudePresent` | `claude_present` | | `HealthStatus#claudeVersion` | `claude_version` | | `AppToken#ipCidrs` | `ip_cidrs` | | `AppToken#createdAt` | `created_at` | ## Public surface ### `ForgeClient` Build via `ForgeClient.builder()`: ```java ForgeClient client = ForgeClient.builder() .baseUrl("http://192.168.0.5:8800") // trailing slash trimmed .token("cf_...") // required bearer token .defaultTimeout(Duration.ofSeconds(120)) // optional, default 120s .httpClient(myCustomHttpClient) // optional, default is JDK default .build(); ``` Methods (all may throw subclasses of `ForgeException`): | Method | Endpoint | Returns | |---|---|---| | `healthz()` | `GET /healthz` | `HealthStatus` | | `run(RunRequest)` | `POST /run` | `RunResult` | | `uploadFile(Path, int ttlSecs)` | `POST /files` | `FileToken` | | `createToken(String name, List ipCidrs)` | `POST /admin/tokens` | `AppToken` | | `listTokens()` | `GET /admin/tokens` | `List` | | `revokeToken(String name)` | `DELETE /admin/tokens/` | `void` | | `createSession()` / `createSession(SessionOptions)` | `POST /sessions` | `Session` | | `listSessions()` | `GET /sessions` | `List` | | `getSession(String id)` | `GET /sessions/` | `SessionState` | ### `RunRequest` Build via `RunRequest.builder()`: ```java RunRequest req = RunRequest.builder() .prompt("...") // required .model("sonnet") // optional, server default = "sonnet" .system("You are ...") // optional system prompt .files(List.of("ff_...")) // optional file tokens from uploadFile() .timeoutSecs(60) // optional, server clamps to [5, 600] .build(); ``` ### Records Public state is exposed via Java 17 records: - `HealthStatus(boolean ok, boolean claudePresent, String claudeVersion)` - `RunResult(boolean ok, JsonNode result, long durationMs, String stopReason)` - `FileToken(String fileToken, int ttlSecs, long size)` - `AppToken(String name, String token, List ipCidrs, long createdAt)` v0.2 session types: - `TurnEvent(String type, String content, String name, Map args, Object result)` - `TurnResult(boolean ok, String sessionId, int turnIndex, List events, String stopReason, long durationMs)` - `SessionState(String sessionId, String agent, String appName, long createdAt, Long lastTurnAt, int turnCount, Long closedAt)` `Session` is a final class (not a record) so its `toString()` can omit the embedded `ForgeClient` reference and avoid leaking the bearer token. `RunResult.result()` is a Jackson `JsonNode` because the server may return either a structured JSON value (when the prompt asked for JSON) or a plain JSON string. Narrow with `isObject()` / `isTextual()` / etc. ## Error model — why unchecked? All SDK errors extend **`ForgeException`**, which extends `RuntimeException`. Checked exceptions feel un-modern in Java 17 — they bleed across API boundaries, force `throws` chains in lambdas, and don't pair well with the records / sealed-interface idioms the rest of this SDK leans on. ``` ForgeException (RuntimeException) ├── ApiException (any non-2xx HTTP) │ └── AuthException (401 / 403) └── TransportException (DNS, connect, IO, decode, timeout) ``` Catch order: ```java try { client.run(req); } catch (AuthException e) { // token revoked, IP not allowed } catch (ApiException e) { // 502 from /run failure, 404 from missing token, etc. // e.statusCode() and e.body() are both available } catch (TransportException e) { // network / IO / JSON-decode failures } catch (ForgeException e) { // catch-all for anything else SDK-level } ``` ## File uploads `uploadFile` streams the file body through `HttpRequest.BodyPublishers.ofInputStream` chained over a `SequenceInputStream` (multipart preamble + `Files.newInputStream(path)` + trailing boundary). The file body is never fully resident in heap — peak upload memory is bounded by the `HttpClient` read-ahead buffer, so a multi-GiB upload won't OOM a default-heap JVM: ```java FileToken ft = client.uploadFile(Path.of("./recipe.png"), 3600); client.run(RunRequest.builder() .prompt("Extract recipe data from the attached image.") .files(List.of(ft.fileToken())) .build()); ``` `ttlSecs` is clamped server-side to `[60, 86400]`. Pass `0` to use the server default of 3600. > **Path safety.** `uploadFile` does not constrain its `Path` argument to > any sandbox root, and `Files.isRegularFile` follows symlinks by default. > Callers that pass user-supplied paths must canonicalise and sandbox them > first (e.g. resolve under a known directory and reject any `Path` whose > `toRealPath()` escapes it). ## Lifecycle `ForgeClient` implements `AutoCloseable` for try-with-resources symmetry: ```java try (ForgeClient client = ForgeClient.builder() .baseUrl("http://192.168.0.5:8800") .token(System.getenv("CLAWDFORGE_TOKEN")) .build()) { client.healthz(); } ``` On JDK 17 `HttpClient` has no `close()` (added in JDK 21), so the SDK's `close()` is currently a no-op — the underlying client is reclaimed when its last reference goes out of scope. For long-lived multi-tenant deployments, prefer constructing a single shared `HttpClient` (injected via `Builder.httpClient(HttpClient)`) and reusing it across `ForgeClient` instances. ## Build ```sh mvn package # produces target/clawdforge-client-0.1.0.jar mvn test # runs the JUnit 5 suite mvn javadoc:javadoc ``` The test suite uses the JDK built-in `com.sun.net.httpserver.HttpServer` in-process — no external mock-server dependency. ## Threading `ForgeClient` instances are immutable and safe to share across threads. Construct once, reuse everywhere.