clawdforge/clients/kotlin/README.md
Kayos 8479725513 clients/kotlin: v0.2 multi-turn Session API
- 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 3c77ef5 pattern)
- Per-session Mutex serializes concurrent turns
- TurnResult.text() helper, Session.toString redacts client
- SessionTest.kt: ~14 tests covering block/idempotency/concurrent/timeout/list/state/404/redaction/regression
- README "Multi-turn / Sessions (v0.2)" section

v0.1 surface unchanged. Ktor 2.3.13 preserved.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
2026-04-29 07:03:54 -07:00

11 KiB

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
  • ForgeClient is Closeable and works inside use { }

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 fires DELETE /sessions/{id} the first time it's called; later invocations short-circuit on an internal AtomicBoolean. 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 same Session won't race on the wire — an internal coroutine Mutex orders 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-fix 3c77ef5 for ForgeClient.run: a timeoutSecs larger than ForgeOptions.defaultTimeout extends the HTTP request window so the client doesn't disconnect while the server is still doing useful work.
  • Session.toString() redacts the embedded client. The ForgeClient reference (which carries the bearer) is intentionally omitted. Same hazard / same pattern as AppToken.toString.
  • Cross-token access is 404. forge.getSession(id) against a session owned by another token surfaces as ForgeApiException(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.isRegularFile resolves 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 with path.toRealPath() and reject anything outside an allowlisted root before calling uploadFile.
  • 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-Disposition header. Control characters (CR, LF, NUL, ...) in the filename are rejected upfront with IllegalArgumentException; 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 the HttpCache plugin, which the SDK doesn't install — but if you reach into ForgeOptions.configure and install HttpCache yourself 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 on ktor-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. ForgeClient is a regular class (no auto-toString()); ForgeOptions doesn't carry the token. Ktor's Logging plugin 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 capturing Authorization headers.

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.