Local models with MLX
An AJS agent never talks to a model directly. It calls an atom (llmPredict,
llmPredictBattery, storeVectorize), and the atom calls a capability the host passed to
vm.run. This chapter sets up a local model server on Apple silicon so those capabilities have
something real behind them.
A local model is useful while you build a host or write agents: calls cost nothing, the prompts and data never leave your machine, and it works offline. It is also what tjs-lang's own live LLM tests run against.
The capabilities come from the batteries (tjs-lang/batteries, re-exported from
tjs-lang): an optional module that supplies ready-made LLM, embedding and store capabilities
(getBatteries()) plus the atoms that read them (batteryAtoms), so a host does not have to
write its own. The batteries speak plain OpenAI-compatible HTTP to a local server, so which
server is a configuration choice, not a code change. The intended local backend is MLX
(Apple-silicon native, open source), served by mlx-omni-server. LM Studio works too and is
covered in LM Studio setup; use it for vision models and on machines that
are not Apple silicon.
The batteries send no API key (no Authorization header), so they cannot talk to a hosted API
that needs one. To use a hosted model, pass your own llmBattery or llm capability object to
vm.run instead; the atoms only care that it has the methods they call (see
The core llmPredict atom for the shape).
Setup
Use mlx-omni-server: the batteries need both chat and embeddings, and it serves both behind
one OpenAI-compatible API on one port (10240 by default). Verified on macOS 26.5, Apple silicon.
Run the server in one terminal and leave it running; it stays in the foreground.
# 1. Install. uv manages its own Python, so the system python3 (often Xcode's 3.9,
# too old for MLX) is neither used nor modified.
brew install uv
uv tool install mlx-omni-server
# 2. Put the server and its `hf` download tool on the PATH. uv installs the server
# binary in ~/.local/bin, and `hf` only inside the tool's own environment.
export PATH="$HOME/.local/share/uv/tools/mlx-omni-server/bin:$HOME/.local/bin:$PATH"
# 3. Download the models first. The server will not fetch them (see gotcha 2).
hf download mlx-community/Qwen2.5-1.5B-Instruct-4bit # chat, ~840MB
hf download mlx-community/bge-small-en-v1.5-bf16 # embed, ~65MB
# 4. Run the server.
mlx-omni-server --port 10240
Then, in a second terminal (the one that will run your host), point the batteries at the server and name both models:
export TJS_LLM_BASE_URL=http://localhost:10240/v1
export TJS_LLM_MODEL=mlx-community/Qwen2.5-1.5B-Instruct-4bit
export TJS_EMBEDDING_MODEL=mlx-community/bge-small-en-v1.5-bf16
The two models above are small enough to run on any Apple-silicon laptop. Any MLX chat or
embedding model from the mlx-community organisation on Hugging Face can replace them; the
model id you export is the Hugging Face repo id you downloaded.
Check the server from the second terminal before involving tjs-lang:
curl -s http://localhost:10240/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"mlx-community/Qwen2.5-1.5B-Instruct-4bit",
"messages":[{"role":"user","content":"Say hi"}],"max_tokens":10}'
A JSON reply with choices[0].message.content means the server and the model work. The first
request for a model loads it, so it can take several seconds.
The environment variables
The batteries read the first three once, when tjs-lang/batteries is first imported, so set
them before the host process starts.
| Variable | Default | Meaning |
|---|---|---|
TJS_LLM_BASE_URL |
http://localhost:1234/v1 |
the server's /v1 URL (the default is LM Studio's port) |
TJS_LLM_MODEL |
none | chat model id |
TJS_EMBEDDING_MODEL |
none | embedding model id |
TJS_CACHE_DIR |
OS cache directory | where the model-audit cache is written |
At startup the batteries run a model audit: they ask the server's /v1/models which models
it has, probe each one, and pick a chat model and an embedding model. The result is cached on
disk for 24 hours. When both TJS_LLM_MODEL and TJS_EMBEDDING_MODEL are set, the
batteries use them as given and skip the audit. When only one is set, they audit and add the
named one to whatever the audit found.
On mlx-omni-server the audit finds nothing (gotcha 1), so set both. With only one set, the
other kind of call fails: with only TJS_LLM_MODEL, every embed fails with
No embedding model available.; with only TJS_EMBEDDING_MODEL, every chat call fails with
No LLM available.
TJS_VISION_MODEL also exists, but getBatteries() does not read it; only the vision tests
and the playground do, and vision does not work on MLX today (see the last section).
Two gotchas
1. /v1/models returns an empty list, so you must name your models.
Symptom: the server answers the curl check, but every chat call from an agent fails with
No LLM available. and every embed with No embedding model available. At startup the console
shows three warnings: ⚠️ No embedding model found., ⚠️ No LLM found. and
⚠️ No LLM with structured output support found., each followed by
(set TJS_LLM_MODEL / TJS_EMBEDDING_MODEL if your server does not list models).
Cause: LM Studio lists the models it has loaded, which is what the audit reads.
mlx-omni-server loads a model on demand from the id in each request, so it has nothing to
list and returns {"object":"list","data":[]}. Filed upstream as
mlx-omni-server#130.
Fix: set both TJS_LLM_MODEL and TJS_EMBEDDING_MODEL, then restart the host.
The same No LLM available. appears when no server is listening at TJS_LLM_BASE_URL at all,
because the audit finds no models there either (the console then also shows
❌ Failed to connect to the local LLM server). With both models named there is no audit, so a
missing server shows up at the first call instead, as
No local LLM server reachable at http://localhost:10240/v1. Start your backend (MLX: mlx-omni-server, or LM Studio) or set TJS_LLM_BASE_URL.
Either way, run the curl check above first.
2. The server does not download models; it serves what is cached. It loads models with
Hugging Face's local_files_only=True, so a model id you have not downloaded fails with
"outgoing traffic has been disabled", even on a working connection. Download it with
hf download <id> (setup step 3; hf is on the PATH only after step 2).
One environment-specific failure: if pyenv is on your PATH without a usable python shim,
the server dies at startup with Failed to inspect Python interpreter … pyenv: python: command not found. Put a real Python first on the PATH. The export in setup step 2 does this, since
the tool's own environment comes first.
Wiring it into a VM
None of the code in this section runs on this site: it needs a model server on your machine.
getBatteries() (from tjs-lang) runs the audit, or takes the named models, and returns the
capabilities the battery atoms read. The audit runs once per process: later calls return the
same capabilities without asking the server again, so restart the host after you change the
environment variables or start the server.
| Capability key | Read by | Contains |
|---|---|---|
llmBattery |
llmPredictBattery, llmVision |
predict and embed |
vector |
storeVectorize |
embed only |
store |
storeCreateCollection, storeVectorAdd, storeSearch, storeGet, storeSet |
a key-value and vector store |
The store is held in memory and is not persisted: its contents are gone when the host process
exits. It has nothing to do with the model server; getBatteries() returns it whatever server
you use.
llmBattery and vector are present even when no model was found. Each call then fails at
run time with the errors in gotcha 1. They are missing (null and undefined) only when local
endpoints are disabled, on an HTTPS page (see Browser hosts); a battery atom
then fails with Capability 'llmBattery' missing or invalid., or for storeVectorize,
Capability 'vector' missing. Ensure vector battery is loaded.
All of these failures are ordinary atom errors. Without a try, the run ends with the message
in run.error. With one, the agent catches it: in AJS the caught value is the message string.
The battery atoms are not in the core VM; register them with new AgentVM(batteryAtoms). This
host runs an agent that asks the chat model a question, then embeds the answer.
llmPredictBattery returns an OpenAI-style chat message, so the text is in .content:
import { AgentVM, batteryAtoms, getBatteries, transpile } from 'tjs-lang'
// The agent, as AJS source text.
const source = `
function askAndEmbed({ question = 'What is a sandbox, in one sentence?' }) {
let reply = llmPredictBattery({
system: 'Answer in one short sentence.',
user: question,
})
let vector = storeVectorize({ text: reply.content })
return { answer: reply.content, dimensions: vector.length }
}
`
const vm = new AgentVM(batteryAtoms)
const batteries = await getBatteries()
const { ast } = transpile(source, { atoms: vm.atoms })
const run = await vm.run(ast, {}, { fuel: 500, capabilities: batteries })
console.log(run.error ? run.error.message : run.result)
// { answer: 'A sandbox is …', dimensions: 384 }
bge-small-en-v1.5 produces 384-dimensional vectors; the answer text varies from run to run.
Passing { atoms: vm.atoms } lets the transpiler check calls to atoms that are not in the core
set against their declared inputs. Without it, storeVectorize({ txt: reply.content })
transpiles cleanly and fails only at run time, with
'storeVectorize': its input does not have the shape the atom declares. With it, the
transpiler refuses the source and names the fix:
'storeVectorize' has no input 'txt'. It takes storeVectorize({ text, model }).
Fuel and time. Fuel counts work, not waiting: llmPredictBattery costs 100 fuel and
storeVectorize 20, however long the model takes, so 500 leaves room for the rest of the
agent. Time is limited separately. Each model call has its own timeout (120 seconds for
llmPredictBattery, llmPredict and llmVision; 60 seconds for storeVectorize), and a run
with no timeoutMs gets twice the slowest registered atom's timeout, 240 seconds here. That is
enough for the first call, which loads the model. Pass a timeoutMs only to lower that limit,
and leave room for the model load if you do.
The core llmPredict atom
There are two LLM atoms because they read different capabilities. llmPredictBattery reads
llmBattery, takes { system, user } and returns the whole chat message. The core atom
llmPredict({ prompt }) reads llm, a capability any host can supply, and returns a string.
Use llmPredictBattery with the batteries; use llmPredict when the agent must run on hosts
that supply only llm.
llm.predict(prompt, options) must return a string. getBatteries() does not supply
llm, so adapt the battery in the host. The battery's own method is
llmBattery.predict(system, user): a system prompt and the user text, returning the chat
message.
const { llmBattery, ...rest } = await getBatteries()
const llm = llmBattery && {
async predict(prompt) {
const message = await llmBattery.predict('You are a concise assistant.', prompt)
return message.content ?? ''
},
}
const { ast } = transpile(`
function summarize({ text = 'AJS agents run in a fuel-metered sandbox.' }) {
let summary = llmPredict({ prompt: 'Summarize in five words: ' + text })
return { summary }
}
`)
const run = await vm.run(ast, {}, {
fuel: 500,
capabilities: { ...rest, llmBattery, llm },
})
The agent gets the model's text back as a string. An adapter that returns the whole message
object instead fails the atom's output check (Output validation failed for 'llmPredict').
Browser hosts
A host running in a web page cannot use getBatteries() with mlx-omni-server as it stands.
Environment variables are not readable in a browser, so the batteries use the default base URL
(LM Studio's port, 1234) and cannot name models, and this server lists none. On an HTTPS page
the batteries also skip local endpoints entirely, because the browser would block an HTTPS page
calling a plain-HTTP server. For a browser host, use LM Studio as described in
LM Studio setup, or pass your own capability object. Either
way, a page calling localhost:10240 needs CORS, which mlx-omni-server has off by default:
start it with --cors-allow-origins="*" (or the page's origin).
Running the live LLM tests
This section is for contributors working in the tjs-lang repository; the published package has no test scripts.
bun run test:llm # live smoke: audit (or named models), one predict, one embed
This is the "our client still works against a real server" test. The HTTP client itself is
tested without any server (src/batteries/llm-transport.test.ts, an in-process fixture), so
bun run test:fast needs no MLX install.
bun run test:grok (which measures how often a small model writes valid AJS, and never fails
the build) can also target this server. Set GROK_MODEL and TJS_LLM_MODEL to the same MLX
model id: the test runs only if its model is in the batteries' model list, which on this server
holds only the named models. The default, google/gemma-4-e2b, is an LM Studio id.
Vision: use LM Studio for now
No MLX vision model has worked with these batteries yet. mlx-omni-server rejects the standard
OpenAI image_url content block
(mlx-omni-server#128) and routes only
the gemma4 architecture to its vision backend
(#129). The one gemma-4 build tried,
mlx-community/gemma-4-e2b-it-4bit, failed to load its weights on both mlx-omni-server and
mlx-openai-server. Run llmVision against LM Studio.