Async Kotlin/JVM client built on Ktor + kotlinx.serialization. Every I/O
method is a `suspend` function; the client is `Closeable` for `use { }`.
Sealed `ForgeException` hierarchy enables exhaustive `when` over auth,
run-failure, generic-API, and transport errors. Models use `@SerialName`
to bridge idiomatic camelCase Kotlin properties to the snake_case wire
format. `RunResult.result` is a `JsonElement` so callers can narrow with
the standard `kotlinx.serialization.json` extensions.
- Kotlin 1.9.25 / JVM 17 toolchain
- Ktor 2.3.12 client (CIO engine; pluggable via ForgeOptions.engine)
- kotlinx-serialization 1.6.3, kotlinx-coroutines 1.8.1
- 14 tests (JUnit 5 + Ktor MockEngine), all green
- `./gradlew build` clean, `publishToMavenLocal` works
- MIT license declared in publishing block
Mirrors the surface of the Go and Rust SDKs (healthz, run, uploadFile,
admin tokens CRUD).
|
||
|---|---|---|
| .. | ||
| 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.1.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),
))
}
}
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. |
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.
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.