clawdforge/clients/c
Kayos 22e57e3dad clients/c: v0.2 multi-turn Session API
- cf_session_t (opaque), cf_session_options_t, cf_turn_options_t,
  cf_turn_event_t, cf_turn_result_t, cf_session_state_t, cf_session_list_t
- cf_session_new / _turn / _close (idempotent) / _free, _state_get,
  _list_get, plus cf_session_id / _agent accessors
- cf_turn_result_text concatenates type=="text" events into a single
  string, lazily cached, OWNED by the result (caller must not free)
- Session-id allow-list ([A-Za-z0-9_-]+) validated client-side everywhere
  it lands in the URL path — same pattern as cf_admin_revoke_token, no
  reverse-proxy traversal foothold
- Bearer hygiene preserved: regression test covers cf_session_new /
  state_get / list_get and asserts the bearer never lands in
  cf_error_t.message
- v0.1 surface unchanged. Existing types (cf_client_t, cf_run_*, etc.)
  are byte-identical. cJSON 1.7.18 preserved.

Tests (21 → 34, +13):
- session_turn_round_trip — full create + turn + close, event parsing
- session_close_idempotent — close × 3, DELETE on the wire ONCE
- session_turn_after_close_returns_error — CF_ERR_USAGE, no HTTP
- session_cross_token_returns_404 — CF_ERR_API + http_status==404
- session_validates_id_traversal — "a/../healthz" rejected pre-network
- session_list_get — 2-row mock parsed; null last_turn_at → -1
- session_state_get — full state shape parsed
- turn_result_text_concatenates — skips non-text + empty content
- session_free_idempotent — NULL-safe across all v0.2 freers
- turn_result_memory_clean — valgrind regression guard
- session_turn_with_files — files + timeout_secs serialised
- session_bearer_never_leaks — 3 fallible paths, bearer stays out
- session_null_arg_defenses — every entry point rejects NULLs

Verification:
- cmake --build build (Release, -Werror -Wall -Wextra -Wpedantic
  -Wshadow): clean
- ctest --test-dir build: 34/34 pass
- valgrind --leak-check=full: 12,678 allocs == 12,678 frees, 0 errors,
  0 leaks
- ASan build: 34/34 pass clean
- UBSan build (-fsanitize=undefined -fno-sanitize-recover=all): 34/34
  pass clean

README adds "Multi-turn / Sessions (v0.2)" section: lifecycle example,
idempotent-close note, event/text ownership rules, state + listing,
memory ownership table, what's not in v0.2.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
2026-04-29 07:08:50 -07:00
..
examples clients/c: initial C SDK for clawdforge 2026-04-28 23:01:52 -07:00
include clients/c: v0.2 multi-turn Session API 2026-04-29 07:08:50 -07:00
pkgconfig clients/c: initial C SDK for clawdforge 2026-04-28 23:01:52 -07:00
src clients/c: v0.2 multi-turn Session API 2026-04-29 07:08:50 -07:00
tests clients/c: v0.2 multi-turn Session API 2026-04-29 07:08:50 -07:00
CMakeLists.txt clients/c: v0.2 multi-turn Session API 2026-04-29 07:08:50 -07:00
README.md clients/c: v0.2 multi-turn Session API 2026-04-29 07:08:50 -07:00

clawdforge — C SDK

A small synchronous C client for the clawdforge HTTP service. clawdforge wraps claude -p and ACPX (multi-turn) behind a bearer-token-gated REST API; this SDK is what you reach for from C, or what you FFI into from any language with a C ABI.

  • v0.1 surface (single-turn cf_run, file uploads, admin tokens) is unchanged. Existing callers keep working byte-for-byte.

  • v0.2 adds multi-turn cf_session_* for context-preserving turns — see Multi-turn / Sessions (v0.2) below.

  • C11 required. The implementation uses C11 atomics (<stdatomic.h>) for the shared libcurl-init refcount. The public header itself stays C99-compatible. No GNU extensions in the public surface.

  • Dependencies: libcurl (system) + cJSON (vendored under src/cjson/).

  • Errors are out-params, not globals. Library functions never write to stderr.

  • All allocations are caller-aware — every output struct ships with a _free().

  • Thread-safety: one cf_client_t per thread. They are not internally locked.

  • License: MIT (see CMakeLists.txt).

