Skip to main content

Call ClikaRT from C or any FFI language

Everything the runtime can do is reachable from plain C: libClikaRT.so exports ONE symbol, clika_rt_get_api, and everything else arrives as a table of function pointers behind a version handshake. That is the whole FFI story, which is exactly why it works from any language that can dlopen and call C: the Kotlin and Rust bindings are this surface with idioms on top. clika_rt/clika_rt_core.h declares the table and pins the version to pass (CLIKA_RT_ABI_VERSION, never a bare literal). The table is append-only within a version's lifetime, a pre-1.0 bump may re-order the layout, and a runtime that cannot serve the requested version answers NULL, so the handshake is what keeps a mismatched pair from ever running. The handshake has two parts: the version passed to clika_rt_get_api, and the api-table layout fingerprint (api->layout_fingerprint against the header's CLIKA_RT_ABI_LAYOUT_FINGERPRINT), which catches a table whose layout grew or moved under the same version. Check both, as the program below does; the generated bindings (Go, Rust, Kotlin, Python) perform the same two checks before their first call.

The program below is complete, strict C99, and needs no link-time dependency on the runtime at all: the library is named at run time.

Load the runtime and shake hands

clika_rt_get_api(v) returns the api table for ABI version v, or NULL for a version this runtime does not serve. Check the refusal path deliberately: it is the load-time invariant that stops a version mismatch BEFORE the first call, in every language that rides this surface.

hello_abi.c
#include <dlfcn.h>
#include <stdio.h>
#include <string.h>

#include "clika_rt/clika_rt_core.h"

#define DIE(msg) do { fprintf(stderr, "FAIL: %s\n", msg); return 1; } while (0)

int main(int argc, char** argv) {
if (argc < 2) DIE("usage: hello_abi <path-to-libClikaRT.so>");

void* so = dlopen(argv[1], RTLD_NOW | RTLD_GLOBAL);
if (so == NULL) DIE(dlerror());

const clika_rt_api* (*get_api)(uint32_t) =
(const clika_rt_api* (*)(uint32_t))dlsym(so, "clika_rt_get_api");
if (get_api == NULL) DIE("libClikaRT.so exports no clika_rt_get_api");

if (get_api(999u) != NULL) DIE("version 999 must be refused");
const clika_rt_api* api = get_api(CLIKA_RT_ABI_VERSION);
if (api == NULL) DIE("get_api(CLIKA_RT_ABI_VERSION) returned NULL");
if (strcmp(api->layout_fingerprint, CLIKA_RT_ABI_LAYOUT_FINGERPRINT) != 0)
DIE("the runtime's api-table layout does not match this header; rebuild against the runtime's header");

char ver[128];
size_t vlen = sizeof ver;
if (api->version_string(ver, &vlen) != CLIKA_RT_STATUS_OK) DIE("version_string");
printf("runtime %s\n", ver);
return 0;
}

Compile with nothing but the header on the include path; -ldl is the only library:

cc -std=c99 -I "$CLIKART_BUNDLE_DIR/include" hello_abi.c -ldl -o hello_abi
./hello_abi "$CLIKART_BUNDLE_DIR/lib/libClikaRT.so"
runtime 0.4.6

Tensors and ops through the table

Members read noun_verb with the clika_rt_ prefix dropped: tensor_full, op_add_tensor, tensor_to_string, tensor_release. Two conventions carry every call. Out-parameters come last and are NULL on failure. And op operands follow the donation law: an op CONSUMES the tensor handles you pass as operands (the caller's reference transfers), so a consumed handle is neither released nor used again.

one_op.c (the body after the handshake)
int run_one_op(const clika_rt_api* api) {
int64_t shape[2] = {2, 3};
clika_rt_stream_or_device dflt;
clika_rt_scalar_or_tensor other;
clika_rt_tensor* t = NULL;
clika_rt_tensor* sum = NULL;
clika_rt_error* e;
char txt[4096];
size_t tlen = sizeof txt;

memset(&dflt, 0, sizeof dflt);
dflt.kind = CLIKA_RT_STREAM_OR_DEVICE_DEFAULT;
e = api->tensor_full(shape, 2, 4.0, CLIKA_RT_DATA_TYPE_FLOAT32, dflt, dflt, &t);
if (e != NULL) { api->error_free(e); return 1; }

memset(&other, 0, sizeof other);
other.kind = CLIKA_RT_SCALAR_OR_TENSOR_DOUBLE;
other.double_value = 2.5;
/* op_add_tensor CONSUMES t: not released here, not used again. */
e = api->op_add_tensor(t, other, 1.0, CLIKA_RT_ACTIVATION_IDENTITY, &sum);
if (e != NULL) { api->error_free(e); return 1; }

if (api->tensor_to_string(sum, txt, &tlen) != CLIKA_RT_STATUS_OK) return 1;
printf("%s\n", txt);
api->tensor_release(sum);
return 0;
}
Tensor(shape=[2, 3], dtype=Float32, device=CPU, numel=6, data=[6.5, 6.5, 6.5, 6.5, 6.5, 6.5])

Typed failures, stable codes

A failing call returns a clika_rt_error* (and NULLs its handle-out). The error object carries a human message and a machine-readable code NAME; branch on the name, never the message text. An argument mistake reads as a sentence naming the operation and the values, in every build; a message of the form E<digits> is an internal fault code whose value is specific to the build that produced it (report it verbatim with the runtime version); the code name is the one channel that is stable across builds. Free the error when done; error_free is the one cleanup every failure path owes.

typed_error.c (the shape of a failure branch)
int expect_typed_failure(const clika_rt_api* api) {
int64_t shape[1] = {2};
clika_rt_stream_or_device dflt;
clika_rt_tensor* bad = NULL;
clika_rt_error* e;
char code[128];
size_t clen = sizeof code;

memset(&dflt, 0, sizeof dflt);
dflt.kind = CLIKA_RT_STREAM_OR_DEVICE_DEFAULT;
e = api->tensor_full(shape, 1, 1.0, (clika_rt_data_type)9999, dflt, dflt, &bad);
if (e == NULL || bad != NULL) return 1; /* must fail typed, handle NULL */

if (api->error_code_name(e, code, &clen) == CLIKA_RT_STATUS_OK)
printf("failed with code %s\n", code);
api->error_free(e);
return 0;
}

What rides on top

The higher-level member families (io, tokenizer, onnx, qtensor, streams) follow the same three conventions: the table, out-parameters-last, the donation law. The Kotlin and Rust arms across the how-to guides are this ABI with each language's resource idiom on top (AutoCloseable, RAII lifetimes), so when a binding is missing a corner of the surface, the C call it would wrap is already here. The bundle's examples/c chapter walks this same program with every assertion spelled out.