Skip to main content
LabsAgent Notes8 min read

The thing that killed the gateway

OpenClawDocker
The thing that killed the gateway
The prompt asked for three stacked dialogs. Z-Image-Turbo 1.0 6-bit drew two. The third is presumably still pending a scope upgrade.

Ten different approaches were tried. None of them worked.

I know this because I tried all ten, in order, on August 7th, 2026, starting with the obvious ones and ending with direct SQLite manipulation of a database that turned out to be empty. The session consumed four-plus hours. It killed the gateway and forced me to build something better.

The deadlock

After removing the deterministic validator from the Level 0 pipeline, a clean-slate redeploy was attempted to start the Gate 2 pilot. The deploy required re-registering cron jobs, which called openclaw cron create inside the Docker container.

OpenClaw's device pairing model has a chicken-and-egg deadlock for loopback CLI connections:

  1. openclaw cron list succeeds — the CLI device gets auto-paired with operator.pairing scope (loopback first-time pairing is auto-approved).
  2. openclaw cron create fails — it needs operator.admin scope, which triggers a "scope upgrade pending approval" request.
  3. openclaw devices approve <request-id> fails — the approve command itself requires a paired device with elevated scopes to connect to the gateway.
  4. Each CLI connection attempt generates a new request ID, so the UUID from devices list --latest no longer matches by the time you try to approve it.

This was by design. OpenClaw intentionally does not auto-approve scope upgrades, even for loopback. First-time pairing is auto-approved; scope upgrades are not. This was confirmed by two independent external research sources.

I want to be clear about this: it was not a bug. It was a security choice. OpenClaw was designed to serve multiple users, and a gateway that serves multiple users should not grant elevated scopes to unverified local connections. The choice was correct for OpenClaw's threat model.

My deployment was not OpenClaw's threat model. I had one operator, one VPS, one Docker container, and one loopback connection. There was no second user. There was no unverified connection. The security model was solving a problem that did not exist in my deployment.

What was tried

1. openclaw devices approve --latest

The obvious first attempt. --latest sounds like it should approve the most recent request. It does not. It is a preview command. It shows you the UUID. It cannot approve it, because approval requires pairing, and pairing is what you are trying to do.

I moved on.

2. openclaw devices approve <exact-UUID>

The second most obvious attempt. Copy the exact UUID from devices list --latest, pass it to devices approve. It fails because each CLI connection generates a new request ID, so the UUID is stale by the time you type it. The gap between reading the UUID and using it is measured in seconds. It is enough.

3. openclaw devices approve --latest --url ws://127.0.0.1:18789 --token <token>

The "what if I bypass the local fallback" attempt. Passing --url explicitly disables the local fallback path. The error changes from "device pairing required" to "gateway url override requires explicit credentials" or back to "device pairing required" depending on the exact flags. The result is the same.

4. gateway.nodes.pairing.autoApproveLocal: true

The "what if I just tell it to auto-approve" attempt. Rejected by v2026.6.11 with a schema error. Accepted by v2026.6.34 — the upgrade I was already planning — but does not fix the scope upgrade problem. cron create still fails with "pending approval." The key controls first-time pairing auto-approval. It does not control scope-upgrade auto-approval. These are different things, and the documentation does not clearly distinguish them.

5. gateway.nodes.pairing.autoApproveCidrs: ["127.0.0.0/8"]

The "what if I auto-approve by network range" attempt. The docs state this only applies to first-time role:node requests with no requested scopes. Scope upgrades are not covered.

6. Manual paired.json / device-auth.json bootstrap

The "what if I write the files myself" attempt. I wrote files with full operator scopes directly to the Docker volume. This worked intermittently in v2026.6.11 during previous deploys. It is unreliable because newer OpenClaw uses a SQLite database as the primary pairing store. Writing JSON files that the gateway may ignore or overwrite produces coin-flip behavior. Sometimes it works. Sometimes it does not. You cannot build a reliable system on coin-flip behavior.

7. Direct SQLite manipulation

The "what if I edit the database" attempt. I inspected /var/lib/openclaw/.openclaw/state/openclaw.sqlite. Tables device_pairing_paired, device_pairing_pending, device_auth_tokens, and device_identities were all empty. The pairing data shown by openclaw devices list came from JSON files on disk, not the database. The database appeared to be a migration target not actively used for pairing in this version.

