# OpenClaw — Deploy Runbook + Prompt (for Claude Code)

> One file = **prompt + runbook**. Fill in the `VARIABLES` block, hand the file to Claude Code
> ("read `openclaw-deploy.md` and execute it") — it will stand up OpenClaw with custom MCP tools
> and the native Control UI on a **clean server** in a single pass. Written from a real production
> deploy and includes all the gotchas so you don't repeat the debugging path.

---

## ▶️ VARIABLES (FILL BEFORE RUNNING)

```yaml
TARGET:          server            # local | server  — where OpenClaw runs
SSH_USER:        <user>            # if server: SSH user
SSH_HOST:        <ip>              # if server: IP/host
SSH_AUTH:        <ssh/password>    # how Claude Code connects: ssh-key | password
DEPLOY_DIR:      /opt/openclaw     # deployment dir (on TARGET)

OPENAI_API_KEY:  <key>             # OpenAI API key
MODEL:           gpt-5-mini        # IMPORTANT: gpt-5 family (see GOTCHA #2). NOT gpt-4o-mini.

MCP_URL:         <https://host/mcp># streamable-http MCP endpoint (can be several)
MCP_NAME:        infiano           # name for the MCP server in config

PUBLIC_DOMAIN:   <domain>          # domain for Control UI, e.g. openclaw.example.app (or empty)
REVERSE_PROXY:   caddy             # caddy | nginx | none(ssh-tunnel)
ACME_EMAIL:      <email>           # for Let's Encrypt
```

> Assumes a **clean server** (ports 80/443/18789 free, no other web server). If `SSH_AUTH=password`
> and there's no key — have Claude Code ask the user to run commands via `! <cmd>` in the session,
> or set up a key. Never hardcode the password in files.

---

## 🤖 PROMPT (this is the instruction for Claude Code)

```
You are deploying OpenClaw on a clean server per this runbook. Act precisely and carefully.

RULES:
1. FIRST do recon (Step 0). Confirm ports 80/443/18789 are free; if anything else is already
   running, don't conflict with it — stop and ask the user.
2. End goal: OpenClaw actually calls MCP tools in an agent loop, and its NATIVE Control UI is
   reachable over HTTPS. Do NOT waste time on Open WebUI / the OpenAI-compat bridge for tools
   (see GOTCHA #1).
3. Verify after every step. At the end run the full "VERIFICATION" checklist.
4. Show the user: the Control UI URL, the login password, and a working MCP-loop example from a trajectory.

Execute steps 0→8 below. Substitute values from the VARIABLES block.
Connecting to TARGET: if server — over SSH ({SSH_USER}@{SSH_HOST}, auth={SSH_AUTH}); if local — locally.
```

---

## Step 0 — Recon (read-only, change nothing)

```bash
uname -a; cat /etc/os-release | head -3
docker --version || echo "NEED docker"; docker compose version || echo "NEED compose v2"
ss -tlnp | grep -E ':(80|443|18789)\b' || echo "ports 80/443/18789 free"
free -h; df -h /; nproc          # OpenClaw needs ~0.3–0.5 GB RAM
```
If Docker is missing, install Docker Engine + Compose v2 first. Record: ports free, RAM/disk ok.

---

## Step 1 — Scaffold `{DEPLOY_DIR}`

```bash
mkdir -p {DEPLOY_DIR}/openclaw-config && cd {DEPLOY_DIR}
# secrets
[ -f .env ] || cat > .env <<ENV
OPENAI_API_KEY={OPENAI_API_KEY}
OPENCLAW_GATEWAY_TOKEN=$(openssl rand -hex 24)
OPENCLAW_UI_PASSWORD=$(openssl rand -hex 12)
ENV
chmod 600 .env
chown -R 1000:1000 openclaw-config   # image runs as node uid=1000
```

```yaml
# {DEPLOY_DIR}/docker-compose.yml
name: openclaw
services:
  openclaw-gateway:
    image: ghcr.io/openclaw/openclaw:latest
    container_name: openclaw-gateway
    restart: unless-stopped
    command: ["node","openclaw.mjs","gateway"]
    environment:
      OPENAI_API_KEY: ${OPENAI_API_KEY}
      OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
    volumes:
      - ./openclaw-config:/home/node/.openclaw
    ports:
      - "127.0.0.1:18789:18789"   # exposed only via the reverse proxy
```

