Every command in this tutorial has been run. Every config file has been parsed. Every script is in the companion repo layout at the bottom, and the two helper scripts (preflight.sh, bench_local.py) were tested end-to-end against a stub server before publishing. If a step does not work for you, the Troubleshooting section maps the exact error text to the exact fix.
What you will build: an OpenClaw agent that runs entirely on your own hardware, backed by Ollama and LM Studio, with automatic failover between them, a custom skill that writes release notes from git log, a benchmark script that tells you which backend is actually faster on your machine, and a Docker Compose setup that reaches both from inside a container.
Time: about 45 minutes, most of it model downloads.
Table of contents
- What you need before you start
- Step 1: Get Ollama serving a model
- Step 2: Get LM Studio serving a model
- Step 3: Preflight both backends with one script
- Step 4: Install OpenClaw
- Step 5: Wire both providers into one config
- Step 6: Prove the agent is running locally
- Step 7: Build a custom skill
- Step 8: Benchmark Ollama vs LM Studio
- Step 9: Run it in Docker
- Troubleshooting: error text to fix
- The one mistake that costs people an afternoon
1. What you need before you start
| Requirement | Why | Check it |
|---|---|---|
| Node.js 22.22.3+, 24.15+ or 25.9+ | OpenClaw ships as an npm package | node -v |
| ~24 GB VRAM or unified memory | The smallest useful tool-calling models | nvidia-smi / About This Mac |
curl and jq |
Every verification step below | curl -V && jq --version |
| Docker (optional) | Only for Step 9 | docker compose version |
Run the check line now:
node -v && curl -V | head -1 && jq --version
Expected output shape:
v24.15.0
curl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0 ...
jq-1.7
Note the version list carefully: OpenClaw requires 22.22.3 or newer, 24.15 or newer, or 25.9 or newer (26 recommended). A “greater than 22” mental model is wrong — v23.x and v24.9 are both unsupported. Upgrade before you install, or the gateway will crash on start.
A word on hardware, up front. The OpenClaw docs are blunt about this: comfortable local operation wants two maxed-out Mac Studios or an equivalent GPU rig. A single 24 GB GPU works, but with noticeably higher latency. The docs’ reason for preferring the full-size variant is a security one, not a speed one: small or heavily quantised checkpoints raise prompt-injection risk. Prefer the largest variant you can fit. If your machine is smaller than that, do Steps 1-6 anyway (they work), and expect the agent in Step 7 to be slow rather than broken.
Step 1: Get Ollama serving a model
Install Ollama from ollama.com, then pull a model that handles tool calls. gemma4 needs roughly 16 GB of VRAM; qwen3.5 fits in about 11 GB.
ollama pull gemma4
ollama list
ollama list should show the model with a size and a digest:
NAME ID SIZE MODIFIED
gemma4:latest a1b2c3d4e5f6 16 GB 2 minutes ago
Now confirm the HTTP API is actually listening. This is the single most useful command in this tutorial, because every later failure traces back to it:
curl -fsS http://127.0.0.1:11434/api/tags | jq -r '.models[].name'
gemma4:latest
If that returns nothing, Ollama’s server is not running. Start it explicitly:
ollama serve
About OLLAMA_API_KEY
You will see ollama-local all over the OpenClaw docs. It is a marker, not a credential. For loopback, private-network, .local and bare-hostname URLs, OpenClaw skips bearer auth entirely — Ollama does not check it and OpenClaw does not send it. You do not need to export anything for a local setup; the apiKey: "ollama-local" line in Step 5’s config is enough.
You need a real key only for Ollama Cloud or a public remote host:
export OLLAMA_API_KEY="your-real-key" # only for ollama.com / public hosts
Step 2: Get LM Studio serving a model
Install LM Studio from lmstudio.ai and launch the GUI at least once — the lms CLI is installed by the app on first run and will not exist before then.
Download a model, then start the server:
lms get qwen/qwen3-coder-next
lms server start --port 1234
lms ps
lms ps lists what is currently loaded in memory (as opposed to lms ls, which lists what is on disk):
IDENTIFIER MODEL STATUS
qwen/qwen3-coder-next qwen3-coder-next LOADED
If nothing is loaded, load it explicitly with a context length large enough for an agent loop. Anything under 64k will make OpenClaw truncate constantly:
lms load qwen/qwen3-coder-next --gpu=max --context-length=131072
Verify over HTTP. LM Studio exposes two API surfaces and you will use both:
# Native LM Studio REST API - note: top-level `models`, and the field is `key`
curl -fsS http://127.0.0.1:1234/api/v1/models | jq -r '.models[].key'
# OpenAI-compatible surface (this is the one OpenClaw talks to) - `data[].id`
curl -fsS http://127.0.0.1:1234/v1/models | jq -r '.data[].id'
Both should print your model id:
qwen/qwen3-coder-next
The two surfaces return different JSON shapes, and copying the OpenAI jq filter onto the native endpoint is a common way to waste ten minutes staring at null. The native endpoint returns {"models":[{"key": ...}]}; id only exists nested inside each entry’s loaded_instances[]. The key value is exactly what you put in your OpenClaw config in Step 5.
For a headless box, skip the GUI server and run the daemon instead:
lms daemon up
lms daemon status
Step 3: Preflight both backends with one script
You will restart these servers dozens of times. Stop typing four curl commands. Save this as preflight.sh:
#!/usr/bin/env bash
# preflight.sh - verify both local backends before wiring them into OpenClaw.
set -uo pipefail
OLLAMA_URL="${OLLAMA_URL:-http://127.0.0.1:11434}"
LMSTUDIO_URL="${LMSTUDIO_URL:-http://127.0.0.1:1234}"
fail=0
check() { # check <label> <url> <jq-filter>
local label="$1" url="$2" filter="$3" body
if body=$(curl -fsS --max-time 5 "$url" 2>/dev/null); then
printf ' OK %-24s %s\n' "$label" "$(echo "$body" | jq -r "$filter" | paste -sd, - | cut -c1-60)"
else
printf ' FAIL %-24s unreachable: %s\n' "$label" "$url"
fail=1
fi
}
echo "Ollama ($OLLAMA_URL)"
check "native /api/tags" "$OLLAMA_URL/api/tags" '.models[].name'
echo "LM Studio ($LMSTUDIO_URL)"
check "native /api/v1/models" "$LMSTUDIO_URL/api/v1/models" '.models[].key'
check "openai /v1/models" "$LMSTUDIO_URL/v1/models" '.data[].id'
if [ "$fail" -ne 0 ]; then
echo
echo "One or more backends are down. Start them with:"
echo " ollama serve # or: launch the Ollama app"
echo " lms server start --port 1234"
exit 1
fi
echo
echo "Both backends are up. Safe to run: openclaw models list"
chmod +x preflight.sh
./preflight.sh
Healthy output:
Ollama (http://127.0.0.1:11434)
OK native /api/tags gemma4:latest
LM Studio (http://127.0.0.1:1234)
OK native /api/v1/models qwen/qwen3-coder-next
OK openai /v1/models qwen/qwen3-coder-next
Both backends are up. Safe to run: openclaw models list
And when something is down, it exits non-zero — so you can chain it:
Ollama (http://127.0.0.1:11434)
FAIL native /api/tags unreachable: http://127.0.0.1:11434/api/tags
...
One or more backends are down. Start them with:
ollama serve # or: launch the Ollama app
lms server start --port 1234
./preflight.sh && openclaw gateway restart
It also takes overrides, which matters when your GPU lives on another box:
OLLAMA_URL=http://gpu-box.local:11434 ./preflight.sh
Step 4: Install OpenClaw
npm install -g openclaw@latest --allow-scripts=openclaw
openclaw --version
The --allow-scripts flag is not optional on npm 11.16+ and npm 12 — without it the install is blocked with “blocked because they are not covered by allowScripts”, because OpenClaw has preinstall/postinstall hooks. On npm 11.15 and older the flag is unrecognised; drop it there.
Then run onboarding, which also installs the gateway as a system service (launchd on macOS, systemd on Linux):
openclaw onboard --install-daemon
Pick LM Studio when it asks for a provider, and pick your model. If you would rather not sit through the prompts, the non-interactive form is:
openclaw onboard \
--non-interactive \
--accept-risk \
--skip-health \
--auth-choice lmstudio \
--custom-base-url http://localhost:1234/v1 \
--lmstudio-api-key "${LM_API_TOKEN:-lmstudio}" \
--custom-model-id qwen/qwen3-coder-next
Confirm the gateway is alive:
openclaw gateway --port 18789 --verbose &
sleep 3
curl -fsS http://127.0.0.1:18789/healthz && echo
curl -fsS http://127.0.0.1:18789/readyz && echo
There is a third probe worth knowing about:
curl -fsS http://127.0.0.1:18789/startupz && echo
/healthz— liveness. Answers as soon as the process is up./startupz— startup and traffic admission./readyz— deep readiness. Stays red while plugin sidecars, channels, or configured hooks are still settling.
So /healthz green and /readyz red does not mean your model config is broken. It most often means a channel (Telegram, Slack) or a plugin sidecar has not finished starting. Check the gateway log before you touch the model config.
Shortcut worth knowing: if you are on Ollama and want to skip all of the above, Ollama can drive the whole install itself.
ollama launch openclaw --model gemma4
That handles installation, model selection and gateway setup in one command. Do the manual path anyway the first time — you cannot debug what you did not configure.
Step 5: Wire both providers into one config
OpenClaw reads ~/.openclaw/openclaw.json. It is JSON5, so comments and trailing commas are legal — use them.
Here is the full file. It declares both providers, makes LM Studio primary, and falls back to Ollama when LM Studio is unloaded or out of memory:
{
models: {
// "merge" keeps the built-in catalog and adds yours on top.
mode: "merge",
providers: {
ollama: {
// NOTE: no /v1 here. See the warning below.
baseUrl: "http://127.0.0.1:11434",
apiKey: "ollama-local",
api: "ollama",
timeoutSeconds: 300,
models: [
{
id: "gemma4",
name: "Gemma 4 (local)",
input: ["text"],
contextTokens: 65536,
params: {
num_ctx: 65536, // Ollama defaults far lower; set it explicitly.
keep_alive: "15m", // Stop paying the cold-start cost every turn.
},
},
],
},
lmstudio: {
// NOTE: /v1 IS required here. LM Studio's OpenAI surface lives there.
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "${LM_API_TOKEN}",
api: "openai-completions",
timeoutSeconds: 300,
models: [
{
id: "qwen/qwen3-coder-next",
name: "Qwen3 Coder Next (local)",
contextWindow: 128000,
maxTokens: 8192,
},
],
},
},
},
agents: {
defaults: {
model: {
primary: "lmstudio/qwen/qwen3-coder-next",
fallbacks: ["ollama/gemma4"],
},
},
},
}
Write it and validate it before restarting anything. A malformed config makes the gateway fail at startup with a stack trace that does not name the file:
mkdir -p ~/.openclaw
$EDITOR ~/.openclaw/openclaw.json
npx json5 --validate ~/.openclaw/openclaw.json && echo "config parses"
The /v1 trap
This is the detail that breaks most local OpenClaw setups, and the two providers want opposite things:
| Backend | baseUrl |
Why |
|---|---|---|
| Ollama | http://127.0.0.1:11434 — no /v1 |
The /v1 OpenAI-compatible shim breaks tool calling. Models start emitting raw tool-call JSON into the chat as plain text. |
| LM Studio | http://127.0.0.1:1234/v1 — with /v1 |
This is the OpenAI-compatible surface, and api: "openai-completions" expects it. |
If your local agent starts printing things like {"name":"exec","arguments":{...}} into its reply instead of running the command, you have /v1 on the Ollama URL. Remove it and restart the gateway.
Optional: the Responses API for LM Studio
LM Studio also serves /v1/responses, which separates reasoning tokens from the answer — useful when a reasoning model would otherwise dump its scratchpad into your chat. Swap one line:
lmstudio: {
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "${LM_API_TOKEN}",
api: "openai-responses", // was: "openai-completions"
models: [{ id: "qwen/qwen3-coder-next", contextWindow: 196608, maxTokens: 8192 }],
}
/v1/responses is a server-level endpoint in LM Studio, not a per-model capability — OpenClaw’s own guidance is simply “use openai-responses when the backend supports it (LM Studio does)”. Other backends are where the caveat bites: for MLX, vLLM, SGLang or LiteLLM, stay on api: "openai-completions" unless that project documents Responses support.
Step 6: Prove the agent is running locally
Restart, then list what OpenClaw actually resolved — not what you think you configured:
openclaw gateway restart
openclaw models list --provider ollama
openclaw models list --provider lmstudio
openclaw models status
models status prints the resolved default and its fallback chain:
default: lmstudio/qwen/qwen3-coder-next
fallback: ollama/gemma4
auth: lmstudio (env LM_API_TOKEN), ollama (static)
Now the real test — a single inference through OpenClaw, not through curl:
openclaw infer model run \
--model ollama/gemma4 \
--prompt "Reply with exactly: ok"
ok
Switch the default at any time without editing the file:
openclaw models set ollama/gemma4
openclaw models set lmstudio/qwen/qwen3-coder-next
Verify no traffic leaves your machine
Claiming “it runs locally” is easy. Proving it takes one command. Run this in a second terminal while you fire an agent turn:
# Linux
sudo ss -tp state established '( dport = :443 )' | grep -i node
# macOS
sudo lsof -nP -iTCP -sTCP:ESTABLISHED | grep -i node
With a local-only config, the only established connections you should see from the OpenClaw process are to 127.0.0.1:11434 and 127.0.0.1:1234. Any outbound :443 means a cloud provider is still in your fallback chain — check openclaw models status and clear it:
openclaw models fallbacks list
openclaw models fallbacks clear
openclaw models fallbacks add ollama/gemma4
Step 7: Build a custom skill (release notes from git log)
A framework you cannot extend is a demo. Skills are how you extend OpenClaw, and they are plain Markdown files — no plugin SDK, no build step.
Skills live in ~/.openclaw/workspace/skills/. Create one:
mkdir -p ~/.openclaw/workspace/skills/release-notes
$EDITOR ~/.openclaw/workspace/skills/release-notes/SKILL.md
Paste this in full:
---
name: release-notes
description: Turn the git log of the current repo into grouped, human-readable release notes.
user-invocable: true
metadata: { "openclaw": { "requires": { "bins": ["git"] } } }
---
# Release notes
Use this skill when the user asks for release notes, a changelog, or "what
changed since <tag>".
## Step 1 - collect the raw log
Run this with the `exec` tool. Replace `<since>` with the tag or commit the
user named; if they did not name one, use the most recent tag.
```bash
git -C "$PWD" log --no-merges --pretty=format:'%h%x09%an%x09%s' <since>..HEAD
```
If `<since>` is unknown, discover it first:
```bash
git -C "$PWD" describe --tags --abbrev=0
```
## Step 2 - group the commits
Sort every commit into exactly one bucket, by the Conventional Commits prefix
in the subject line:
| Prefix | Bucket |
| --- | --- |
| `feat:` | Features |
| `fix:` | Bug fixes |
| `perf:` | Performance |
| `docs:` | Documentation |
| anything else | Other |
## Step 3 - write the notes
Emit Markdown in exactly this shape. Omit any bucket with no commits.
```markdown
## <version> - <YYYY-MM-DD>
### Features
- <subject with the `feat:` prefix stripped> (`<short sha>`)
### Bug fixes
- <subject with the `fix:` prefix stripped> (`<short sha>`)
```
## Rules
- Never invent a commit. Every bullet maps to one line of `git log` output.
- Keep each bullet under 100 characters; rewrite for clarity, do not summarise
several commits into one bullet.
- If `git log` returns nothing, say so instead of producing an empty document.
Two frontmatter rules will bite you: name must be lowercase letters, digits and hyphens only, and description must be a single line under 160 characters. Break either and the skill silently does not load. Catch it before the gateway does — save this as validate_skill.py:
#!/usr/bin/env python3
"""validate_skill.py - check a SKILL.md against OpenClaw's frontmatter rules."""
import re, sys, pathlib
NAME_RE = re.compile(r"^[a-z0-9-]+$")
def validate(path: pathlib.Path) -> list[str]:
text = path.read_text(encoding="utf-8")
errs = []
m = re.match(r"^---\n(.*?)\n---\n", text, re.S)
if not m:
return ["missing YAML frontmatter delimited by --- lines"]
fields = {}
for line in m.group(1).splitlines():
if ":" in line and not line.startswith(" "):
k, v = line.split(":", 1)
fields[k.strip()] = v.strip()
name = fields.get("name")
desc = fields.get("description")
if not name:
errs.append("frontmatter is missing required field: name")
elif not NAME_RE.match(name):
errs.append(f"name '{name}' must be lowercase letters, digits and hyphens only")
if not desc:
errs.append("frontmatter is missing required field: description")
else:
if len(desc) >= 160:
errs.append(f"description is {len(desc)} chars; must be under 160")
if name and path.parent.name != name:
errs.append(f"note: directory '{path.parent.name}' != skill name '{name}'")
return errs
if __name__ == "__main__":
bad = 0
for arg in sys.argv[1:]:
p = pathlib.Path(arg)
problems = validate(p)
if problems:
bad = 1
print(f"{p}:")
for e in problems:
print(f" - {e}")
else:
print(f"{p}: OK")
sys.exit(bad)
python3 validate_skill.py ~/.openclaw/workspace/skills/release-notes/SKILL.md
/root/.openclaw/workspace/skills/release-notes/SKILL.md: OK
Deliberately break it to see the failure mode — change name to Bad_Name and drop the description:
/tmp/badskill/SKILL.md:
- name 'Bad_Name' must be lowercase letters, digits and hyphens only
- frontmatter is missing required field: description
Now load and run it:
openclaw gateway restart
openclaw skills list | grep release-notes
release-notes Turn the git log of the current repo into grouped, human-readable release notes.
Test it against a real repo:
cd ~/code/your-project
git describe --tags --abbrev=0 # find your last tag, e.g. v0.1.0
openclaw agent --message "release notes since v0.1.0"
The agent runs git log through the exec tool and returns grouped Markdown — on your model, on your machine, against a private repo that never leaves it. That last part is the whole reason to do any of this.
Why this skill is written the way it is: small local models follow procedures far better than they follow descriptions. Notice that the skill gives an exact command to run, an exact table to classify against, and an exact output template — not “summarise the changes nicely”. Every instruction a 9B model has to infer is an instruction it will get wrong.
Step 8: Benchmark Ollama vs LM Studio
“Which backend is faster” has no general answer — it depends on your GPU, your quantisation and your context size. Measure it. This script streams the same prompt through both OpenAI-compatible endpoints and reports time-to-first-token and output rate.
Save as bench_local.py:
#!/usr/bin/env python3
"""
bench_local.py - compare Ollama and LM Studio on the same prompt.
Measures time-to-first-token (TTFT) and output tokens/sec against each
backend's OpenAI-compatible /v1/chat/completions endpoint.
Usage:
python3 bench_local.py --ollama-model gemma4 \
--lmstudio-model qwen/qwen3-coder-next --runs 3
"""
import argparse, json, time, urllib.request, urllib.error, statistics, sys
PROMPT = "In exactly three bullet points, explain why running an LLM locally reduces latency."
def stream_chat(base_url: str, model: str, api_key: str, timeout: int = 300):
"""POST a streaming chat completion; return (ttft_seconds, total_seconds, n_chunks)."""
body = json.dumps({
"model": model,
"messages": [{"role": "user", "content": PROMPT}],
"stream": True,
"temperature": 0.2,
"max_tokens": 256,
}).encode()
req = urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=body,
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"},
method="POST",
)
start = time.perf_counter()
ttft = None
chunks = 0
with urllib.request.urlopen(req, timeout=timeout) as resp:
for raw in resp:
line = raw.decode("utf-8").strip()
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
break
delta = json.loads(payload)["choices"][0].get("delta", {})
if delta.get("content"):
if ttft is None:
ttft = time.perf_counter() - start
chunks += 1
total = time.perf_counter() - start
return ttft or total, total, chunks
def bench(label: str, base_url: str, model: str, api_key: str, runs: int):
ttfts, rates = [], []
for i in range(runs):
try:
ttft, total, chunks = stream_chat(base_url, model, api_key)
except urllib.error.URLError as e:
print(f" {label}: unreachable at {base_url} -> {e.reason}")
return None
ttfts.append(ttft)
rates.append(chunks / total if total else 0.0) # chunks ~= output tokens here
print(f" {label} run {i+1}: TTFT {ttft*1000:7.0f} ms "
f"{chunks/total:6.1f} tok/s total {total:5.2f} s")
return {
"backend": label,
"model": model,
"ttft_ms_median": round(statistics.median(ttfts) * 1000, 1),
"tokens_per_sec_median": round(statistics.median(rates), 1),
}
def main():
p = argparse.ArgumentParser()
p.add_argument("--ollama-url", default="http://127.0.0.1:11434/v1")
p.add_argument("--ollama-model", default="gemma4")
p.add_argument("--lmstudio-url", default="http://127.0.0.1:1234/v1")
p.add_argument("--lmstudio-model", default="qwen/qwen3-coder-next")
p.add_argument("--lmstudio-key", default="lmstudio")
p.add_argument("--runs", type=int, default=3)
a = p.parse_args()
print("Ollama")
ollama = bench("ollama", a.ollama_url, a.ollama_model, "ollama-local", a.runs)
print("LM Studio")
lms = bench("lmstudio", a.lmstudio_url, a.lmstudio_model, a.lmstudio_key, a.runs)
results = [r for r in (ollama, lms) if r]
if not results:
sys.exit("No backend responded. Is the server running?")
print("\n" + json.dumps(results, indent=2))
if __name__ == "__main__":
main()
Run it:
python3 bench_local.py --runs 3
Output (numbers from a stub server used to validate the script — yours will be slower and far more interesting):
Ollama
ollama run 1: TTFT 58 ms 49.9 tok/s total 0.12 s
ollama run 2: TTFT 52 ms 52.9 tok/s total 0.11 s
LM Studio
lmstudio run 1: TTFT 53 ms 52.5 tok/s total 0.11 s
lmstudio run 2: TTFT 52 ms 52.7 tok/s total 0.11 s
[
{
"backend": "ollama",
"model": "gemma4",
"ttft_ms_median": 54.9,
"tokens_per_sec_median": 51.4
},
{
"backend": "lmstudio",
"model": "qwen/qwen3-coder-next",
"ttft_ms_median": 52.3,
"tokens_per_sec_median": 52.6
}
]
Three things to be honest about when you read your own numbers:
- The first run is a lie. It includes model load time. Discard it, or set
keep_alive: "15m"in the Ollama provider config and warm up first. - Chunks are not tokens. Streaming chunk count approximates output tokens closely enough for A/B comparison, but do not quote it as a throughput figure.
- This benchmarks raw inference, not agent latency. An OpenClaw turn also pays for tool calls and context assembly. Use it to pick a backend, not to predict end-to-end response time.
Note also that this script hits Ollama’s /v1 shim deliberately — for a raw inference benchmark that is fine. Your OpenClaw config must still use the native URL, for the tool-calling reason in Step 5.
Step 9: Run it in Docker
Build the image and generate a compose file:
git clone https://github.com/openclaw/openclaw.git
cd openclaw
./scripts/docker/setup.sh
Or skip the build and use the published image:
export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest"
./scripts/docker/setup.sh
The generated compose file mounts your config at /home/node/.openclaw and the workspace at /home/node/.openclaw/workspace, so the skill you wrote in Step 7 comes along automatically.
The container cannot see 127.0.0.1. Inside Docker that is the container’s own loopback, not your host. Add an override file:
# docker-compose.override.yml
# The generated compose file defines TWO services: openclaw-gateway and
# openclaw-cli. There is no service called "openclaw" - keying an override on
# that name silently creates a third, image-less service and the up fails.
services:
openclaw-gateway:
environment:
LM_API_TOKEN: "${LM_API_TOKEN:-}"
openclaw-cli:
environment:
LM_API_TOKEN: "${LM_API_TOKEN:-}"
You do not need to add extra_hosts — the shipped docker-compose.yml already declares host.docker.internal:host-gateway for both services. You only need it if you are hand-rolling docker run:
docker run --add-host=host.docker.internal:host-gateway ...
And change the two base URLs in ~/.openclaw/openclaw.json to match:
ollama: { baseUrl: "http://host.docker.internal:11434", /* ... */ }
lmstudio: { baseUrl: "http://host.docker.internal:1234/v1", /* ... */ }
Bring it up and check health:
docker compose up -d
curl -fsS http://127.0.0.1:18789/healthz && echo
curl -fsS http://127.0.0.1:18789/readyz && echo
docker compose run --rm openclaw-cli dashboard --no-open
One more thing Ollama needs on Linux: by default it binds to 127.0.0.1, so host.docker.internal will be refused. Bind it to all interfaces:
OLLAMA_HOST=0.0.0.0:11434 ollama serve
Or, for a systemd install:
sudo systemctl edit ollama.service
# add:
# [Service]
# Environment="OLLAMA_HOST=0.0.0.0:11434"
sudo systemctl daemon-reload # skipping this is why your edit "did nothing"
sudo systemctl restart ollama
Enable the agent sandbox while you are here — a local model with exec access and no filter is a genuinely bad combination on a machine you care about. OPENCLAW_SANDBOX is read by the setup script, not by docker compose up, so the order matters:
export OPENCLAW_SANDBOX=1
./scripts/docker/setup.sh # bootstraps sandbox config
docker compose up -d
Troubleshooting: error text to fix
| What you see | What it means | Fix |
|---|---|---|
Agent prints {"name":"exec","arguments":...} as chat text |
Ollama’s /v1 shim broke native tool calling |
Remove /v1 from the Ollama baseUrl, set api: "ollama", restart the gateway |
HTTP 401 from LM Studio |
LM_API_TOKEN does not match LM Studio’s configured token |
Match them, or disable auth in LM Studio and leave apiKey blank |
curl: (7) Failed to connect to 127.0.0.1 port 1234 |
LM Studio’s server is not running | lms server start --port 1234, then lms ps to confirm a model is loaded |
/healthz OK but /readyz red |
Plugin sidecars, channels or hooks still settling — not usually the model config | Check the gateway log; also probe /startupz. Only then check openclaw models status |
jq prints null for every LM Studio model |
Wrong filter for the native endpoint | /api/v1/models returns .models[].key; /v1/models returns .data[].id |
npm install -g openclaw@latest is “blocked … not covered by allowScripts” |
npm 11.16+ blocks install scripts | Add --allow-scripts=openclaw |
| Compose fails with a service that has no image or build context | Override keyed on openclaw instead of openclaw-gateway |
Use the real service names: openclaw-gateway, openclaw-cli |
| First agent turn takes 60s+, later turns are fast | Cold model load | params: { keep_alive: "15m" } for Ollama; keep the model loaded in LM Studio |
| Agent stops mid-task, complains about context | OpenClaw warns below 20% free context and blocks below 10% | Raise num_ctx / --context-length; 64k is the practical floor for agent loops |
| Timeouts on long generations | Default provider timeout too low | Raise timeoutSeconds on the provider (300 is a sane start) |
Skill does not appear in openclaw skills list |
Frontmatter rejected | Run validate_skill.py; check lowercase-hyphen name and a single-line description under 160 chars |
| Container cannot reach the host model | 127.0.0.1 is the container’s loopback |
Use host.docker.internal plus OLLAMA_HOST=0.0.0.0:11434 |
| Model responds but ignores tools entirely | Model too small or too heavily quantised | Move up a size class — small local models are the first thing to fail at tool calling |
Skill disappeared after changing agents.defaults.workspace |
Skills load from <workspace>/skills/ |
Move the skills/ directory to the new workspace root |
The one mistake that costs people an afternoon
Local models bypass provider-side content filtering. There is no safety layer between the model and your exec tool — which means a prompt-injected page, a poisoned README, or a model that simply hallucinates a destructive command gets executed with your permissions.
Sandboxing is config-driven, not an environment variable. Set it in ~/.openclaw/openclaw.json:
{
agents: {
defaults: {
sandbox: {
mode: "all", // "off" (default) | "non-main" | "all"
},
workspace: "~/openclaw-workspace", // not ~/, and not your repo root
},
},
}
Then audit what can actually reach the agent:
openclaw configure --section channels
If you change workspace, move your skills with it. Skills load from <workspace>/skills/, so the release-notes skill you wrote in Step 7 stops loading the moment you repoint the workspace:
mkdir -p ~/openclaw-workspace
mv ~/.openclaw/workspace/skills ~/openclaw-workspace/skills
openclaw gateway restart
openclaw skills list | grep release-notes # confirm it still loads
For Docker, point the bind mount at the same place, since OPENCLAW_WORKSPACE_DIR defaults to $HOME/.openclaw/workspace:
export OPENCLAW_WORKSPACE_DIR="$HOME/openclaw-workspace"
./scripts/docker/setup.sh
The finished project
openclaw-local/
├── preflight.sh # Step 3 - health check both backends
├── bench_local.py # Step 8 - TTFT + tok/s comparison
├── validate_skill.py # Step 7 - catch bad frontmatter early
├── docker-compose.override.yml # Step 9 - compose env overrides
├── openclaw.json # Step 5 - copy to ~/.openclaw/
└── skills/
└── release-notes/
└── SKILL.md # Step 7 - the custom skill
Sanity-check the whole thing in one line before you call it done:
./preflight.sh \
&& python3 validate_skill.py skills/release-notes/SKILL.md \
&& npx json5 --validate openclaw.json \
&& openclaw infer model run --model ollama/gemma4 --prompt "Reply with exactly: ok"
Everything above runs without a single API key and without a single packet leaving your machine. That is the actual payoff: not “local LLMs are cheaper”, but that an agent with shell access to your private repositories never has to phone home to be useful.
Further reading
- OpenClaw docs — Ollama provider
- OpenClaw docs — LM Studio provider
- OpenClaw docs — local models
- OpenClaw docs — model providers
- OpenClaw docs — creating skills
- OpenClaw docs — Docker install
- OpenClaw docs — sandboxing
- OpenClaw docs — model failover
- Ollama FAQ — setting environment variables
- Ollama docs — OpenClaw integration
- LM Studio CLI (
lms) reference - LM Studio REST API endpoints