clawdforge/clients/cpp/README.md
Kayos 1f6606d3b9 clients/cpp: v0.2 multi-turn Session API
- 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
2026-04-29 07:10:50 -07:00

10 KiB

clawdforge — C++ SDK

Modern C++ client for the 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)

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

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:

find_package(clawdforge CONFIG REQUIRED)
target_link_libraries(my_app PRIVATE clawdforge::clawdforge)

Quickstart

#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.

#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

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.

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

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:

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

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

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.