clawdforge/clients/java
Kayos 33b9ed5e22 clients/java: v0.2 multi-turn Session API
- Session implements AutoCloseable; try-with-resources is the canonical form
- AtomicBoolean compareAndSet idempotency on close()
- ForgeClient.createSession / .listSessions / .getSession
- TurnResult.text() helper, records throughout for shapes
- Session.toString redacts embedded client (no bearer leak)
- SessionTest.java: 13 tests covering try-with-resources/idempotency/exception/list/state/redaction/regression
- README "Multi-turn / Sessions (v0.2)" section

v0.1 surface unchanged.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
2026-04-29 06:50:02 -07:00
..
examples clients/java: apply audit findings — true streaming upload + token redaction (0d3ee26 → next) 2026-04-28 23:20:45 -07:00
src clients/java: v0.2 multi-turn Session API 2026-04-29 06:50:02 -07:00
pom.xml clients/java: apply audit findings — true streaming upload + token redaction (0d3ee26 → next) 2026-04-28 23:20:45 -07:00
README.md clients/java: v0.2 multi-turn Session API 2026-04-29 06:50:02 -07:00

clawdforge-client (Java)

Java SDK for clawdforge — 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:

git clone http://192.168.0.5:3001/Sulkta-Coop/clawdforge.git
cd clawdforge/clients/java
mvn install

Then depend on it:

<dependency>
    <groupId>com.clawdforge</groupId>
    <artifactId>clawdforge-client</artifactId>
    <version>0.1.0</version>
</dependency>

Quickstart

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.

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/<id>) here
}

For the all-defaults case the no-arg overload is enough:

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/<id>/turn TurnResult
turnWithFiles(String, List<String>) POST /sessions/<id>/turn TurnResult
turn(String, TurnOptions) POST /sessions/<id>/turn TurnResult
state() GET /sessions/<id> SessionState
close() DELETE /sessions/<id> 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/<id> 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

List<SessionState> 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

record TurnEvent(String type, String content, String name,
                 Map<String, Object> args, Object result) {}

record TurnResult(boolean ok, String sessionId, int turnIndex,
                  List<TurnEvent> 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():

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<String> ipCidrs) POST /admin/tokens AppToken
listTokens() GET /admin/tokens List<AppToken>
revokeToken(String name) DELETE /admin/tokens/<name> void
createSession() / createSession(SessionOptions) POST /sessions Session
listSessions() GET /sessions List<SessionState>
getSession(String id) GET /sessions/<id> SessionState

RunRequest

Build via RunRequest.builder():

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<String> ipCidrs, long createdAt)

v0.2 session types:

  • TurnEvent(String type, String content, String name, Map<String, Object> args, Object result)
  • TurnResult(boolean ok, String sessionId, int turnIndex, List<TurnEvent> 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:

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:

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:

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

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.