Install

Build deps on Debian/Ubuntu:

sudo apt install build-essential cmake libcurl4-openssl-dev pkg-config

Then:

git clone <gitea-url>/Sulkta-Coop/clawdforge.git
cd clawdforge/clients/c
cmake -S . -B build
cmake --build build -j
sudo cmake --install build --prefix /usr/local

This installs:

  • libclawdforge.a and libclawdforge.so.0.1.0 into ${prefix}/lib
  • clawdforge.h into ${prefix}/include
  • clawdforge.pc into ${prefix}/lib/pkgconfig

Use it from your build

pkg-config

gcc app.c $(pkg-config --cflags --libs clawdforge) -o app

CMake

If you installed to a custom prefix, point CMAKE_PREFIX_PATH at it. Then:

find_package(PkgConfig REQUIRED)
pkg_check_modules(CLAWDFORGE REQUIRED IMPORTED_TARGET clawdforge)
target_link_libraries(my_app PRIVATE PkgConfig::CLAWDFORGE)

Quickstart

#include <stdio.h>
#include <clawdforge.h>

int main(void) {
    cf_client_t *c = cf_client_new("http://192.168.0.5:8800", "cf_xxx");
    if (!c) return 1;

    cf_error_t err = {0};

    /* Health check */
    cf_healthz_t h = {0};
    if (cf_healthz(c, &h, &err) != CF_OK) {
        fprintf(stderr, "%s\n", cf_error_message(&err));
        cf_error_free(&err);
        cf_client_free(c);
        return 1;
    }
    printf("claude: %s\n", h.claude_version);
    cf_healthz_free(&h);

    /* Run a prompt */
    cf_run_request_t req = {
        .prompt       = "Reply with JSON: {\"hello\":\"world\"}",
        .model        = "sonnet",
        .timeout_secs = 60,
    };
    cf_run_result_t res = {0};
    if (cf_run(c, &req, &res, &err) != CF_OK) {
        fprintf(stderr, "%s\n", cf_error_message(&err));
        cf_error_free(&err);
        cf_client_free(c);
        return 1;
    }
    printf("%s\n", res.result_json);     /* {"hello":"world"} */
    printf("%ld ms\n", res.duration_ms);
    cf_run_result_free(&res);

    cf_client_free(c);
    return 0;
}

Memory rules

The library never holds onto pointers past the call boundary. The pattern is:

  1. You pass const char * strings in. The library copies what it needs.
  2. The library populates output structs. Their string members are heap allocated and owned by the caller.
  3. You call the matching *_free() to release them.
cf_run_result_t res = {0};            /* zero-initialise */
cf_run(c, &req, &res, &err);          /* fills it */
/* ... use res.result_json ... */
cf_run_result_free(&res);             /* releases it */

The cf_error_t follows the same pattern: zero-initialise on the stack, pass by address, free on failure. Do not call cf_error_free() after a successful call (the struct was not touched).

Status codes

typedef enum cf_status {
    CF_OK            = 0,
    CF_ERR_AUTH      = 1,   /* 401 / 403 */
    CF_ERR_API       = 2,   /* server returned a structured error envelope */
    CF_ERR_TRANSPORT = 3,   /* network / TLS / connection */
    CF_ERR_USAGE     = 4,   /* caller passed a bad argument */
    CF_ERR_OOM       = 5,   /* out of memory */
    CF_ERR_PARSE     = 6    /* server response was not the expected JSON shape */
} cf_status_t;

Use cf_status_str(code) for a human label.

API reference

Lifecycle

