Skip to content
tokenworm ★ GitHub

← Back to writing

C ABI as the universal AI SDK boundary

tokenworm / neullabs · ·
ffic-abiarchitecture

If you read the tokenworm README in one direction, it looks like a CLI tool with three language bindings. If you read it in the other direction, the CLI is itself a binding — one of four — and the actual product is a Zig-compiled shared library reached through a C ABI defined in src/lib/ffi.zig.

That is the load-bearing decision. The implementation language is Zig 0.16 because Zig has good C interop. The CLI exists because it is the most common surface. The Python, TypeScript, and Go SDKs exist because most agent harnesses are written in one of those three. But the boundary that holds the system together is the C ABI.

This post is about why that boundary, in particular, makes sense for an AI agent SDK.

What the C ABI actually looks like

The README documents the surface as a handful of exported symbols: tokenworm_init, tokenworm_run, tokenworm_cancel, the session export/import calls, and the cleanup primitives. That is roughly the shape:

void* tokenworm_init(const char* provider, const char* model);
void  tokenworm_run(void* agent, const char* prompt, callback_t on_chunk);
void  tokenworm_cancel(void* agent);
void* tokenworm_export_session(void* agent, size_t* out_len);
void* tokenworm_from_session(const void* data, size_t len);
void  tokenworm_free(void* ptr);

(The exact symbol list is in the repository; the shape above is what the README describes.)

That is a deliberately narrow surface. There is no exposed configuration object. There is no exposed conversation type. There is one opaque agent handle, a string in, callbacks out, and an explicit free for everything the library allocates. That is the entire foreign-function contract.

Every language binding wraps that contract. The Python Agent(provider=...) constructor calls tokenworm_init. The TypeScript for await (const chunk of agent.run(...)) adapts the chunk callback into an async iterator. The Go agent.Run(prompt) collects the callbacks into a slice. None of those bindings reimplements the agent loop. None reimplements the tool dispatch. None reimplements the provider abstraction.

Why this matters more for agent SDKs than for, say, an HTTP client

Lots of libraries have C ABIs. SQLite has one. libcurl has one. They are great, and they are well understood.

What makes the C ABI choice particularly load-bearing for an agent SDK is the surface area the SDK has to expose. An agent is not a stateless function call. It is a long-running, stateful, streaming, cancellable thing that holds a conversation history, talks to a model provider over the network, spawns subprocesses, manages a sandbox, and emits structured trace events as it goes. Every one of those concerns is a place where you could ship divergent behavior across language bindings.

A typical multi-language SDK approach reimplements the agent loop in each language. The Python version has its own provider classes. The TypeScript version has its own conversation model. The Go version has its own tool dispatch. The behavior drifts. The bug fixes have to be ported. The feature surfaces lag each other by months.

tokenworm’s approach inverts that. There is one agent loop, written in Zig. There is one provider implementation per provider, written in Zig. There is one tool implementation per tool, written in Zig. The sandbox backends are Zig. The session format is Zig. The language bindings are smaller than the documentation for the language bindings.

That has three downstream consequences worth naming.

Consequence 1: a fix in the core is a fix everywhere

When a bug is fixed in src/core/agent.zig — say, an off-by-one in the iteration counter, or a wrong content-type in an HTTP request — the next release of the shared library carries the fix. Every SDK picks it up at the next version bump, without any of the bindings doing any work.

This is not unique to tokenworm; it is what a well-defined ABI buys you anywhere. But for an agent SDK specifically it matters because the agent loop is a fast-moving target. Provider APIs change. Tool behavior gets tuned. The sandbox layer evolves. Keeping three language reimplementations in sync with that pace is, in practice, where most multi-language SDKs go wrong.

Consequence 2: the session format is portable for free

The README highlights that .tworm sessions can be exported from the CLI and resumed in Python. Or exported from Python and resumed in TypeScript. Or shared between Go and the CLI.

This is not a feature anyone implemented per binding. It falls out of the architecture. The session format is defined in Zig. Every binding hands a byte buffer to the same tokenworm_from_session function. The function deserializes the bytes in exactly the same way every time, because it is exactly the same code. The portable session format is a side effect of the C ABI being the source of truth.

By contrast, if Python and TypeScript each implemented their own session serialization in their own native language, “share a session across SDKs” would be a feature you would have to design for, version, and test. Here it is just a property of the architecture.

Consequence 3: new language bindings are mostly bookkeeping

The README mentions “5+ language SDKs” specifically as a counter to the “2 (Python, TS)” reported for the alternatives. The implicit claim is that adding more languages is cheap.

This is true to the extent that the C ABI is small and the language has FFI. A Rust binding to tokenworm would be straightforward: cc + bindgen + a small wrapper crate. A Swift binding would be straightforward. A C# binding via P/Invoke would be straightforward. A Kotlin binding via JNI would be straightforward.

The cost of each new language is mostly the binding ergonomics — making the wrapped API feel native to that language. The agent itself is already implemented. The provider list is already implemented. The tool set is already implemented. You are mapping tokenworm_run to an idiomatic async surface in the host language and stopping there.

That is qualitatively different from “we want Java support, so we port the agent loop to Java”.

Why C, specifically

A reasonable question: why a C ABI, rather than a higher-level cross-language format like gRPC, or a shared IPC channel, or a WebAssembly module?

The README’s deployment story answers most of it. tokenworm wants to be embedded in the same process as the calling code. No network. No serialization overhead. No subprocess. The CLI calls into the library directly. The Python SDK calls into the library directly. The library is loaded once into the process and the agent runs there.

That rules out gRPC and other RPC formats — they introduce serialization overhead and a separate process. It rules out subprocess-based IPC — that is what the alternatives effectively do when they subprocess.Popen a Node script. It leaves WebAssembly, which is a coherent answer for a different set of constraints (web embedding, sandboxing) but pays a cost in performance and tool access that does not align with what tokenworm is optimising for.

The C ABI is the cheapest in-process boundary that any modern language understands. Every language with FFI knows how to call C. The cost is the discipline of writing the ABI in C-shaped terms — opaque handles, callbacks instead of generators, explicit lifetime management. Zig makes that discipline manageable; the README’s choice to use Zig is downstream of the choice to make the C ABI the contract.

The cost

This design is not free. It pushes some friction onto the language-binding authors. Some patterns that feel natural in Python — context managers, async generators, exception propagation — are not free across the FFI boundary. The Python SDK is the one that has to bridge async for chunk to a C callback; the TypeScript SDK is the one that has to bridge for await to a C callback. That work has to be done well in every binding, and the cost is borne by the binding maintainers.

It also forces some discipline on the core. Anything you expose through the C ABI is now a versioned contract. Adding a parameter is a breaking change unless you do it through a struct with a length prefix. Removing a function is a breaking change. The agent loop can evolve freely; the ABI cannot.

For an SDK that wants to outlive any individual language fashion, that discipline is the price of admission. The bet is that the language ecosystems will keep changing — Node will deprecate APIs, Python will major-version, Go will get generics again — and the C ABI will keep meaning the same thing.

That is not glamorous. It is what makes the rest of the system stable.