The Rust SDK already existed at Sulkta-Coop/clawdforge clients/rust/ — async,
reqwest-based, bearer-auth, exposes Client::run() + Session for multi-turn.
Vendoring it into vendor/clawdforge so skald is self-contained: no
git-submodule + no needing the clawdforge repo cloned next to skald.
Trade-off accepted: updates require manual re-copy until both sides
stabilize and we publish to a private cargo registry.
What landed:
- vendor/clawdforge/ — full SDK source from Sulkta-Coop/clawdforge HEAD.
Pinned in skald-core/Cargo.toml as a path dep.
- skald-core/src/forge.rs — three-pass orchestration shell. Forge wraps
clawdforge::Client; generate() / cleanup() / audit() each build a
RunRequest with the right system prompt + model alias (always opus),
call client.run(), return a PassOutput.
Prompt templates are TODO stubs (SYSTEM_GEN_TODO etc) — filling in the
actual prose-craft prompts is its own deep session.
- skald-core/src/config.rs — ForgeConfig { base_url, app_token, model }.
Resolved by the binary from env (CLAWDFORGE_URL + CLAWDFORGE_TOKEN);
lib stays env-agnostic.
- skald-core::AuditFinding + AuditResponse — parse shape for what the
third-Opus canon audit returns, ready to map onto audit_findings rows.
- docs/tts-pipeline.md — full plan for v0.2 narration + post-TTS audit
chain. Whisper-large-v3 STT does text-to-text verification on every
render; an optional Gemini Flash audio pass catches subjective issues
(prosody, tone) Whisper can't see. Reroll loop on crit findings.
What's still stubbed:
- Prompt templates in forge.rs (gen / cleanup / audit) — placeholders
that describe the role but don't constrain output shape yet.
- context.rs (assemble the LLM context blob from DB rows) — entire module
TBD.
- No CLI subcommand yet for invoking forge — that comes after context.rs.
Naming note: in Rust 2024 'gen' is a reserved keyword (for generators),
so the method is Forge::generate(), not Forge::gen().
270 lines
8.3 KiB
Markdown
270 lines
8.3 KiB
Markdown
# 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::<T>()` 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 <pin>
|
|
```
|
|
|
|
Or pin manually in `Cargo.toml`:
|
|
|
|
```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:
|
|
|
|
```toml
|
|
clawdforge = { path = "../clawdforge/clients/rust" }
|
|
```
|
|
|
|
## Quickstart
|
|
|
|
```rust
|
|
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.
|
|
|
|
[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, 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
|
|
|
|
```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.
|