The plaintext is shown once at create time — the server
+ * stores only a SHA-256 hash, so a lost plaintext means revoking and minting
+ * a new one.
+ *
+ * @param name app-level identifier, e.g. {@code "cauldron"}
+ * @param token plaintext token, populated only on create; {@code null} on list
+ * @param ipCidrs per-app IP allowlist, may be empty (global allowlist still applies)
+ * @param createdAt unix-second creation timestamp; 0 on create response
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record AppToken(
+ @JsonProperty("name") String name,
+ @JsonProperty("token") String token,
+ @JsonProperty("ip_cidrs") List The {@code fileToken} value is a server-issued opaque handle (prefixed
+ * {@code ff_}) that may be passed in {@link RunRequest#files()} on subsequent
+ * {@code /run} calls. File tokens are scoped to the uploading app — token A
+ * cannot reference token B's files.
+ *
+ * @param fileToken server-issued opaque handle, prefixed {@code ff_}
+ * @param ttlSecs time-to-live in seconds (clamped server-side to [60, 86400])
+ * @param size size of the staged file in bytes
+ */
+public record FileToken(
+ @JsonProperty("file_token") String fileToken,
+ @JsonProperty("ttl_secs") int ttlSecs,
+ @JsonProperty("size") long size) {
+
+ /**
+ * Jackson-friendly canonical constructor.
+ */
+ @JsonCreator
+ public FileToken {
+ }
+}
diff --git a/clients/java/src/main/java/com/clawdforge/ForgeClient.java b/clients/java/src/main/java/com/clawdforge/ForgeClient.java
new file mode 100644
index 0000000..1c004b5
--- /dev/null
+++ b/clients/java/src/main/java/com/clawdforge/ForgeClient.java
@@ -0,0 +1,472 @@
+package com.clawdforge;
+
+import com.clawdforge.exception.ApiException;
+import com.clawdforge.exception.AuthException;
+import com.clawdforge.exception.ForgeException;
+import com.clawdforge.exception.TransportException;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpRequest.BodyPublisher;
+import java.net.http.HttpRequest.BodyPublishers;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * Java SDK client for the LAN-only clawdforge HTTP service.
+ *
+ * Construct via {@link #builder()}; instances are immutable and safe for
+ * concurrent use.
+ *
+ * Wire format note. The clawdforge API uses
+ * {@code snake_case} on the wire (e.g. {@code timeout_secs}, {@code file_token}).
+ * This SDK uses idiomatic Java {@code camelCase} everywhere; the snake_case
+ * mapping is handled by Jackson {@code @JsonProperty} annotations on each
+ * record.
+ *
+ * Example:
+ * Note on timeouts: {@code body.timeoutSecs()} controls the upstream
+ * subprocess timeout. The HTTP-level timeout adds a 30 second margin over
+ * that so this client doesn't bail while the server is still doing useful
+ * work. If {@code body.timeoutSecs()} is {@code null}, the configured
+ * default is used.
+ *
+ * @param body run request (must be non-null; {@code prompt} is required)
+ * @return parsed run result on HTTP 200
+ * @throws AuthException on HTTP 401/403
+ * @throws ApiException on HTTP 502 (run failure) or other non-2xx
+ * @throws TransportException on transport / decoding failure
+ */
+ public RunResult run(RunRequest body) {
+ Objects.requireNonNull(body, "body");
+ byte[] payload = serialize(body);
+ Duration httpTimeout = httpTimeoutFor(body.timeoutSecs());
+ HttpRequest req = newRequest(
+ "/run",
+ "POST",
+ BodyPublishers.ofByteArray(payload),
+ "application/json",
+ httpTimeout);
+ return send(req, RunResult.class);
+ }
+
+ /**
+ * Streams a file from disk to {@code POST /files} as
+ * {@code multipart/form-data} and returns the resulting file token.
+ *
+ * The file is read from disk in chunks via
+ * {@link BodyPublishers#ofByteArrays} rather than slurped fully into
+ * memory.
+ *
+ * @param path local file to upload
+ * @param ttlSecs time-to-live in seconds; server clamps to [60, 86400].
+ * Pass {@code 0} to use the server default of 3600.
+ * @return parsed file-token response
+ * @throws AuthException on HTTP 401/403
+ * @throws ApiException on any other non-2xx response
+ * @throws TransportException on transport / IO / decoding failure
+ */
+ public FileToken uploadFile(Path path, int ttlSecs) {
+ Objects.requireNonNull(path, "path");
+ if (!Files.isRegularFile(path)) {
+ throw new TransportException("not a regular file: " + path);
+ }
+ String filename = path.getFileName().toString();
+ String boundary = generateBoundary();
+
+ BodyPublisher publisher;
+ try {
+ publisher = multipartFromFile(boundary, path, filename, ttlSecs);
+ } catch (IOException e) {
+ throw new TransportException("read " + path, e);
+ }
+
+ HttpRequest req = newRequest(
+ "/files",
+ "POST",
+ publisher,
+ "multipart/form-data; boundary=" + boundary,
+ defaultTimeout);
+ return send(req, FileToken.class);
+ }
+
+ /**
+ * Mints a new per-app token. Plaintext is in
+ * {@link AppToken#token()} and will not be retrievable again.
+ *
+ * @param name short slug (lowercase alphanumerics, dash/underscore)
+ * @param ipCidrs optional per-app IP allowlist; may be empty/null
+ * @return the freshly-minted token (with {@code token} populated)
+ * @throws AuthException if not using the admin bootstrap token
+ * @throws ApiException on any other non-2xx
+ * @throws TransportException on transport / decoding failure
+ */
+ public AppToken createToken(String name, List Wire shape (snake_case) is mapped to camelCase accessors via Jackson
+ * annotations on the canonical constructor.
+ *
+ * @param ok server liveness flag
+ * @param claudePresent whether the {@code claude} binary was found on PATH
+ * @param claudeVersion first line of {@code claude --version}, or {@code null}
+ * if the binary was missing or errored
+ */
+public record HealthStatus(
+ @JsonProperty("ok") boolean ok,
+ @JsonProperty("claude_present") boolean claudePresent,
+ @JsonProperty("claude_version") String claudeVersion) {
+
+ /**
+ * Jackson-friendly canonical constructor. Explicit annotation makes the
+ * mapping unambiguous when records are deserialized via Jackson.
+ */
+ @JsonCreator
+ public HealthStatus {
+ }
+}
diff --git a/clients/java/src/main/java/com/clawdforge/RunRequest.java b/clients/java/src/main/java/com/clawdforge/RunRequest.java
new file mode 100644
index 0000000..732cf50
--- /dev/null
+++ b/clients/java/src/main/java/com/clawdforge/RunRequest.java
@@ -0,0 +1,124 @@
+package com.clawdforge;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Body for {@code POST /run}. Build via {@link #builder()}.
+ *
+ * Wire mapping uses snake_case via {@link JsonProperty}; null/zero-value
+ * optional fields are dropped via {@link JsonInclude}.
+ *
+ * Field semantics:
+ * {@code result} is exposed as a Jackson {@link JsonNode} because the
+ * upstream may return either a structured JSON value (object/array/etc) when
+ * the prompt asked for JSON, or a plain JSON string. Callers narrow:
+ *
+ * For 502 responses to {@code /run}, the body usually carries the
+ * upstream failure shape: {@code error}, {@code stderr}, {@code duration_ms},
+ * {@code stop_reason}. Callers that need those fields can parse {@link #body()}
+ * with their preferred JSON mapper.
+ */
+public class ApiException extends ForgeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final int statusCode;
+ private final String body;
+
+ /**
+ * @param statusCode HTTP status
+ * @param message short human-readable summary (often pulled from the
+ * body's {@code error} / {@code detail} field)
+ * @param body raw response body (truncated to a sane size)
+ */
+ public ApiException(int statusCode, String message, String body) {
+ super(formatMessage(statusCode, message, body));
+ this.statusCode = statusCode;
+ this.body = body;
+ }
+
+ /**
+ * @return the HTTP status code from the failing response
+ */
+ public int statusCode() {
+ return statusCode;
+ }
+
+ /**
+ * @return the raw (possibly truncated) response body, or {@code ""} if empty
+ */
+ public String body() {
+ return body == null ? "" : body;
+ }
+
+ private static String formatMessage(int statusCode, String message, String body) {
+ if (message != null && !message.isBlank()) {
+ return "clawdforge: HTTP " + statusCode + ": " + message;
+ }
+ if (body != null && !body.isBlank()) {
+ return "clawdforge: HTTP " + statusCode + ": " + body;
+ }
+ return "clawdforge: HTTP " + statusCode;
+ }
+}
diff --git a/clients/java/src/main/java/com/clawdforge/exception/AuthException.java b/clients/java/src/main/java/com/clawdforge/exception/AuthException.java
new file mode 100644
index 0000000..d4f63ca
--- /dev/null
+++ b/clients/java/src/main/java/com/clawdforge/exception/AuthException.java
@@ -0,0 +1,19 @@
+package com.clawdforge.exception;
+
+/**
+ * Thrown for HTTP 401 / 403 responses from clawdforge — the bearer token is
+ * missing, malformed, revoked, or the caller's IP is not on the allowlist.
+ */
+public class AuthException extends ApiException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * @param statusCode HTTP status (401 or 403)
+ * @param message short human-readable summary
+ * @param body raw response body (truncated)
+ */
+ public AuthException(int statusCode, String message, String body) {
+ super(statusCode, message, body);
+ }
+}
diff --git a/clients/java/src/main/java/com/clawdforge/exception/ForgeException.java b/clients/java/src/main/java/com/clawdforge/exception/ForgeException.java
new file mode 100644
index 0000000..0f73968
--- /dev/null
+++ b/clients/java/src/main/java/com/clawdforge/exception/ForgeException.java
@@ -0,0 +1,45 @@
+package com.clawdforge.exception;
+
+/**
+ * Base unchecked exception for all clawdforge SDK failures.
+ *
+ * Why unchecked? Checked exceptions feel un-modern in
+ * Java 17 — they bleed across API boundaries, force {@code throws} chains in
+ * lambdas, and don't pair well with the records / sealed-interface idioms
+ * the rest of this SDK leans on. All clawdforge errors extend this class so
+ * a single {@code catch (ForgeException e)} catches everything; subclasses
+ * let callers narrow when they care:
+ *
+ * The wrapped cause is preserved via {@link Throwable#getCause()} so
+ * callers can branch on, e.g., {@link java.net.http.HttpTimeoutException} or
+ * {@link java.net.ConnectException}.
+ */
+public class TransportException extends ForgeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * @param message human-readable description (typically the operation,
+ * e.g. {@code "POST /run"})
+ * @param cause underlying network/IO/decoding cause
+ */
+ public TransportException(String message, Throwable cause) {
+ super("clawdforge: transport: " + message, cause);
+ }
+
+ /**
+ * @param message human-readable description
+ */
+ public TransportException(String message) {
+ super("clawdforge: transport: " + message);
+ }
+}
diff --git a/clients/java/src/test/java/com/clawdforge/ForgeClientTest.java b/clients/java/src/test/java/com/clawdforge/ForgeClientTest.java
new file mode 100644
index 0000000..132e298
--- /dev/null
+++ b/clients/java/src/test/java/com/clawdforge/ForgeClientTest.java
@@ -0,0 +1,314 @@
+package com.clawdforge;
+
+import com.clawdforge.exception.ApiException;
+import com.clawdforge.exception.AuthException;
+import com.clawdforge.exception.TransportException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * End-to-end tests against an in-process {@link HttpServer}. No external
+ * mock-server dependency — JDK's {@code com.sun.net.httpserver} is enough
+ * to exercise headers, status codes, JSON bodies, and multipart uploads.
+ */
+class ForgeClientTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private HttpServer server;
+ private String baseUrl;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.start();
+ baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ private ForgeClient client(String token) {
+ return ForgeClient.builder().baseUrl(baseUrl).token(token).build();
+ }
+
+ private void route(String path, HttpHandler h) {
+ server.createContext(path, h);
+ }
+
+ private static void respond(HttpExchange ex, int status, String body) throws IOException {
+ byte[] payload = body.getBytes(StandardCharsets.UTF_8);
+ ex.getResponseHeaders().add("Content-Type", "application/json");
+ ex.sendResponseHeaders(status, payload.length);
+ ex.getResponseBody().write(payload);
+ ex.close();
+ }
+
+ private static String readBody(HttpExchange ex) throws IOException {
+ return new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ }
+
+ private static byte[] readBytes(HttpExchange ex) throws IOException {
+ return ex.getRequestBody().readAllBytes();
+ }
+
+ // ----- tests -------------------------------------------------------------
+
+ @Test
+ @DisplayName("healthz: parses ok + claude_version + claude_present")
+ void healthzParses() {
+ route("/healthz", ex -> respond(ex, 200,
+ "{\"ok\":true,\"claude_present\":true,\"claude_version\":\"1.2.3 (sonnet)\"}"));
+ HealthStatus h = client("cf_test").healthz();
+ assertTrue(h.ok());
+ assertTrue(h.claudePresent());
+ assertEquals("1.2.3 (sonnet)", h.claudeVersion());
+ }
+
+ @Test
+ @DisplayName("healthz: sends Bearer token + Accept header")
+ void healthzSendsHeaders() {
+ AtomicReference{@code
+ * 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());
+ * }
+ */
+public final class ForgeClient {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private final String baseUrl;
+ private final String token;
+ private final HttpClient httpClient;
+ private final Duration defaultTimeout;
+
+ private ForgeClient(Builder b) {
+ this.baseUrl = stripTrailingSlash(Objects.requireNonNull(b.baseUrl, "baseUrl"));
+ this.token = Objects.requireNonNull(b.token, "token");
+ this.defaultTimeout = b.defaultTimeout == null ? Duration.ofSeconds(120) : b.defaultTimeout;
+ this.httpClient = b.httpClient != null
+ ? b.httpClient
+ : HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
+ }
+
+ /**
+ * @return a fresh builder for {@link ForgeClient}
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ // ---------- public API --------------------------------------------------
+
+ /**
+ * Issues {@code GET /healthz}. The bearer token is sent but not strictly
+ * required by the server; the caller's IP must satisfy the global
+ * allowlist regardless.
+ *
+ * @return parsed health status
+ * @throws AuthException on HTTP 401/403
+ * @throws ApiException on any other non-2xx response
+ * @throws TransportException on transport / decoding failure
+ */
+ public HealthStatus healthz() {
+ HttpRequest req = newRequest("/healthz", "GET", BodyPublishers.noBody(), null);
+ return send(req, HealthStatus.class);
+ }
+
+ /**
+ * Issues {@code POST /run}.
+ *
+ *
+ *
+ *
+ * @param prompt required prompt text (non-empty)
+ * @param model optional model name, e.g. {@code "sonnet"}, {@code "haiku"}
+ * @param system optional system prompt prepended to the run
+ * @param files optional list of file tokens to attach
+ * @param timeoutSecs optional subprocess timeout in seconds (5..600)
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record RunRequest(
+ @JsonProperty("prompt") String prompt,
+ @JsonProperty("model") String model,
+ @JsonProperty("system") String system,
+ @JsonProperty("files") List{@code
+ * RunResult res = client.run(req);
+ * JsonNode r = res.result();
+ * if (r.isObject() && r.has("hello")) {
+ * System.out.println(r.get("hello").asText());
+ * } else if (r.isTextual()) {
+ * System.out.println(r.asText());
+ * }
+ * }
+ *
+ * @param ok always {@code true} on the success path
+ * @param result the parsed result, never {@code null}
+ * (use {@link JsonNode#isMissingNode()} on absence)
+ * @param durationMs subprocess wall-clock duration in milliseconds
+ * @param stopReason claude's reported stop reason, e.g. {@code "end_turn"}
+ */
+public record RunResult(
+ @JsonProperty("ok") boolean ok,
+ @JsonProperty("result") JsonNode result,
+ @JsonProperty("duration_ms") long durationMs,
+ @JsonProperty("stop_reason") String stopReason) {
+
+ /**
+ * Jackson-friendly canonical constructor.
+ */
+ @JsonCreator
+ public RunResult {
+ }
+}
diff --git a/clients/java/src/main/java/com/clawdforge/exception/ApiException.java b/clients/java/src/main/java/com/clawdforge/exception/ApiException.java
new file mode 100644
index 0000000..8ebd164
--- /dev/null
+++ b/clients/java/src/main/java/com/clawdforge/exception/ApiException.java
@@ -0,0 +1,54 @@
+package com.clawdforge.exception;
+
+/**
+ * Thrown for any non-2xx HTTP response from clawdforge that isn't a
+ * transport-layer error.
+ *
+ * {@code
+ * try {
+ * client.run(req);
+ * } catch (AuthException e) {
+ * // 401/403 — token revoked, IP not allowed
+ * } catch (ApiException e) {
+ * // any other non-2xx, including 502 run failures
+ * } catch (TransportException e) {
+ * // DNS, connect-refused, TLS, timeout, IO
+ * } catch (ForgeException e) {
+ * // catch-all for SDK-level failures (e.g. JSON decode)
+ * }
+ * }
+ */
+public class ForgeException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * @param message human-readable description
+ */
+ public ForgeException(String message) {
+ super(message);
+ }
+
+ /**
+ * @param message human-readable description
+ * @param cause underlying cause
+ */
+ public ForgeException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/clients/java/src/main/java/com/clawdforge/exception/TransportException.java b/clients/java/src/main/java/com/clawdforge/exception/TransportException.java
new file mode 100644
index 0000000..ccca544
--- /dev/null
+++ b/clients/java/src/main/java/com/clawdforge/exception/TransportException.java
@@ -0,0 +1,31 @@
+package com.clawdforge.exception;
+
+/**
+ * Thrown for low-level transport failures: DNS, connection refused, TLS
+ * handshake, request-build, IO mid-stream, request timeout, JSON decode of
+ * a 2xx response, etc.
+ *
+ *