Function Notes
cf_client_t *cf_client_new(const char *base_url, const char *token) Allocates a client. token may be NULL if you only intend to use admin endpoints. Trailing slashes on base_url are stripped.
cf_status_t cf_client_set_admin_token(cf_client_t *c, const char *admin_token) Required before any cf_admin_* call.
void cf_client_set_timeout_secs(cf_client_t *c, long timeout_secs) Default 600 seconds.
void cf_client_free(cf_client_t *c) Releases.

/healthz

cf_status_t cf_healthz(cf_client_t *c, cf_healthz_t *out, cf_error_t *err);
void        cf_healthz_free(cf_healthz_t *h);

cf_healthz_t carries ok, claude_present, and claude_version (heap str).

/run

cf_status_t cf_run(cf_client_t *c,
                   const cf_run_request_t *req,
                   cf_run_result_t *out,
                   cf_error_t *err);
void        cf_run_result_free(cf_run_result_t *r);
void       *cf_run_result_as_cjson(const cf_run_result_t *r);

cf_run_result_t::result_json is the inner result field re-serialised back to a JSON string. If the server's result was a string, this is a quoted JSON string (e.g. "\"hello\""). If the server returned an object, this is the object's JSON. result_is_string distinguishes the two.

cf_run_result_as_cjson() returns a freshly-parsed cJSON * (cast it). The caller must cJSON_Delete() the returned node. The header returns void * so consumers don't need cJSON visible.

/files

cf_status_t cf_upload_file(cf_client_t *c,
                           const char *path,
                           long ttl_secs,           /* 0 or 60..86400 */
                           cf_file_token_t *out,
                           cf_error_t *err);
void        cf_file_token_free(cf_file_token_t *ft);

Uploads stream from disk via curl_mime_filedata — files are NOT loaded into memory.

/admin/tokens

cf_status_t cf_admin_create_token(cf_client_t *c,
                                  const char *name,
                                  const char *const *ip_cidrs,
                                  size_t ip_cidrs_count,
                                  cf_admin_token_t *out,
                                  cf_error_t *err);
cf_status_t cf_admin_list_tokens(cf_client_t *c,
                                 cf_admin_token_list_t *out,
                                 cf_error_t *err);
cf_status_t cf_admin_revoke_token(cf_client_t *c,
                                  const char *name,
                                  cf_error_t *err);

All require an admin token via cf_client_set_admin_token first.

Multi-turn / Sessions (v0.2)

v0.2 adds multi-turn Session support backed by the server's ACPX path. The surface is purely additive — every v0.1 entry point keeps working unchanged. Use cf_run for one-shot prompts; reach for sessions when you need context across turns.

Lifecycle

#include <clawdforge.h>

cf_client_t *c = cf_client_new("http://192.168.0.5:8800", "cf_xxx");
cf_error_t err = {0};

/* 1. Create a session */
cf_session_t *s = NULL;
cf_session_options_t sopts = { .agent = "claude" };
if (cf_session_new(c, &sopts, &s, &err) != CF_OK) {
    fprintf(stderr, "session_new: %s\n", cf_error_message(&err));
    cf_error_free(&err);
    cf_client_free(c);
    return 1;
}
printf("session id: %s\n", cf_session_id(s));

/* 2. Send a turn */
cf_turn_options_t topts = { 0 };
cf_turn_result_t *r = NULL;
if (cf_session_turn(s, "Read README.md and summarize", &topts, &r, &err) != CF_OK) {
    fprintf(stderr, "turn: %s\n", cf_error_message(&err));
    cf_error_free(&err);
    cf_session_free(s);   /* implicit close */
    cf_client_free(c);
    return 1;
}
printf("text: %s\n", cf_turn_result_text(r));   /* owned by r — do not free */
printf("turn_index: %d, duration: %ld ms\n", r->turn_index, r->duration_ms);
cf_turn_result_free(r);

