# clawdforge — C++ SDK Modern C++ client for the [clawdforge](https://github.com/Sulkta-Coop/clawdforge) HTTP API. Wraps `claude -p` subprocess calls behind a bearer-token-gated REST service. - C++17 minimum. C++20 unlocks designated initializers in caller code (used in the examples / tests for ergonomics) but the library itself compiles cleanly at C++17. - libcurl + nlohmann/json. No Boost. - RAII, move-only `Client` (one libcurl handle per instance). - Throwing API; full exception hierarchy under `clawdforge::Error`. ## Install ### Option A — FetchContent (drop-in for existing CMake projects) ```cmake include(FetchContent) FetchContent_Declare(clawdforge GIT_REPOSITORY https://github.com/Sulkta-Coop/clawdforge.git GIT_TAG main SOURCE_SUBDIR clients/cpp ) FetchContent_MakeAvailable(clawdforge) target_link_libraries(my_app PRIVATE clawdforge::clawdforge) ``` `CURL` must be available via `find_package(CURL REQUIRED)`. On Debian/Ubuntu: `sudo apt install libcurl4-openssl-dev`. ### Option B — install + find_package ```bash git clone https://github.com/Sulkta-Coop/clawdforge.git cd clawdforge/clients/cpp cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j sudo cmake --install build ``` Then in your project: ```cmake find_package(clawdforge CONFIG REQUIRED) target_link_libraries(my_app PRIVATE clawdforge::clawdforge) ``` ## Quickstart ```cpp #include #include namespace cf = clawdforge; int main() { cf::Client client{cf::ClientOptions{ .base_url = "http://localhost:8800", .token = "cf_...", }}; auto h = client.healthz(); std::cout << "claude_version=" << h.claude_version << "\n"; auto r = client.run(cf::RunRequest{ .prompt = R"(Reply with JSON: {"hello":"world"})", .model = "sonnet", .timeout_secs = 60, }); if (auto* j = std::get_if(&r.result)) { std::cout << j->at("hello").get() << "\n"; } else if (auto* s = std::get_if(&r.result)) { std::cout << *s << "\n"; } } ``` ## Multi-turn / Sessions (v0.2) `clawdforge::Session` is the multi-turn handle returned by `Client::create_session`. RAII — when the `Session` goes out of scope its destructor best-effort closes the session on the server. ```cpp #include #include namespace cf = clawdforge; int main() { cf::Client client{cf::ClientOptions{ .base_url = "http://localhost:8800", .token = "cf_...", }}; { // Destructor closes if not already closed. auto s = client.create_session(cf::CreateSessionOptions{.agent = "claude"}); auto r1 = s.turn("Read README.md and summarize"); std::cout << r1.text() << "\n"; auto r2 = s.turn("Now look at the auth flow", cf::TurnOptions{.files = {"ff_xyz"}}); std::cout << r2.text() << "\n"; } // ~Session sends DELETE /sessions/ // List + state lookups: auto sessions = client.list_sessions(); for (const auto& st : sessions) { std::cout << st.session_id << " turns=" << st.turn_count << "\n"; } } ``` ### Explicit close as fallback ```cpp auto s = client.create_session(); try { s.turn("..."); } catch (...) { s.close(); // optional — destructor would do it anyway throw; } s.close(); // idempotent — second + subsequent calls short-circuit // without an HTTP round-trip ``` `close()` is idempotent both client-side (an internal flag short-circuits without a network call) and server-side (the server returns `{"ok":true,"already_closed":true}` on a second hit). Safe to call from any cleanup path. ### Move-only — mirrors `Client` `Session` follows the same shape as `Client`: move-only, copy-deleted. A moved-from `Session` is marked already-closed so its destructor doesn't double-call. Move-assign best-effort closes the LHS before taking over the RHS's state. ```cpp auto a = client.create_session(); auto b = std::move(a); // a's session id transfers to b; ~a is a no-op ``` ### Methods | Method | HTTP | Returns | |---|---|---| | `Client::create_session(opts={})` | `POST /sessions` | `Session` (move-only) | | `Client::list_sessions()` | `GET /sessions` | `std::vector` | | `Client::get_session(id)` | `GET /sessions/` | `SessionState` | | `Session::turn(prompt, opts={})` | `POST /sessions//turn` | `TurnResult` | | `Session::state()` | `GET /sessions/` | `SessionState` | | `Session::close()` | `DELETE /sessions/` | `void` (idempotent) | `TurnResult::text()` concatenates the `content` of every `type == "text"` event into a single string, dropping `thinking` / `tool_call` frames. Use when you only want the model's user-facing reply. Tool-call events carry their `args` and `result` payloads as raw JSON strings (`args_json` / `result_json`) rather than parsed structures — re-parse via `nlohmann::json::parse(*event.args_json)` if you need the structured value. Cross-token access — pulling a session id that exists under a different token — surfaces as `clawdforge::APIError` with `status_code() == 404`, matching the server's no-existence-leak design. Calling `Session::turn` after `close()` (or on a moved-from `Session`) throws `clawdforge::ProtocolError("Session::turn called on closed session")`. ### v0.1 surface unchanged The `/run` path stays byte-identical to v0.1. Single-turn callers don't need to migrate. ## API `clawdforge::Client` (in ``): | Method | HTTP | Returns | |---|---|---| | `healthz()` | `GET /healthz` | `HealthzResponse` | | `run(req)` | `POST /run` | `RunResult` | | `upload_file(path, ttl_secs=0)` | `POST /files` | `FileToken` | | `create_token(req)` | `POST /admin/tokens` | `AppToken` | | `list_tokens()` | `GET /admin/tokens` | `std::vector` | | `revoke_token(name)` | `DELETE /admin/tokens/` | `void` | | `create_session(opts={})` | `POST /sessions` | `Session` | | `list_sessions()` | `GET /sessions` | `std::vector` | | `get_session(id)` | `GET /sessions/` | `SessionState` | All methods throw on failure. `ClientOptions` lets you set `base_url`, `token`, `admin_token`, `timeout`, `connect_timeout`, `user_agent`, and `insecure_tls`. ## Errors ```text clawdforge::Error // abstract base, derives from std::runtime_error ├── clawdforge::AuthError // 401 / 403, or missing token on the client ├── clawdforge::APIError // any other non-2xx — has status_code() + body() ├── clawdforge::TransportError // libcurl-level failures └── clawdforge::ProtocolError // bad URL, missing fields, malformed JSON ``` Catch broadly: ```cpp try { client.run(req); } catch (const cf::AuthError& e) { /* refresh / abort */ } catch (const cf::APIError& e) { std::cerr << e.status_code() << " " << e.body(); } catch (const cf::TransportError& e) { /* retry */ } catch (const cf::Error& e) { /* anything else */ } ``` `POST /run` returns HTTP 502 on subprocess failure. Surfaced as `APIError` with `status_code() == 502`; `body()` parses as the documented `RunFailure` shape. ## File uploads ```cpp auto ft = client.upload_file("/path/to/recipe.png", /*ttl_secs=*/3600); auto res = client.run(cf::RunRequest{ .prompt = "extract recipe data", .files = {ft.file_token}, }); ``` The file is streamed off disk via libcurl's `curl_mime_filedata` — no in-memory buffering of the payload. ## Threading `Client` is move-only and **not** thread-safe. Construct one per worker thread or wrap external accesses in a mutex. Multiple `Client` instances share the process-wide libcurl global state safely (refcounted internally). ## Linking `nlohmann_json` is a **public** dependency of the SDK (used in `types.hpp` for on-wire structs). Consumers therefore compile against and link `nlohmann_json` even when only calling, say, `healthz()`. If you already vendor a different version of `nlohmann_json`, you'll want to pin a compatible version (3.10+) to avoid ODR drift across the boundary. This is a known v0.1 limitation. A v0.2 refactor will hide nlohmann behind a thin `clawdforge::JsonValue` wrapper to drop it from the public ABI. `libcurl` is private and not exposed across the public header. ## Threat model The SDK is intended for clawdforge servers that the caller trusts (LAN-deployed, bearer-gated). With that in mind: - The `result` field of a `POST /run` reply is **untrusted user-influenced data** — it carries Claude's response, which can be shaped by whatever ended up in the prompt. The SDK parses it as JSON via `nlohmann::json::parse`, which is recursion-based. A pathologically deep object (thousands of nested arrays) could push the stack arbitrarily far before the parser bails. The SDK does **not** currently apply a max-depth guard. If your threat model includes attacker-controlled prompts, either cap prompt size upstream or pre-validate the response body before letting the SDK parse it. - TLS verification is **on by default**. Only disable via `ClientOptions::insecure_tls = true` for self-signed LAN-internal certs. Bearer tokens are passed via `Authorization: Bearer …`; libcurl strips this on cross-host redirects automatically (≥ 7.64.0), and the SDK additionally pins `MAXREDIRS=5` and `REDIR_PROTOCOLS=http,https`. - Bearer tokens never appear in exception messages, log lines, or `truncate_for_log` output by design. If you observe one, that's a bug — please file an issue. ## Build options | Option | Default | Effect | |---|---|---| | `CLAWDFORGE_BUILD_TESTS` | `ON` (top-level), `OFF` (subdirectory) | doctest-based suite | | `CLAWDFORGE_BUILD_EXAMPLES` | `ON` (top-level), `OFF` (subdirectory) | builds `clawdforge_basic_example` | | `CLAWDFORGE_WARNINGS_AS_ERRORS` | `ON` | `-Werror` / `/WX` on the library target | ## Development ```bash cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j ctest --test-dir build --output-on-failure ``` The test suite spins up a `cpp-httplib` mock server in-process — no real clawdforge needed. ### Test deps | Dep | Version | Notes | |---|---|---| | `cpp-httplib` | `0.20.1` | Test-only; mock binds to `127.0.0.1:0`. Optional backends (OpenSSL / zlib / brotli / zstd) are forced off when fetched. | | `doctest` | `2.4.11` | Test-only. | Test deps are NOT linked into the shipped `clawdforge::clawdforge` target. ## License MIT. See `LICENSE` at the repo root.