- 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:
|
||
|---|---|---|
| .. | ||
| examples | ||
| src | ||
| pom.xml | ||
| README.md | ||
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-databind2.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.
uploadFiledoes not constrain itsPathargument to any sandbox root, andFiles.isRegularFilefollows symlinks by default. Callers that pass user-supplied paths must canonicalise and sandbox them first (e.g. resolve under a known directory and reject anyPathwhosetoRealPath()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.