# 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. [clawdforge]: https://gitea.sulkta.com/Sulkta-Coop/clawdforge - Tokio + reqwest under the hood - `serde` + `serde_json` types - Streaming multipart upload (`tokio::fs::File`, no full-file buffer) - Builder pattern for configuration - Typed `RunResult::as_json::()` and `as_text()` helpers over a `serde_json::Value` payload ## Install This crate is not on crates.io. Pull it directly from the upstream git host: ```sh cargo add clawdforge --git https://gitea.sulkta.com/Sulkta-Coop/clawdforge --rev ``` Or pin manually in `Cargo.toml`: ```toml [dependencies] clawdforge = { git = "https://gitea.sulkta.com/Sulkta-Coop/clawdforge", rev = "" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` For an in-repo workspace consumer, point at the `clients/rust/` path: ```toml clawdforge = { path = "../clawdforge/clients/rust" } ``` ## Quickstart ```rust use clawdforge::{Client, RunRequest}; #[tokio::main] async fn main() -> Result<(), Box> { 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::(). 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(()) } ``` ## Multi-turn / Sessions (v0.2) v0.1 `Client::run` is a single-turn shot. v0.2 adds a parallel session API backed by the server's [ACPX]-driven `/sessions/*` surface for back-and-forth agent flows that need context across turns. [ACPX]: https://github.com/openclaw/acpx ```rust use clawdforge::{Client, SessionOptions}; #[tokio::main] async fn main() -> Result<(), clawdforge::Error> { let client = Client::builder() .base_url("http://localhost:8800") .token("cf_xxxxxxxxxxxxxxxx") .build()?; let mut s = client.new_session(SessionOptions::default()).await?; let r1 = s.turn("Read README.md and summarize it").await?; println!("{}", r1.text()); // Attach files uploaded via Client::upload_file. let r2 = s .turn_with_files( "Now look at the auth flow", &["ff_xyz".into()], ) .await?; println!("turn {}: {}", r2.turn_index, r2.text()); // Explicit close consumes `s` — using it after this is a compile error. s.close().await?; Ok(()) } ``` ### Lifecycle | API | Purpose | |---|---| | `Client::new_session(SessionOptions)` | `POST /sessions` — returns a `Session`. | | `Session::turn(prompt)` | `POST /sessions/{id}/turn` with no files. | | `Session::turn_with_files(prompt, &[token, ...])` | `POST /sessions/{id}/turn` with `ff_*` tokens from `upload_file`. | | `Session::close(self)` | `DELETE /sessions/{id}`. **Consumes `self`** — use-after-close is a compile error. | | `Client::list_sessions()` | `GET /sessions` — sessions visible to the calling token. | | `Client::get_session(id)` | `GET /sessions/{id}` — current state. | ### Drop fallback If a `Session` is dropped without an explicit `close().await?`, `Drop` spawns a best-effort async DELETE via `tokio::spawn` to release the server-side session. This is **best-effort**: - The spawned future is not awaited — the calling task continues immediately. - Failures are logged via `tracing::warn!` (target `clawdforge::session`), not panicked. - If `Session` is dropped outside any tokio runtime, the close is skipped with a warning rather than panicking on `tokio::spawn`. - If `close().await?` already ran, `Drop` short-circuits without a second network call (an `AtomicBool` flag tracks closed state). For deterministic cleanup, **prefer `s.close().await?`**. The Drop path is a backstop for panics / early returns, not a primary lifecycle hook. ### `TurnResult::text()` Concatenates all `"text"` events into one string. `"thinking"` and `"tool_call"` events are skipped — inspect `result.events` directly if you need them. ```rust let r = s.turn("hi").await?; let answer: String = r.text(); let n_tool_calls = r .events .iter() .filter(|e| e.event_type == "tool_call") .count(); ``` ### v0.1 compatibility The v0.1 surface (`Client::run`, `Client::upload_file`, `Client::create_token`, etc.) is byte-identical. v0.2 is purely additive. v0.1 callers do not need to change anything to upgrade. ## 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` 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. | | `new_session(opts)` | `POST /sessions` | v0.2. Returns `Session`. | | `list_sessions()` | `GET /sessions` | v0.2. Returns `SessionList`. | | `get_session(id)` | `GET /sessions/{id}` | v0.2. Returns `SessionState`. | ### `RunResult` helpers ```rust 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 ```rust 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: ```rust 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 ```sh CLAWDFORGE_URL=http://localhost:8800 \ CLAWDFORGE_TOKEN=cf_xxxx \ cargo run --example basic ``` Optional file demo: ```sh CLAWDFORGE_DEMO_FILE=./some.png cargo run --example basic ``` ## Development ```sh cargo build --release cargo test --all cargo clippy --all-targets -- -D warnings cargo build --examples ``` Tests use [`wiremock`](https://docs.rs/wiremock) — no live clawdforge needed. ## License MIT.