- 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
358 lines
12 KiB
Markdown
358 lines
12 KiB
Markdown
# 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
|
|
<dependency>
|
|
<groupId>com.clawdforge</groupId>
|
|
<artifactId>clawdforge-client</artifactId>
|
|
<version>0.1.0</version>
|
|
</dependency>
|
|
```
|
|
|
|
## 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/<id>) 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/<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
|
|
|
|
```java
|
|
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
|
|
|
|
```java
|
|
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()`:
|
|
|
|
```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<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()`:
|
|
|
|
```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<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:
|
|
|
|
```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.
|