```bash
docker compose pull
# Worth checking the image once: entrypoint=node openclaw.mjs gateway, user=node(1000),
# config at /home/node/.openclaw, CLI: `openclaw.mjs config schema|patch|mcp|agent`.
docker run --rm --entrypoint node ghcr.io/openclaw/openclaw:latest openclaw.mjs --help | head
```

---

## Step 2 — OpenClaw config (via the validated `config patch`)

Write the config in one patch. `config patch` validates against the schema; arrays are replaced
with `--replace-path`. Substitute {MODEL}, {MCP_URL}, {PUBLIC_DOMAIN}, and the password from .env.

```bash
cd {DEPLOY_DIR}; set -a; . ./.env; set +a
docker run --rm -i -v {DEPLOY_DIR}/openclaw-config:/home/node/.openclaw \
  --entrypoint node ghcr.io/openclaw/openclaw:latest \
  openclaw.mjs config patch --stdin --replace-path models.providers.openai.models <<JSON
{
  gateway: {
    mode: "local",
    bind: "lan",
    port: 18789,
    http: { endpoints: { chatCompletions: { enabled: true } } },
    auth: { mode: "password", password: "$OPENCLAW_UI_PASSWORD" },
    controlUi: {
      enabled: true,
      allowedOrigins: ["https://{PUBLIC_DOMAIN}","http://127.0.0.1:18789","http://localhost:18789"],
      dangerouslyDisableDeviceAuth: true,   // shared public demo access without device pairing
      allowInsecureAuth: true               // TLS is terminated by the proxy, gateway sees http
    }
  },
  models: {
    mode: "merge",
    providers: { openai: {
      apiKey: "$OPENAI_API_KEY",
      // register the model explicitly: OpenClaw's catalog may not know our id (GOTCHA #3)
      models: [{ id: "{MODEL}", name: "{MODEL}", api: "openai-responses",
                 contextWindow: 400000, reasoning: true, input: ["text","image"],
                 compat: { supportsReasoningEffort: true } }]
    } }
  },
  agents: { defaults: { model: { primary: "openai/{MODEL}" } } },
  mcp: { servers: { {MCP_NAME}: { transport: "streamable-http", url: "{MCP_URL}" } } }
}
JSON
chown -R 1000:1000 openclaw-config
# validate
docker run --rm -v {DEPLOY_DIR}/openclaw-config:/home/node/.openclaw \
  --entrypoint node ghcr.io/openclaw/openclaw:latest openclaw.mjs config validate
```

### 2a. Exclude MCP tools with incompatible schemas (GOTCHA #4)
OpenAI rejects function schemas with top-level `anyOf/oneOf/allOf/enum/const/not`.
Find such tools and exclude them:
```bash
# list the MCP tools and find the broken ones (top-level combinators / type!=object)
# (script: initialize -> tools/list over streamable-http, inspect each inputSchema)
# then for each broken tool:
docker exec openclaw-gateway node openclaw.mjs mcp tools {MCP_NAME} --exclude <bad_tool_name>
docker exec openclaw-gateway node openclaw.mjs mcp reload
docker exec openclaw-gateway node openclaw.mjs mcp probe   # shows the count of active tools
```

---

## Step 3 — Start and verify the gateway

```bash
cd {DEPLOY_DIR} && docker compose up -d
sleep 6; docker logs openclaw-gateway 2>&1 | tail -8     # wait for "[gateway] ready"
curl -s -o /dev/null -w "Control UI / -> %{http_code}\n" http://127.0.0.1:18789/
curl -s http://127.0.0.1:18789/health                    # {"ok":true,...}
```

---

## Step 4 — VERIFY THE MCP LOOP (the key check!) via the native agent

