Apple’s Foundation Models framework (macOS 26) provides access to a ~3B parameter LLM that runs on-device, for free, with no API key. But here’s the catch: it only speaks Swift. And not just any Swift — Swift async.
Is your tooling in Python? No bindings. Rust? Nope. A shell script? Forget it. The cheapest LLM in the world (literally free) is trapped behind two barriers: the language and the concurrency model.
The obvious solution? Spin up a local HTTP server to expose the model as a REST API, like Ollama. But that’s like using a sledgehammer to crack a nut. A constantly running process, an occupied port, JSON round-tripping, and a curl for every trivial query. To classify a commit as fix or feat, you don’t need HTTP. You need a C function.
The 4-function dylib
So I built libfoundationmodels: a dynamic library that compiles Apple’s framework into a .dylib, exposing exactly 4 C functions:
int32_t fm_init(void);
int32_t fm_is_available(void);
int32_t fm_generate(system, prompt, output, output_len);
int32_t fm_classify(system, prompt, choices, output, output_len);
---
Well, 5 if you count `fm_generate_json`, but the idea is the same. Four concepts: initialize, check availability, generate free text, and classify by forcing a choice among options. All synchronous. All blocking. Input buffer, output buffer, return code. Classic C.
From Python, using it looks like this:
```python
import ctypes
fm = ctypes.CDLL("libfoundationmodels.dylib")
fm.fm_init()
buf = ctypes.create_string_buffer(256)
fm.fm_classify(
None, # no system prompt
b"fix: handle nil response in OAuth", # text to classify
b"fix\nfeat\nrefactor\ntest\ndocs", # predefined options
buf, 256
)
print(buf.value.decode()) # "fix"
That’s it. No requests. No urllib. No JSON. No server running. Just a call to a C function that internally wakes up your Mac’s Neural Engine, passes the text, and returns the response in a buffer. Typical latency: 200–800ms.
The interesting problem: async Swift in a synchronous C API
Here’s the fun part. The Foundation Models framework is async:
let result = try await session.respond(to: prompt)
That await is the problem. C doesn’t know what await is. C has no structured concurrency. C functions start at the top, do stuff, and return at the bottom. That’s it.
What you need is a bridge to convert an async call into a blocking one. Basically, the C thread needs to stop and wait until the Swift async function completes.
The classic solution is a semaphore. But in Swift 6, with strict concurrency enabled, using a DispatchSemaphore inside async code is risky business. The compiler won’t be happy — semaphores can block a thread in the cooperative thread pool, and that’s exactly what Swift 6 tries to prevent.
Here’s how to solve it:
private func blockingCall<T: Sendable>(
_ body: @Sendable @escaping () async -> T?
) -> T? {
let box = Mutex<T?>(nil)
let semaphore = DispatchSemaphore(value: 0)
Task {
let value = await body()
box.withLock { $0 = value }
semaphore.signal()
}
semaphore.wait()
return box.withLock { $0 }
}
Three components work together here:
Mutex<T?>(from Swift’sSynchronizationframework, available since macOS 15). This is a lock that protects the return value. Why not just use a simplevar? Because theTaskwrites to it from one thread, andsemaphore.wait()reads it from another. Without the mutex, you’ve got a data race. Swift 6 would yell at you.DispatchSemaphore. This is the signaling mechanism: the C thread blocks on.wait(), and theTaskcalls.signal()when it finishes. The trick here is that the semaphore blocks the calling thread (the one from C), not a thread in the cooperative pool. TheTaskruns freely on its cooperative thread.@_cdecl. This attribute tells the Swift compiler: “export this function with C name mangling.” Thanks to this,fm_generateappears in the.dylib’s symbol table as a plain C function callable from any language.
The combination works elegantly because each part solves a specific problem: the mutex protects shared memory, the semaphore synchronizes threads, and @_cdecl exposes the interface. No black magic — just solid plumbing.
Why it works (and why it’s not a hack)
The argument against using DispatchSemaphore in modern Swift is totally valid: if you block a thread in the cooperative thread pool, you could cause a deadlock because the pool has a fixed number of threads. If all threads are blocked waiting on semaphores, none can execute the Tasks that call .signal().
But in this case, the .wait() is done by the C thread — an external thread that isn’t part of the pool. The Task executes in the Swift pool, does its async work, and signals. There’s no risk of starving the pool because the blocked thread doesn’t belong to the pool.
It’s like a waiter (the C thread) ordering a dish from the kitchen (the async Task) and waiting at the counter. The kitchen has its own chefs (the thread pool) and never gets stuck because a waiter is waiting outside. The waiter isn’t occupying a stove.
@_cdecl: the undocumented attribute
A quick note on @_cdecl. Notice the underscore: @_cdecl, not @cdecl. The underscore means “internal, unstable, may change without notice.” It’s been this way since Swift 2, and it’s the de facto standard way to export Swift functions to C.
Swift Evolution approved SE-0495 to formalize @cdecl (without the underscore) as part of the language. Initial support arrived in Swift 6.2, and it will stabilize in Swift 6.3, alongside a new family of @c attributes for C/C++ interop.
Does that mean @_cdecl will stop working? Not anytime soon. But if you’re building something to last, migrate to @cdecl as soon as your toolchain supports it. The behavior is identical; only the name changes.
Forced classification: the choices trick
The library’s most useful function isn’t fm_generate (free text). It’s fm_classify:
fm_classify(
"You classify git commit messages.", // system prompt
"fix: handle nil in OAuth refresh", // text to classify
"fix\nfeat\nrefactor\ntest\ndocs\nchore", // allowed options
buffer, sizeof(buffer)
);
Internally, fm_classify does something Apple’s framework doesn’t expose at the C level: it builds a prompt that forces the model to select one of the options and then validates the response:
let constrainedPrompt = """
\(promptStr)
You MUST reply with exactly one of these values, nothing else:
\(choiceList.joined(separator: "\n"))
"""
// After generating, validate:
if choiceList.contains(where: {
$0.caseInsensitiveCompare(raw) == .orderedSame
}) {
return raw
}
// Fallback: find the option within the response
return choiceList.first {
raw.localizedCaseInsensitiveContains($0)
} ?? raw
This is constrained generation on a budget: instead of using @Generable’s guided generation (which requires defining a Swift struct), you tell the model “pick one of these” and then validate it did. If the model responds “The answer is fix” instead of just “fix,” the fallback catches it.
Is it as robust as @Generable? No. But from C, you can’t define a @Generable struct. And for simple classification — 80% of tooling use cases — it works.
Smoke tests: 9 out of 9
The smoke tests are a 78-line C file that verifies:
fm_is_available()returns 0 or 1 (not garbage).fm_init()returns 0 if the model is available.fm_classifyreturns positive bytes and a non-empty buffer.- Classification produces a reasonable response.
fm_generategenerates text.- A buffer that’s too small returns -3 (truncated), not a crash.
fm_generate_jsonproduces valid JSON.
9 assertions, 9 pass. On a Mac with Apple Intelligence enabled, make test takes about 3 seconds. If Apple Intelligence isn’t available, the generation tests are skipped, and only fm_is_available() is checked to return 0. The library doesn’t crash on unsupported hardware — it just returns -1.
Why not an HTTP server?
It’s a fair question. Ollama, LM Studio, llama.cpp — all expose models as local HTTP servers. So why is a C dylib better?
| HTTP Server | C dylib | |
|---|---|---|
| Latency | ~10-50ms overhead (TCP + JSON parse) | ~0 (function call) |
| Process | Requires a daemon running | Loads on demand |
| Dependencies | Free port, HTTP client | A single line of ctypes/extern "C" |
| Integration | HTTP from any language | FFI from any language |
| Memory Overhead | Separate process (~50-200MB) | Loads into your process |
For a service handling multiple concurrent clients, an HTTP server makes sense. But for developer tooling — a pre-commit hook, a local CI script, a menu bar tool — you don’t need a server. You just need a function you can call, that gives you a response, and then gets out of the way.
It’s like installing PostgreSQL to store a grocery list versus using SQLite. Sometimes the simple solution is the right one.
How to use it in your language
The library produces a single file: libfoundationmodels.dylib. And a single header: foundationmodels.h. Any language with C FFI support can use it:
- Python:
ctypes.CDLL("libfoundationmodels.dylib") - Rust:
extern "C" { fn fm_classify(...) -> i32; } - Go:
// #cgo LDFLAGS: -lfoundationmodels+import "C" - Ruby:
FFI::Librarywithffi_lib "foundationmodels" - Node.js:
ffi-napiornode-ffi
The pattern is the same everywhere: load the dylib, declare function signatures, call the function, read the buffer. If you know how to use ctypes in Python or extern "C" in Rust, you already know how to use this.
What it complements (and what it doesn’t replace)
This library doesn’t replace Ollama or llama.cpp. It doesn’t run arbitrary models, doesn’t support LoRA, and doesn’t stream results. It’s a minimal wrapper for the model Apple already gave you, tailored for the specific use case of developer tooling where you want quick classification, short-form generation, or structured JSON without standing up infrastructure.
If my last post was “your Mac has a free LLM you’re not using,” this one is “and now you can use it from any language, not just Swift.” Layer 1 of the layered model architecture has just been opened up to the entire ecosystem.
Four C functions. One dylib. No server. No API key. No dependencies. Sometimes the best tool is the one that doesn’t need a manual.
Try it out
git clone https://github.com/frr149/libfoundationmodels
cd libfoundationmodels
make test # 9 smoke tests (~3s)
make examples # example in C
python3 examples/classify.py # example in Python
Requirements: Apple Silicon, macOS 26, Apple Intelligence enabled. If you don’t have macOS 26, tests are skipped instead of failing.