skald/vendor/clawdforge
Kayos f71b533e52 v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan
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().
2026-05-13 10:18:56 -07:00
..
examples v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan 2026-05-13 10:18:56 -07:00
src v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan 2026-05-13 10:18:56 -07:00
tests v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan 2026-05-13 10:18:56 -07:00
Cargo.toml v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan 2026-05-13 10:18:56 -07:00
README.md v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan 2026-05-13 10:18:56 -07:00

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_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:

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! (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.

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.