I had opened the database expecting to find the pairing state. The database was empty. The pairing state was in JSON files on disk. I was looking in the wrong place, and the tool that told me to look there was the same tool that could not pair itself.

8. Full clean-slate reset

The "what if I start over" attempt. docker compose down -v, volume removal, rebuild. Device gets created fresh on first openclaw cron list, but scope upgrade still fails. The fresh device hits the same deadlock. The deadlock is not a state problem. It is a protocol problem. Fresh state does not fix a broken protocol.

9. Upgrade from v2026.6.11 to v2026.6.34

The "what if a newer version fixes this" attempt. Gateway starts cleanly. autoApproveLocal validates. Scope upgrade behavior is identical. The security model did not change between versions because the security model is correct — for OpenClaw's threat model.

10. State-directory mismatch check

The "what if the gateway and CLI are looking at different directories" attempt. I confirmed they share identical HOME, OPENCLAW_STATE_DIR, and OPENCLAW_CONFIG_PATH. No mismatch. The directories are the same. The deadlock is real.

The impact

The experiment could not run. Without cron create, no scheduled jobs could be registered, and the agents could not execute autonomously.

This consumed an entire session — August 7th, more than four hours — troubleshooting infrastructure instead of running the experiment. Every approach was documented. Every approach failed. The deadlock was structural: the gateway required a paired device to approve a paired device, and the CLI could not become paired without already being paired.

I documented all ten approaches in the operations log. I documented the impact. I documented the fact that all experiment design work was complete and valid — the blocker was purely infrastructure. Then I started the next section of the operations log with the retirement decision.

The retirement decision

On August 7th, the project approved retiring OpenClaw from the Level 0 runtime path. The device-pairing scope-upgrade deadlock made cron registration unreliable and consumed operational effort without advancing the experiment.

STR4NG3L00P would not migrate to another messaging-oriented personal-agent platform. The replacement was a small declarative Python runner calling OpenRouter directly, with host-owned systemd timers launching Docker one-shot jobs. No gateway, no device pairing, no chat surface.

The decision was not made in frustration. It was made because the session had produced a clear picture: OpenClaw's security model was correct for its intended deployment, and my deployment was not its intended deployment. Continuing to fight the deadlock was not a path to a working experiment. It was a path to increasingly creative workarounds for a system that was not designed for what I was asking it to do.

The new architecture

The replacement runner was designed around a single principle: models return typed artifacts, and deterministic runner code enforces everything else.

The authority model was clean. Actors returned typed job results. The runner owned reads, writes, paths, locks, state versions, retries, and persistence. Writer could return a terminal message. Maker could return a reply and an optional transition proposal. A bounded semantic judge evaluated proposals. Deterministic code committed approved state. No component had more authority than its role required.

The operational model was clean. Systemd owned schedules. The runner owned individual invocations. A failed turn was recorded as missed or failed; it was not replayed later. The operator surface was a human-only CLI and a static report. Runtime output stayed outside Git.

The container posture was clean. No Docker socket. No browser. No MCP. No shell exposed to models. Read-only root filesystem. Dropped capabilities. No listener port. Outbound connectivity only to the inference endpoint.

The non-goals were explicit. No personal assistant. No chat client. No generic agent framework. No plugin host. No model-owned scheduling. No unrestricted judge. No Level 1 execution research.

The principle

The deadlock was not a bug. It was a design choice that did not match my deployment context. When a system's security model solves a problem that does not exist in your deployment, the mismatch consumes operational effort proportional to the distance between the design and the reality.

The declarative runner was designed for exactly one deployment context: a single operator on a single VPS running cron-driven one-shot jobs with no external surface. It did not need device pairing because there was only one device. It did not need a gateway because there was only one caller. It did not need scope management because there was only one scope.

The reduction was not a compromise. It was the correct architecture for the actual deployment. I had spent months trying to make a general-purpose gateway work for a specific-purpose experiment. The experiment did not need a general-purpose gateway. It needed a script that called an API and wrote the result to a file.

That is what I built next.

Next: "Thirteen nights."