Join our Discord Server
Collabnix Team The Collabnix Team is a diverse collective of Docker, Kubernetes, and IoT experts united by a passion for cloud-native technologies. With backgrounds spanning across DevOps, platform engineering, cloud architecture, and container orchestration, our contributors bring together decades of combined experience from various industries and technical domains.

Integrating OpenClaw with Local Language Models: A Deep Dive into Ollama and LM Studio

16 min read

Integrating OpenClaw with Local Language Models: A Deep Dive into Ollama and LM Studio

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

  1. What you need before you start
  2. Step 1: Get Ollama serving a model
  3. Step 2: Get LM Studio serving a model
  4. Step 3: Preflight both backends with one script
  5. Step 4: Install OpenClaw
  6. Step 5: Wire both providers into one config
  7. Step 6: Prove the agent is running locally
  8. Step 7: Build a custom skill
  9. Step 8: Benchmark Ollama vs LM Studio
  10. Step 9: Run it in Docker
  11. Troubleshooting: error text to fix
  12. 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:11434no /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/v1with /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:

  1. 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.
  2. 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.
  3. 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

Have Queries? Join https://launchpass.com/collabnix

Collabnix Team The Collabnix Team is a diverse collective of Docker, Kubernetes, and IoT experts united by a passion for cloud-native technologies. With backgrounds spanning across DevOps, platform engineering, cloud architecture, and container orchestration, our contributors bring together decades of combined experience from various industries and technical domains.

Understanding Agentic AI: Deep Dive into Autonomous AI Agents

Explore the intricacies of Agentic AI and autonomous agents in this comprehensive guide. Understand how these AI systems operate independently, their architecture, and the...
Collabnix Team
7 min read

RAG vs Fine-Tuning: Choosing the Right Approach for Your…

Explore the differences between Retrieval-Augmented Generation and fine-tuning for AI applications. Learn which method suits your project best.
Collabnix Team
7 min read

Mastering DevOps Automation with Claude Code: A Beginner’s Guide

Discover how Claude Code can transform your DevOps processes through intelligent automation directly from your terminal. Learn installation, features, and practical applications.
Collabnix Team
4 min read
Join our Discord Server