- Session move-only RAII; destructor best-effort close
- Client::create_session / list_sessions / get_session
- TurnResult.text() helper
- All post-parse json::get<T>() wrapped via with_protocol_guard (no nlohmann leak)
- tests/test_session.cpp: ~12 tests covering RAII/idempotency/move/list/state/404/protocol-error/regression
- ASan + UBSan clean
- README "Multi-turn / Sessions (v0.2)" section
v0.1 surface unchanged. C++17 preserved. cpp-httplib 0.20.1 preserved.
Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
312 lines
10 KiB
Markdown
312 lines
10 KiB
Markdown
# 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 <clawdforge/client.hpp>
|
|
#include <iostream>
|
|
|
|
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<nlohmann::json>(&r.result)) {
|
|
std::cout << j->at("hello").get<std::string>() << "\n";
|
|
} else if (auto* s = std::get_if<std::string>(&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 <clawdforge/client.hpp>
|
|
#include <iostream>
|
|
|
|
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/<id>
|
|
|
|
// 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<SessionState>` |
|
|
| `Client::get_session(id)` | `GET /sessions/<id>` | `SessionState` |
|
|
| `Session::turn(prompt, opts={})` | `POST /sessions/<id>/turn` | `TurnResult` |
|
|
| `Session::state()` | `GET /sessions/<id>` | `SessionState` |
|
|
| `Session::close()` | `DELETE /sessions/<id>` | `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 `<clawdforge/client.hpp>`):
|
|
|
|
| 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<AppTokenInfo>` |
|
|
| `revoke_token(name)` | `DELETE /admin/tokens/<name>` | `void` |
|
|
| `create_session(opts={})` | `POST /sessions` | `Session` |
|
|
| `list_sessions()` | `GET /sessions` | `std::vector<SessionState>` |
|
|
| `get_session(id)` | `GET /sessions/<id>` | `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.
|