forge.rs threads Effort::Max on gen + cleanup. Audit + summarize stay default — they're structured-output / tool-shaped tasks where extended thinking doesn't help. Bumps subprocess timeout from 600s to 1800s so max-effort prose-craft has the wall clock it needs. continue_story::run takes a chapter_count param; loops gen+cleanup per chapter with each iteration's just-written prose appended to context. Audit fires once at end against the combined batch vs parent canon. Cap is 20 (~5h wall clock, ~$600 at max effort — beyond that is operationally absurd). CLI: 'skald continue --chapters N'. Web: numeric field on both new- story and continue forms, 1..=20, defaults to 1. Vendored clawdforge SDK refreshed for the Effort enum. |
||
|---|---|---|
| .. | ||
| 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(())
}
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.
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!(targetclawdforge::session), not panicked. - If
Sessionis dropped outside any tokio runtime, the close is skipped with a warning rather than panicking ontokio::spawn. - If
close().await?already ran,Dropshort-circuits without a second network call (anAtomicBoolflag 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.
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, 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. |
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
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.