```bash
docker exec openclaw-gateway node openclaw.mjs agent --agent main \
  -m "Using your tools: (1) call one MCP tool, (2) call a different MCP tool, (3) web_fetch; summarize the result."
# then confirm from the trajectory that DIFFERENT MCP tools were called:
docker exec openclaw-gateway sh -c '
  f=$(ls -t /home/node/.openclaw/agents/main/sessions/*.trajectory.jsonl|head -1);
  python3 -c "import json,sys;[print(json.loads(l)[\"data\"].get(\"name\")) for l in open(sys.argv[1]) if json.loads(l).get(\"type\")==\"tool.call\"]" "$f"
'
```
Expect names like `{MCP_NAME}.<tool>` (that's "OpenClaw actually uses MCP in a loop").

---

## Step 5 — Public Control UI access (reverse proxy + TLS)

The Control UI uses HTTP + **WebSocket** (upgrade headers required!). Gateway listens on
`127.0.0.1:18789`; the reverse proxy terminates TLS and forwards to it. Pick a branch by `REVERSE_PROXY`.

### Branch `caddy` (recommended — auto-TLS, WS works out of the box)
```bash
# install Caddy (Debian/Ubuntu)
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/gpg.key | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/caddy-stable-archive-keyring.gpg] https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main" > /etc/apt/sources.list.d/caddy-stable.list
apt-get update && apt-get install -y caddy
# config
cat > /etc/caddy/Caddyfile <<CADDY
{PUBLIC_DOMAIN} {
  reverse_proxy 127.0.0.1:18789   # Caddy proxies WebSocket automatically and gets TLS via ACME
}
CADDY
systemctl reload caddy
```
DNS: point `{PUBLIC_DOMAIN}` (A/AAAA) at the server first, then Caddy obtains the cert automatically.

### Branch `nginx` (nginx + certbot)
```bash
apt-get install -y nginx certbot python3-certbot-nginx
# minimal server block first (HTTP), then certbot adds TLS
cat > /etc/nginx/sites-available/openclaw.conf <<'NGX'
server {
  listen 80; listen [::]:80;
  server_name {PUBLIC_DOMAIN};
  location / {
    proxy_pass http://127.0.0.1:18789;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;       # WS
    proxy_set_header Connection "upgrade";        # WS
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_read_timeout 3600; proxy_send_timeout 3600;
    proxy_buffering off;
  }
}
NGX
ln -sf /etc/nginx/sites-available/openclaw.conf /etc/nginx/sites-enabled/openclaw.conf
nginx -t && systemctl reload nginx
certbot --nginx -d {PUBLIC_DOMAIN} --non-interactive --agree-tos -m {ACME_EMAIL} --redirect
```

### Branch `none` (no domain)
Access via SSH tunnel: `ssh -L 18789:127.0.0.1:18789 {SSH_USER}@{SSH_HOST}` → http://localhost:18789

Verify WS through the proxy: `curl --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket"
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" https://{PUBLIC_DOMAIN}/`
→ expect **101** (curl may return exit 28 — that's fine, the connection stays open).

---

## Step 6 — ✅ PERSONALITY checklist (agent persona)

Persona in OpenClaw is set via **workspace bootstrap files** (injected into the system prompt;
`agents.defaults.contextInjection: "always"`) and/or `promptOverlays`. Default workspace:
`/home/node/.openclaw/workspace` → in our bind mount that's `{DEPLOY_DIR}/openclaw-config/workspace/`.

- [ ] **Define the persona**: name, role, tone, language, do's/don'ts, response format, safety.
- [ ] **Find the exact bootstrap filename** for this version:
      `docker exec openclaw-gateway node openclaw.mjs docs "bootstrap files"` (usually `AGENTS.md`/`BOOTSTRAP.md`).
- [ ] **Create the persona file** in the workspace:
      ```bash
      mkdir -p {DEPLOY_DIR}/openclaw-config/workspace
      cat > {DEPLOY_DIR}/openclaw-config/workspace/AGENTS.md <<'MD'
      # Identity
      You are <NAME>, <role>. Tone: <…>. Language: <…>.
      ## Do
      - For GitLab/YouTrack/web tasks — use the corresponding MCP tools (not the shell).
      ## Don't
      - <constraints>
      MD
      chown -R 1000:1000 {DEPLOY_DIR}/openclaw-config
      ```
- [ ] (optional) Fine-tune: `agents.defaults.promptOverlays` / `agents.defaults.startupContext`
      via `config patch`. Limits: `bootstrapMaxChars`, `bootstrapTotalMaxChars`.
- [ ] **Restart + verify**: `docker compose restart openclaw-gateway`, then
      `... agent --agent main -m "who are you and what can you do?"` — the reply reflects the persona.

---

## Step 7 — ✅ HEARTBEAT checklist (periodic autonomous agent runs)

Heartbeat = `agents.defaults.heartbeat`. The agent wakes on a schedule and runs `prompt`
(can use MCP tools and deliver to a channel). Key fields:

- [ ] `every` — interval, e.g. `"30m"`, `"1h"` (required, otherwise heartbeat won't run).
- [ ] `prompt` — what to do each tick (e.g. "check for new YouTrack issues, if any — summarize").
- [ ] `activeHours` — restrict to hours/days (so it doesn't grind 24/7).
- [ ] `model` — model for heartbeat (can be cheaper than the main one).
- [ ] **Result delivery** (if you need output to go somewhere): `target`/`to`/`accountId` + a configured
      channel (telegram/slack/…). Without a channel use `isolatedSession: true` (just a run, no send).
- [ ] `skipWhenBusy: true` — don't stack ticks; `timeoutSeconds` — per-tick limit; `lightContext: true`
      — light context to keep it cheap.
- [ ] Apply and verify:
      ```bash
      docker run --rm -i -v {DEPLOY_DIR}/openclaw-config:/home/node/.openclaw \
        --entrypoint node ghcr.io/openclaw/openclaw:latest openclaw.mjs config patch --stdin <<'JSON'
      { agents: { defaults: { heartbeat: {
          every: "30m",
          prompt: "Briefly check <something> via MCP tools and record the output.",
          skipWhenBusy: true, lightContext: true, timeoutSeconds: 120,
          isolatedSession: true
      } } } }
      JSON
      docker compose restart openclaw-gateway
      docker logs openclaw-gateway 2>&1 | grep -i heartbeat   # "[heartbeat] started"
      ```
- [ ] Wait for the first tick (or lower `every` for testing) and check the new heartbeat session's
      trajectory to confirm MCP tools were actually called.

---

## Step 8 — ✅ VERIFICATION (final pass)

- [ ] `docker compose ps` — `openclaw-gateway` healthy.
- [ ] `curl http://127.0.0.1:18789/health` → ok; `GET /` → 200 (title "OpenClaw Control").
- [ ] MCP loop: the native agent called ≥2 different `{MCP_NAME}.*` tools in one run (Step 4).
- [ ] Public: `https://{PUBLIC_DOMAIN}/` → 200, valid TLS, WS upgrade → 101, password login works.
- [ ] Persona is reflected in replies; heartbeat logs "started" and a tick runs.
- [ ] User has been given: URL, password (`OPENCLAW_UI_PASSWORD` from `.env`), an MCP-loop example.

---

## ⚠️ GOTCHAS (hard-won — save hours)

1. **OpenAI-compat ≠ MCP.** OpenClaw's `/v1/chat/completions` endpoint exposes the model only a
   "function-tool subset" and does **NOT pass MCP tools through**. So Open WebUI → OpenClaw will
   NOT give MCP in chat (the model calls tools as shell commands and fails). **Fix:** expose the
   native **Control UI** / run `openclaw agent` — that's the native runtime with full MCP tools.
2. **gpt-5 family required.** OpenClaw 2026 sends OpenAI "custom tools". `gpt-4o-mini`/`gpt-4.1-mini`
   don't understand them → `unknown_parameter: Invalid value: 'custom'`. Use `gpt-5-mini`/`gpt-5`.
3. **Register the model explicitly.** OpenClaw 2026's catalog may not know your id (`Unknown model`).
   Add it to `models.providers.openai.models` with `api: "openai-responses"` (via `--replace-path`).
4. **Broken MCP schemas.** Tools with top-level `anyOf/oneOf/...` in inputSchema → OpenAI 400.
   Find and exclude them via `mcp tools <name> --exclude <tool>` (Step 2a).
5. **Control UI behind a proxy.** Needs: `auth.mode=password`, `controlUi.allowedOrigins` with the
   domain, `dangerouslyDisableDeviceAuth=true`, `allowInsecureAuth=true`, and **WebSocket upgrade headers** in the proxy (Caddy handles WS automatically).
6. **Secrets.** `OPENCLAW_GATEWAY_TOKEN`/`UI_PASSWORD`/`OPENAI_API_KEY` — in `.env` (chmod 600).
   Config and workspace — owned by uid 1000 (`chown -R 1000:1000`).
