Async client over reqwest+tokio with builder-pattern Client, serde RunRequest/RunResult/FileToken/AppToken types, thiserror Error enum, streaming multipart upload via tokio::fs::File, and 14 wiremock-backed integration tests covering healthz, run-success-json, run-success-text, run-502, run-with-files, file-upload, token mint/list/revoke, auth failure, missing-token short-circuit, transport timeout, and builder validation. Doc-tested. cargo test, cargo clippy --all-targets -D warnings, and cargo build --examples all clean. |
||
|---|---|---|
| .. | ||
| examples | ||
| src | ||
| tests | ||
| Cargo.toml | ||
| README.md | ||
clawdforge — Rust client
Async Rust SDK for clawdforge, a small LAN-only HTTP service that wraps
claude -p subprocess calls behind a bearer-token-gated REST API.
- Tokio + reqwest under the hood
serde+serde_jsontypes- Streaming multipart upload (
tokio::fs::File, no full-file buffer) - Builder pattern for configuration
- Typed
RunResult::as_json::<T>()andas_text()helpers over aserde_json::Valuepayload
Install
This crate is not on crates.io. Pull it directly from the upstream git host:
cargo add clawdforge --git https://gitea.sulkta.com/Sulkta-Coop/clawdforge --rev <pin>
Or pin manually in Cargo.toml:
[dependencies]
clawdforge = { git = "https://gitea.sulkta.com/Sulkta-Coop/clawdforge", rev = "<pin>" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
For an in-repo workspace consumer, point at the clients/rust/ path:
clawdforge = { path = "../clawdforge/clients/rust" }
Quickstart
use clawdforge::{Client, RunRequest};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::builder()
.base_url("http://localhost:8800")
.token("cf_xxxxxxxxxxxxxxxx")
.build()?;
// Liveness — does not require a token, but sends one if configured.
let h = client.healthz().await?;
println!("claude present: {} version: {:?}", h.claude_present, h.claude_version);
// Run a prompt. `result` is a serde_json::Value — narrow via .as_json::<T>().
let r = client.run(RunRequest {
prompt: "Reply with JSON: {\"hello\":\"world\"}".into(),
model: Some("sonnet".into()),
timeout_secs: Some(30),
..Default::default()
}).await?;
#[derive(serde::Deserialize)]
struct Hello { hello: String }
let typed: Hello = r.as_json()?;
println!("{}", typed.hello);
// Upload a file, then attach it to a follow-up run.
let ft = client.upload_file("./recipe.png", Some(3600)).await?;
let r2 = client.run(RunRequest {
prompt: "extract recipe data".into(),
files: Some(vec![ft.file_token]),
..Default::default()
}).await?;
println!("{:?}", r2.result);
Ok(())
}
Public API
Client::builder()
Builder for the HTTP client.
| Method | Purpose |
|---|---|
.base_url(url) |
Required. e.g. "http://localhost:8800". |
.token(t) |
App bearer for /run, /files. |
.admin_token(t) |
Admin bearer for /admin/*. |
.timeout(Duration) |
Per-request timeout (default 120 s). |
.user_agent(s) |
Override User-Agent header. |
.danger_accept_invalid_certs(bool) |
Skip TLS verify (off by default). |
.build() |
Returns Result<Client, Error>. |
Client async methods
| Method | Endpoint | Notes |
|---|---|---|
healthz() |
GET /healthz |
Returns Healthz. |
run(RunRequest) |
POST /run |
Returns RunResult. 502 surfaces as Error::Api. |
upload_file(path, ttl_secs) |
POST /files |
Streams from disk; returns FileToken. |
create_token(TokenCreateRequest) |
POST /admin/tokens |
Admin only. Returns AppToken. |
list_tokens() |
GET /admin/tokens |
Admin only. Returns TokenList. |
revoke_token(name) |
DELETE /admin/tokens/{name} |
Admin only. |
RunResult helpers
let r = client.run(req).await?;
// Try a typed shape.
#[derive(serde::Deserialize)]
struct Recipe { name: String, qty: u32 }
let recipe: Recipe = r.as_json()?;
// Or fall back to a string when the model declined to emit JSON.
if let Some(text) = r.as_text() {
println!("{text}");
}
r.result itself is serde_json::Value if you need to branch on shape.
Error model
pub enum Error {
Auth(String), // missing/invalid bearer, 401, 403
Api { status: u16, body: String }, // any other non-2xx
Transport(reqwest::Error), // connect, TLS, read, request timeout
Json(serde_json::Error), // decode failure on a 2xx body
Io(std::io::Error), // local file open in upload_file
Timeout(String), // explicit deadline (reserved)
Config(String), // builder misconfiguration
}
A 502 from /run lands in Error::Api { status: 502, body } — the body is
the JSON failure envelope. Recover the structured form with:
let parsed: clawdforge::RunFailure = serde_json::from_str(&body)?;
Wire format
clawdforge speaks snake_case JSON end-to-end. The structs in this crate
match that without #[serde(rename_all = "camelCase")]. If a future endpoint
exposes camelCase, prefer per-field #[serde(rename = "...")] over a blanket
container attribute so both styles can coexist.
Examples
CLAWDFORGE_URL=http://localhost:8800 \
CLAWDFORGE_TOKEN=cf_xxxx \
cargo run --example basic
Optional file demo:
CLAWDFORGE_DEMO_FILE=./some.png cargo run --example basic
Development
cargo build --release
cargo test --all
cargo clippy --all-targets -- -D warnings
cargo build --examples
Tests use wiremock — no live clawdforge needed.
License
MIT.