Accessing Apple's on-device AI from Kotlin using apfel

Search for a command to run...

No comments yet. Be the first to comment.
Sometimes you need to start several pieces of work at the same time and handle each result as soon as it is available. The fan-in pattern is a good fit for this: many concurrent producers send their r

In looking at enabling virtual threads for a Spring Boot application, I began to wonder how many virtual threads I could spawn compared to platform threads. I knew the answer would be "many more", but

Sometimes you need to run work in the background of a Spring Boot application. This may be work that should not block the HTTP request that triggered it, or work that is started by a scheduled process

Henry Brown's Notes on the JVM
4 posts
Practical notes on software development for the JVM, from Cape Town. Mostly Kotlin and Spring Boot: coroutines and structured concurrency, virtual threads, reactive programming, and the occasional Git trick or experiment with local AI models. Every post starts with something I actually needed to build, debug, or measure — so expect working code, real numbers, and honest trade-offs rather than theory. I also cover some of this on YouTube. All views are my own.
If you own a modern Mac with an Apple Silicon chip and Apple Intelligence enabled, then you already have access to a local foundation model. There is no separate model to download, no API key to create, and no token bill to worry about.
macOS Tahoe ships with Apple's on-device foundation model, roughly a 3B parameter LLM, and apfel gives it a very convenient front door.
It wraps Apple's FoundationModels framework and exposes the model in three ways:
In this post I will show how to use apfel from the command line, then call the same local model from a Kotlin script using the OpenAI-compatible API.
The installation is a single Homebrew command:
brew install apfel
apfel requires Apple Silicon, macOS Tahoe, and Apple Intelligence to be enabled. Once those pieces are in place, there is no extra configuration needed.
The important part is that the model runs on your Mac. For short prompts, small text transformations, command explanations, shell one-liners, translations, and local scripting tasks, that is very appealing. You get a useful local assistant without sending the prompt to a cloud model.
After installing apfel, you can prompt the model directly:
apfel --stream "Can you write some Kotlin code to print Hello World to the screen?"
You can also use it in the normal UNIX way by redirecting output:
apfel "Write a Kotlin script to print Hello World to the console without any explanation or anything else besides the script." > hello.kts
The model is still a small local model, so you should inspect what it writes, but the output is easier to pipe into a file.
The most interesting part for Kotlin is the server mode. apfel can expose the local model as an OpenAI-compatible API:
apfel --serve
By default, this starts a server on:
http://127.0.0.1:11434
If that port is already busy, or you just prefer a different port, you can specify one:
apfel --serve --port 11435
The server exposes the familiar chat completions endpoint:
POST /v1/chat/completions
It also supports useful OpenAI-style features such as streaming, GET /v1/models, tool calling, response_format: json_object, temperature, max_tokens, seed, and CORS for browser clients.
For this post, we only need the basic chat completions endpoint.
Because the server speaks the OpenAI API shape, calling it from Kotlin is straightforward.
The model name is apple-foundationmodel, and the API key can be any placeholder value because the local server does not require authentication by default.
Here is a complete Kotlin script:
@file:DependsOn("tools.jackson.core:jackson-databind:3.0.3")
import tools.jackson.databind.json.JsonMapper
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
val baseUrl = "http://localhost:11434/v1"
val apiKey = "unused"
val body = """
{
"model": "apple-foundationmodel",
"messages": [
{
"role": "user",
"content": "Translate to Korean: Thank you"
}
],
"temperature": 0.2,
"max_tokens": 100
}
""".trimIndent()
val request = HttpRequest.newBuilder(URI.create("$baseUrl/chat/completions"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer $apiKey")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build()
runCatching {
val response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString())
check(response.statusCode() in 200..299) {
"HTTP ${response.statusCode()}: ${response.body()}"
}
val root = JsonMapper.shared().readTree(response.body())
val content = root.path("choices").get(0)
?.path("message")?.path("content")?.asString()
?: error("Unexpected response shape: ${response.body()}")
println(content)
}.onFailure { System.err.println("Error: ${it.message}") }
Save this as apfel.main.kts.
With Kotlin on your path, you can run it directly:
kotlin apfel.main.kts
Here is the output I get:
감사합니다.
If you started apfel on the alternate port shown earlier, change the script to:
val baseUrl = "http://localhost:11435/v1"
That is the main benefit of the OpenAI-compatible API.
The code is ordinary HTTP client code.
If you already have a client abstraction that talks to OpenAI, you can often point it at http://localhost:11434/v1, use the apple-foundationmodel model name, and keep most of the rest of the code the same.
The server also exposes a models endpoint, which is useful when you want to verify that the server is running:
curl http://localhost:11434/v1/models
For apfel, the important model id is:
apple-foundationmodel
There is no model picker here in the same sense as a cloud API or a local model manager such as Ollama. apfel is intentionally wrapping the Apple model that is already available through macOS.
The local model is not meant to replace larger cloud-based models. It has a smaller context window, around 4,096 tokens for input and output combined, and it is not the tool I would choose for long documents, complex code generation, or tasks that need strong factual recall.
Where it becomes interesting is in small, local, everyday tasks:
The local context is the part I like most.
Cloud models cannot see the output of df -h, lsof, git log, or ls unless you explicitly send that data to them.
With apfel, you can pipe local command output into a local model and keep the whole loop on your machine.
For example:
df -h | apfel "Which volume is closest to full? Reply in one sentence."
or:
git log --oneline -10 | apfel "Summarise these commits in three bullets."
That makes apfel feel less like a replacement for a big hosted model and more like a small language-aware UNIX tool.
For me, the nice thing about apfel is not only that it provides CLI access to Apple's on-device model. It is that it exposes the model through interfaces developers already know: standard input and output for shell scripts, and an OpenAI-compatible API for application code.
That makes it easy to experiment from Kotlin.
Start apfel --serve, point a small Kotlin script at http://localhost:11434/v1, send a normal chat completions request, and you have a private local model call running on your Mac.
It will not replace cloud models for every use case, and it should not be treated as if it has the same capabilities as a larger hosted model. But for quick local automation, small text transforms, and developer workflows where privacy and zero setup matter, it is a very useful tool to have installed.