{
  "title": "Gateway-owned pairing (Option B)",
  "content": "In Gateway-owned pairing, the **Gateway** is the source of truth for which nodes\nare allowed to join. UIs (macOS app, future clients) are just frontends that\napprove or reject pending requests.\n\n**Important:** WS nodes use **device pairing** (role `node`) during `connect`.\n`node.pair.*` is a separate pairing store and does **not** gate the WS handshake.\nOnly clients that explicitly call `node.pair.*` use this flow.\n\n* **Pending request**: a node asked to join; requires approval.\n* **Paired node**: approved node with an issued auth token.\n* **Transport**: the Gateway WS endpoint forwards requests but does not decide\n  membership. (Legacy TCP bridge support is deprecated/removed.)\n\n1. A node connects to the Gateway WS and requests pairing.\n2. The Gateway stores a **pending request** and emits `node.pair.requested`.\n3. You approve or reject the request (CLI or UI).\n4. On approval, the Gateway issues a **new token** (tokens are rotated on re‑pair).\n5. The node reconnects using the token and is now “paired”.\n\nPending requests expire automatically after **5 minutes**.\n\n## CLI workflow (headless friendly)\n\n`nodes status` shows paired/connected nodes and their capabilities.\n\n## API surface (gateway protocol)\n\n* `node.pair.requested` — emitted when a new pending request is created.\n* `node.pair.resolved` — emitted when a request is approved/rejected/expired.\n\n* `node.pair.request` — create or reuse a pending request.\n* `node.pair.list` — list pending + paired nodes.\n* `node.pair.approve` — approve a pending request (issues token).\n* `node.pair.reject` — reject a pending request.\n* `node.pair.verify` — verify `{ nodeId, token }`.\n\n* `node.pair.request` is idempotent per node: repeated calls return the same\n  pending request.\n* Approval **always** generates a fresh token; no token is ever returned from\n  `node.pair.request`.\n* Requests may include `silent: true` as a hint for auto-approval flows.\n\n## Auto-approval (macOS app)\n\nThe macOS app can optionally attempt a **silent approval** when:\n\n* the request is marked `silent`, and\n* the app can verify an SSH connection to the gateway host using the same user.\n\nIf silent approval fails, it falls back to the normal “Approve/Reject” prompt.\n\n## Storage (local, private)\n\nPairing state is stored under the Gateway state directory (default `~/.openclaw`):\n\n* `~/.openclaw/nodes/paired.json`\n* `~/.openclaw/nodes/pending.json`\n\nIf you override `OPENCLAW_STATE_DIR`, the `nodes/` folder moves with it.\n\n* Tokens are secrets; treat `paired.json` as sensitive.\n* Rotating a token requires re-approval (or deleting the node entry).\n\n## Transport behavior\n\n* The transport is **stateless**; it does not store membership.\n* If the Gateway is offline or pairing is disabled, nodes cannot pair.\n* If the Gateway is in remote mode, pairing still happens against the remote Gateway’s store.",
  "code_samples": [],
  "headings": [
    {
      "level": "h2",
      "text": "Concepts",
      "id": "concepts"
    },
    {
      "level": "h2",
      "text": "How pairing works",
      "id": "how-pairing-works"
    },
    {
      "level": "h2",
      "text": "CLI workflow (headless friendly)",
      "id": "cli-workflow-(headless-friendly)"
    },
    {
      "level": "h2",
      "text": "API surface (gateway protocol)",
      "id": "api-surface-(gateway-protocol)"
    },
    {
      "level": "h2",
      "text": "Auto-approval (macOS app)",
      "id": "auto-approval-(macos-app)"
    },
    {
      "level": "h2",
      "text": "Storage (local, private)",
      "id": "storage-(local,-private)"
    },
    {
      "level": "h2",
      "text": "Transport behavior",
      "id": "transport-behavior"
    }
  ],
  "url": "llms-txt#gateway-owned-pairing-(option-b)",
  "links": []
}