- Session : Closeable; AtomicBoolean idempotent close (rollback on transient)
- forge.session(opts) { s -> ... } block helper preferred
- ForgeClient.createSession / listSessions / getSession
- Per-call HTTP timeout on /sessions/{id}/turn (audit-fix
|
||
|---|---|---|
| .. | ||
| examples/main/kotlin | ||
| gradle/wrapper | ||
| src | ||
| .gitignore | ||
| build.gradle.kts | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| README.md | ||
| settings.gradle.kts | ||
clawdforge-kotlin
Async Kotlin client for the clawdforge HTTP service — a
LAN-only bearer-token-gated REST wrapper around claude -p subprocess calls.
- Kotlin 1.9+, JVM 17 target
- Coroutines — every I/O method is
suspend. No blocking variants. - Ktor Client (CIO engine) for HTTP, kotlinx.serialization for JSON
ForgeClientisCloseableand works insideuse { }
Install
The jar is published to your local Maven repo; consume it from any JVM build.
./gradlew publishToMavenLocal
Then in the consumer's build.gradle.kts:
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
implementation("com.clawdforge:clawdforge:0.2.0")
}
You'll also pull Ktor + kotlinx-serialization transitively (they're on the
public API surface — RunResult.result is a JsonElement).
Quickstart
import com.clawdforge.ForgeClient
import com.clawdforge.RunRequest
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.nio.file.Path
suspend fun main() {
ForgeClient(
baseUrl = "http://localhost:8800",
token = System.getenv("CLAWDFORGE_TOKEN"),
).use { client ->
val h = client.healthz()
println(h.claudeVersion)
val res = client.run(RunRequest(
prompt = """Reply with JSON: {"hello": "world"}""",
model = "sonnet",
timeoutSecs = 60,
))
println("duration: ${res.durationMs}ms")
println(res.result.jsonObject["hello"]?.jsonPrimitive?.content)
// Upload + reference a file
val ft = client.uploadFile(Path.of("./recipe.png"), ttlSecs = 3600)
client.run(RunRequest(
prompt = "Extract recipe data from the attached image.",
files = listOf(ft.fileToken),
))
}
}
Multi-turn / Sessions (v0.2)
v0.2 adds a parallel /sessions/* surface for multi-turn conversations.
v0.1's /run still works exactly as before — sessions are purely additive.
The preferred entry point is the session { … } block helper, which
auto-closes the server-side session on block exit, even on throw:
import com.clawdforge.ForgeClient
import com.clawdforge.SessionOptions
suspend fun main() {
val forge = ForgeClient("http://localhost:8800", System.getenv("CLAWDFORGE_TOKEN"))
forge.use {
forge.session(SessionOptions(agent = "claude")) { s ->
val r1 = s.turn("Read README.md and summarize")
println(r1.text())
val r2 = s.turn("Now look at the auth flow", files = listOf("ff_xyz"))
println(r2.text())
}
// session is closed here; DELETE /sessions/{id} has fired
}
}
Session implements java.io.Closeable, so it also composes with use:
forge.createSession().use { s ->
val r = s.turn("hello")
println(r.text())
} // auto-closed
For long-lived handles you can drive the lifecycle yourself:
val s = forge.createSession(SessionOptions(agent = "claude"))
try {
val r = s.turn("hello")
} finally {
s.close()
}
Listing and inspection:
val sessions: List<SessionState> = forge.listSessions()
val state: SessionState = forge.getSession(s.id)
Behaviours worth flagging
- Idempotent close.
Session.close()only firesDELETE /sessions/{id}the first time it's called; later invocations short-circuit on an internalAtomicBoolean. If the DELETE fails on the first attempt the flag is rolled back so a transient transport blip can be retried. - Concurrent turns are serialized. Two coroutines calling
s.turn()on the sameSessionwon't race on the wire — an internal coroutineMutexorders them, so the server only ever sees one in-flight turn per session. (Multiple sessions, of course, run in parallel.) - Per-call HTTP timeout.
Session.turn(prompt, files, timeoutSecs)applies the same per-call timeout pattern as audit-fix3c77ef5forForgeClient.run: atimeoutSecslarger thanForgeOptions.defaultTimeoutextends the HTTP request window so the client doesn't disconnect while the server is still doing useful work. Session.toString()redacts the embedded client. TheForgeClientreference (which carries the bearer) is intentionally omitted. Same hazard / same pattern asAppToken.toString.- Cross-token access is 404.
forge.getSession(id)against a session owned by another token surfaces asForgeApiException(statusCode=404)— the server returns 404 (not 403) to avoid leaking session existence.
Turn output
TurnResult.events is the structured event list (text, thinking,
tool_call, …). For the common case where you want the model's textual
reply, use result.text():
val r = s.turn("Explain in two sentences")
println(r.text()) // concatenated content of every type=="text" event
TurnEvent.args and TurnEvent.result are JsonElement? — narrow them
with the standard kotlinx.serialization.json extensions.
Public API
All methods are suspend — call them from a coroutine scope.
| Method | HTTP | Notes |
|---|---|---|
healthz() |
GET /healthz |
No bearer required; IP allowlist still applies. |
run(RunRequest) |
POST /run |
200 → RunResult; 502 → ForgeRunFailureException. |
uploadFile(Path, ttlSecs?) |
POST /files |
Streams via Ktor's submitFormWithBinaryData. |
createToken(CreateTokenRequest) |
POST /admin/tokens |
Admin bootstrap token only. |
listTokens() |
GET /admin/tokens |
Admin only. |
revokeToken(name) |
DELETE /admin/tokens/{name} |
Admin only. |
createSession(SessionOptions) |
POST /sessions |
v0.2; returns a Session. |
session(SessionOptions) { s -> … } |
— | v0.2 block helper; auto-closes. |
listSessions() |
GET /sessions |
v0.2; returns List<SessionState>. |
getSession(id) |
GET /sessions/{id} |
v0.2; cross-token = 404. |
close() |
— | Disposes the underlying Ktor client. |
Constructor
ForgeClient(
baseUrl: String,
token: String,
options: ForgeOptions = ForgeOptions(),
)
ForgeOptions knobs:
data class ForgeOptions(
val defaultModel: String = "sonnet",
val defaultTimeout: Duration = 60.seconds,
val requestMargin: Duration = 30.seconds,
val engine: HttpClientEngine? = null, // override (e.g. MockEngine in tests)
val configure: (HttpClientConfig<*>.() -> Unit)? = null,
)
JSON wire format
The server speaks snake_case (timeout_secs, claude_version,
file_token, ip_cidrs). The Kotlin SDK exposes idiomatic camelCase
properties and uses @SerialName to bridge — callers never see the
snake_case names:
@Serializable
data class RunRequest(
val prompt: String,
val model: String? = null,
val system: String? = null,
val files: List<String>? = null,
@SerialName("timeout_secs") val timeoutSecs: Int? = null,
)
RunResult.result is a kotlinx.serialization.json.JsonElement — the
server may return either a parsed JSON object/array (when the prompt asked
for JSON) or a plain string. Narrow it at the call site:
val obj = res.result.jsonObject // throws if not object
val txt = res.result.jsonPrimitive.contentOrNull // null if not primitive
Errors
A sealed exception hierarchy makes when exhaustive:
try {
client.run(req)
} catch (e: ForgeException) {
when (e) {
is ForgeAuthException -> rotateToken() // 401/403
is ForgeRunFailureException -> log("claude died: ${e.errorMessage}") // 502
is ForgeApiException -> log("api ${e.statusCode}: ${e.body}") // other 4xx/5xx
is ForgeTransportException -> backoffAndRetry() // network/decode
}
}
Cancellation propagates correctly: CancellationException is rethrown
unmodified, so structured concurrency (timeouts, parent-cancel) keep working.
File uploads stream
uploadFile(path, ttlSecs) uses Ktor's submitFormWithBinaryData with an
InputProvider backed by the file's InputStream. A 100 MB file uses
roughly the buffer size of heap, not 100 MB.
A few caller-trust assumptions worth flagging:
- Symlinks are followed.
Files.isRegularFileresolves symlinks by default, so a symlink-to-/etc/passwd(or anywhere else readable by the process) will be accepted and streamed upstream. If you're relaying paths from untrusted input, resolve them yourself withpath.toRealPath()and reject anything outside an allowlisted root before callinguploadFile. - Don't mutate the file mid-upload. The size is sampled once for the multipart Content-Length, then the stream is opened separately. Live edits between those two reads will produce a truncated or padded body on the wire.
- Filename safety. The leaf filename is interpolated into a
Content-Dispositionheader. Control characters (CR, LF, NUL, ...) in the filename are rejected upfront withIllegalArgumentException; embedded"is stripped. Anything else is passed through verbatim.
Security
- CVE-2024-49580 (
ktor-client-core). Cleared by the Ktor 2.3.13 bump. The vulnerable code path is in theHttpCacheplugin, which the SDK doesn't install — but if you reach intoForgeOptions.configureand installHttpCacheyourself on a client linked against an older Ktor, you'd expose yourself. - CVE-2025-29904 (
ktor-server-*). Server-side request smuggling. The SDK only depends onktor-client-*, so this is not exposed by the client surface. There is no 2.3.x backport from JetBrains; if/when this SDK migrates to Ktor 3.x it will pick up the patch alongside. - Bearer token never logged.
ForgeClientis a regular class (no auto-toString());ForgeOptionsdoesn't carry the token. Ktor'sLoggingplugin is not installed by default.AppToken.toString()redacts the plaintext when it's set. If you're consuming this library and adding logging, double-check you're not capturingAuthorizationheaders.
Builds & tests
./gradlew build # compile + tests + jar
./gradlew test # tests only
./gradlew publishToMavenLocal
Tests use Ktor's MockEngine — no network, no extra deps.
Compatibility
- JVM 17+ (toolchain pinned in
build.gradle.kts) - Kotlin 1.9.25
- Ktor 2.3.x
- kotlinx-serialization 1.6.x
- kotlinx-coroutines 1.8.x
Multiplatform isn't enabled out of the box, but the only JVM-only choices
in the SDK are the CIO engine and java.nio.file.Path for uploadFile.
A KMP fork would replace those with okio.Path and a per-target engine.
License
MIT.