/* 3. Close (idempotent — safe to call multiple times) */
cf_session_close(s, &err);
cf_session_free(s);

cf_client_free(c);

Idempotent close

cf_session_close is idempotent: the first successful call issues DELETE /sessions/{id}, and any subsequent call short-circuits and returns CF_OK without hitting the wire. This means you can safely call it from cleanup paths (e.g. wrapped in a goto-cleanup pattern) without worrying about double-free. If you skip the explicit close, cf_session_free makes a best-effort close internally before tearing the handle down.

Turn events and cf_turn_result_text

A cf_turn_result_t exposes the structured event log that came back from the turn:

typedef struct cf_turn_event {
    char *type;           /* "text", "tool_call", "thinking", ... */
    char *content;        /* nullable */
    char *name;           /* nullable */
    char *args_json;      /* nullable, raw JSON */
    char *result_json;    /* nullable, raw JSON */
} cf_turn_event_t;

If you only want the visible text reply, cf_turn_result_text(r) concatenates every type=="text" event's content into a single string. The pointer it returns is owned by r — do not free it. It's computed lazily on first call and cached.

For tool-call events, args_json and result_json are raw JSON strings that you can hand to cJSON_Parse if you need structured access.

State + listing

/* Look up state by id */
cf_session_state_t *st = NULL;
cf_session_state_get(c, "sess_abc", &st, &err);
printf("turn_count: %d, closed_at: %ld\n", st->turn_count, st->closed_at);
cf_session_state_free(st);

/* List the sessions visible to your token */
cf_session_list_t *list = NULL;
cf_session_list_get(c, &list, &err);
for (size_t i = 0; i < list->items_count; i++) {
    printf("- %s (%d turns)\n",
           list->items[i].session_id, list->items[i].turn_count);
}
cf_session_list_free(list);

last_turn_at and closed_at use -1 to represent the JSON null state (never had a turn / still open) — pick whichever sentinel feels right for your call site.

Memory ownership summary

Producer How to release
cf_session_new cf_session_free
cf_session_turn cf_turn_result_free
cf_session_state_get cf_session_state_free
cf_session_list_get cf_session_list_free
cf_turn_result_text (do not free — owned by the result)
cf_session_id / cf_session_agent (do not free — owned by the session)

Every freer is NULL-safe.

What's not in v0.2

  • No streaming (turn returns the whole event batch when finished).
  • No background close on session-handle drop — call cf_session_close explicitly (or rely on the implicit close inside cf_session_free).

Build options

Option Default What it does
-DCLAWDFORGE_BUILD_TESTS=ON/OFF ON Build clawdforge_tests.
-DCLAWDFORGE_BUILD_EXAMPLES=ON/OFF ON Build clawdforge_example_basic.
-DCLAWDFORGE_ASAN=ON/OFF OFF Build the lib + tests with -fsanitize=address.

Tests

cmake -S . -B build
cmake --build build -j
cd build && ctest --output-on-failure

The test suite spins up an in-process HTTP server on a loopback random port and exercises the wire surface: URL construction, auth header injection, request/response JSON shapes, error envelopes, and multipart upload.

To run under AddressSanitizer:

cmake -S . -B build-asan -DCLAWDFORGE_ASAN=ON
cmake --build build-asan -j
cd build-asan && ctest --output-on-failure

To run under valgrind:

valgrind --leak-check=full --error-exitcode=1 ./build/clawdforge_tests

Threading

cf_client_t is not safe to share across threads. Each thread should own its own client (or guard one with your own mutex). Internally, every call constructs and tears down its own libcurl easy handle, so adding a shared connection pool is a future enhancement that won't change the API.

Vendored cJSON

src/cjson/cJSON.{c,h} are vendored from cJSON v1.7.18 (MIT, from upstream). The license file lives next to them. Bumping to 1.7.18 picks up the fix for CVE-2024-31755 — a NULL pointer dereference reachable through cJSON_Parse on crafted input.