- 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:
|
||
|---|---|---|
| .. | ||
| examples | ||
| include | ||
| pkgconfig | ||
| src | ||
| tests | ||
| CMakeLists.txt | ||
| README.md | ||
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_tper 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.aandlibclawdforge.so.0.1.0into${prefix}/libclawdforge.hinto${prefix}/includeclawdforge.pcinto${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:
- You pass
const char *strings in. The library copies what it needs. - The library populates output structs. Their string members are heap allocated and owned by the caller.
- 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_closeexplicitly (or rely on the implicit close insidecf_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.