{"_id":"@controluiclaw/sdk","_rev":"5-ba55df8ca73d0297f66d27154d7b2d7f","name":"@controluiclaw/sdk","dist-tags":{"latest":"1.1.0"},"versions":{"1.0.0":{"name":"@controluiclaw/sdk","version":"1.0.0","keywords":["controluiclaw","websocket","sdk","gateway","chat","ai"],"license":"MIT","_id":"@controluiclaw/sdk@1.0.0","maintainers":[{"name":"aliazazalam","email":"ali.azaz.alam@hotmail.com"}],"dist":{"shasum":"b4833b6120f10822c4ec0f239971f344b2e6f541","tarball":"https://registry.npmjs.org/@controluiclaw/sdk/-/sdk-1.0.0.tgz","fileCount":23,"integrity":"sha512-dbd7fpXJ5lfZdNLzzuMDCSfkwVp2h6bLOSzHkKWHJ8AlFAJevG9a/6Uing8Q2thpgJ6dJe1miAjxeq4mloQnwg==","signatures":[{"sig":"MEUCIQDU5KPE+08UDNYPbPXoeD7TeI8UeWjLyJ7QNVTO0z/sCQIgQro0vmfD+pBCoS1DgAssXxUknQnDyfGwoUlbihhXsiY=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":175199},"main":"./dist/controluiclaw.js","type":"module","types":"./dist/controluiclaw.d.ts","module":"./dist/controluiclaw.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/controluiclaw.d.ts","import":"./dist/controluiclaw.js","default":"./dist/controluiclaw.js"},"./crypto":{"types":"./dist/crypto.d.ts","import":"./dist/crypto.js","default":"./dist/crypto.js"}},"gitHead":"5542097b881bd6821f64666dd8c530dd5e47930e","scripts":{"build":"tsc && node -e \"const fs=require('fs'); for(const ext of ['.js','.js.map','.d.ts','.d.ts.map']){const s='dist/index'+ext,d='dist/controluiclaw'+ext; if(fs.existsSync(s)){fs.copyFileSync(s,d)}}\"","clean":"rm -rf dist","prepare":"npm run build","@test.html":"npm run build && printf '\\n  Test page: http://127.0.0.1:8787/test.html\\n\\n' && npx --yes serve . -l 8787","prepublishOnly":"npm run clean && npm run build","@test.html:watch":"npm run build && concurrently -k -n tsc,web -c blue,magenta \"nodemon -q -L -C --watch src --ext ts --exec npm run build\" \"live-server . --port=8787 --open=test.html\""},"_npmUser":{"name":"aliazazalam","email":"ali.azaz.alam@hotmail.com"},"_npmVersion":"11.12.1","description":"TypeScript SDK for connecting to the OpenClaw gateway via WebSocket","directories":{},"_nodeVersion":"22.22.0","_hasShrinkwrap":false,"devDependencies":{"nodemon":"^3.1.14","typescript":"^5.5.0","live-server":"^1.2.2","concurrently":"^9.2.1"},"optionalDependencies":{"@noble/ed25519":"^3.0.1"},"_npmOperationalInternal":{"tmp":"tmp/sdk_1.0.0_1777815891922_0.30262527365758296","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@controluiclaw/sdk","version":"1.0.1","keywords":["controluiclaw","websocket","sdk","gateway","chat","ai"],"license":"MIT","_id":"@controluiclaw/sdk@1.0.1","maintainers":[{"name":"aliazazalam","email":"ali.azaz.alam@hotmail.com"}],"dist":{"shasum":"34ddcad7d20a9d0fb54e62ca7c4889d5f2cb761a","tarball":"https://registry.npmjs.org/@controluiclaw/sdk/-/sdk-1.0.1.tgz","fileCount":23,"integrity":"sha512-iDmT/vJnFkxidT7JGrmA7C/NaOKZe/KVVdncrpIpQfH7b3pt38N5g3vmSDa3SFKjGi2QTaViiMvv3ZxhfZWeaA==","signatures":[{"sig":"MEQCIF8g/bmf3h5F+sdLr8aVnVOMDa3peat1uCDsXtszm6QPAiAuP2gT1XZ9SX2DRfZPnf643mIUKf2bfcnQZOqLaaYa/A==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":175230},"main":"./dist/controluiclaw.js","type":"module","types":"./dist/controluiclaw.d.ts","module":"./dist/controluiclaw.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/controluiclaw.d.ts","import":"./dist/controluiclaw.js","default":"./dist/controluiclaw.js"},"./crypto":{"types":"./dist/crypto.d.ts","import":"./dist/crypto.js","default":"./dist/crypto.js"}},"gitHead":"5542097b881bd6821f64666dd8c530dd5e47930e","scripts":{"build":"tsc && node -e \"const fs=require('fs'); for(const ext of ['.js','.js.map','.d.ts','.d.ts.map']){const s='dist/index'+ext,d='dist/controluiclaw'+ext; if(fs.existsSync(s)){fs.copyFileSync(s,d)}}\"","clean":"rm -rf dist","prepare":"npm run build","@test.html":"npm run build && printf '\\n  Test page: http://127.0.0.1:8787/test.html\\n\\n' && npx --yes serve . -l 8787","prepublishOnly":"npm run clean && npm run build","@test.html:watch":"npm run build && concurrently -k -n tsc,web -c blue,magenta \"nodemon -q -L -C --watch src --ext ts --exec npm run build\" \"live-server . --port=8787 --open=test.html\""},"_npmUser":{"name":"aliazazalam","email":"ali.azaz.alam@hotmail.com"},"_npmVersion":"11.12.1","description":"TypeScript SDK for connecting to the OpenClaw gateway via WebSocket","directories":{},"_nodeVersion":"22.22.0","_hasShrinkwrap":false,"devDependencies":{"nodemon":"^3.1.14","typescript":"^5.5.0","live-server":"^1.2.2","concurrently":"^9.2.1"},"optionalDependencies":{"@noble/ed25519":"^3.0.1"},"_npmOperationalInternal":{"tmp":"tmp/sdk_1.0.1_1777816206709_0.18192427942193778","host":"s3://npm-registry-packages-npm-production"}},"1.0.2":{"name":"@controluiclaw/sdk","version":"1.0.2","keywords":["controluiclaw","websocket","sdk","gateway","chat","ai"],"license":"MIT","_id":"@controluiclaw/sdk@1.0.2","maintainers":[{"name":"aliazazalam","email":"ali.azaz.alam@hotmail.com"}],"dist":{"shasum":"e51c257bab8b21ec67d57d45267db7cb8c554e48","tarball":"https://registry.npmjs.org/@controluiclaw/sdk/-/sdk-1.0.2.tgz","fileCount":23,"integrity":"sha512-ArV1cx6rccLXkVQC4hxdFgOq8hzn+SDiq56JKQrQV1ATxxRKsMdz4DCsmfdzizFzkuq7GNg1h0zHRozGW70KjQ==","signatures":[{"sig":"MEUCIEKCyYcRcYgxxLNlA0SThegBYCRXbaNq0rwrR3NlzK2ZAiEA5Z6TWHF+T3A7GAIh4dRbC4vsW+QM+Lcn1BIpyiewXls=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":194099},"main":"./dist/controluiclaw.js","type":"module","types":"./dist/controluiclaw.d.ts","module":"./dist/controluiclaw.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/controluiclaw.d.ts","import":"./dist/controluiclaw.js","default":"./dist/controluiclaw.js"},"./crypto":{"types":"./dist/crypto.d.ts","import":"./dist/crypto.js","default":"./dist/crypto.js"}},"gitHead":"ec107d3c394e6f9b901fdea424e5e388daef8b06","scripts":{"build":"tsc && node -e \"const fs=require('fs'); for(const ext of ['.js','.js.map','.d.ts','.d.ts.map']){const s='dist/index'+ext,d='dist/controluiclaw'+ext; if(fs.existsSync(s)){fs.copyFileSync(s,d)}}\"","clean":"rm -rf dist","prepare":"npm run build","@test.html":"npm run build && printf '\\n  Test page: http://127.0.0.1:8787/test.html\\n\\n' && npx --yes serve . -l 8787","prepublishOnly":"npm run clean && npm run build","@test.html:watch":"npm run build && concurrently -k -n tsc,web -c blue,magenta \"nodemon -q -L -C --watch src --ext ts --exec npm run build\" \"live-server . --port=8787 --open=test.html\""},"_npmUser":{"name":"aliazazalam","email":"ali.azaz.alam@hotmail.com"},"_npmVersion":"11.12.1","description":"TypeScript SDK for connecting to the OpenClaw gateway via WebSocket","directories":{},"_nodeVersion":"22.22.0","_hasShrinkwrap":false,"devDependencies":{"nodemon":"^3.1.14","typescript":"^5.5.0","live-server":"^1.2.2","concurrently":"^9.2.1"},"optionalDependencies":{"@noble/ed25519":"^3.0.1"},"_npmOperationalInternal":{"tmp":"tmp/sdk_1.0.2_1779211165019_0.9210557727437358","host":"s3://npm-registry-packages-npm-production"}},"1.0.3":{"name":"@controluiclaw/sdk","version":"1.0.3","keywords":["controluiclaw","websocket","sdk","gateway","chat","ai"],"license":"MIT","_id":"@controluiclaw/sdk@1.0.3","maintainers":[{"name":"aliazazalam","email":"ali.azaz.alam@hotmail.com"}],"dist":{"shasum":"26f4aaf727e1a14c3f7040edeaeda92b13b79f8a","tarball":"https://registry.npmjs.org/@controluiclaw/sdk/-/sdk-1.0.3.tgz","fileCount":23,"integrity":"sha512-aIAqRppdDNti+t1GzQ00VtQzDBezNdapuKcS+s2XGyAOHD7oOvHX2pRmWYXjE94rILGnKh6xQidtt2R3CdoajQ==","signatures":[{"sig":"MEUCIFRd50SoxjGsvS5x0m43sSyncEl8zFz14lQWSVUt+p7TAiEA5lSUrLgPUSL+dI6EwZPq0ghdD9lUkAWWtnqRxlbSOvM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":196639},"main":"./dist/controluiclaw.js","type":"module","types":"./dist/controluiclaw.d.ts","module":"./dist/controluiclaw.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/controluiclaw.d.ts","import":"./dist/controluiclaw.js","default":"./dist/controluiclaw.js"},"./crypto":{"types":"./dist/crypto.d.ts","import":"./dist/crypto.js","default":"./dist/crypto.js"}},"gitHead":"643e74e4156eb59c9572cfca10195c5e1d832cfc","scripts":{"build":"tsc && node -e \"const fs=require('fs'); for(const ext of ['.js','.js.map','.d.ts','.d.ts.map']){const s='dist/index'+ext,d='dist/controluiclaw'+ext; if(fs.existsSync(s)){fs.copyFileSync(s,d)}}\"","clean":"rm -rf dist","prepare":"npm run build","@test.html":"npm run build && printf '\\n  Test page: http://127.0.0.1:8787/test.html\\n\\n' && npx --yes serve . -l 8787","prepublishOnly":"npm run clean && npm run build","@test.html:watch":"npm run build && concurrently -k -n tsc,web -c blue,magenta \"nodemon -q -L -C --watch src --ext ts --exec npm run build\" \"live-server . --port=8787 --open=test.html\""},"_npmUser":{"name":"aliazazalam","email":"ali.azaz.alam@hotmail.com"},"_npmVersion":"11.12.1","description":"TypeScript SDK for connecting to the OpenClaw gateway via WebSocket","directories":{},"_nodeVersion":"22.22.0","_hasShrinkwrap":false,"devDependencies":{"nodemon":"^3.1.14","typescript":"^5.5.0","live-server":"^1.2.2","concurrently":"^9.2.1"},"optionalDependencies":{"@noble/ed25519":"^3.0.1"},"_npmOperationalInternal":{"tmp":"tmp/sdk_1.0.3_1779730070938_0.4271119029466164","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"@controluiclaw/sdk","version":"1.1.0","description":"TypeScript SDK for connecting to the OpenClaw gateway via WebSocket","type":"module","main":"./dist/controluiclaw.js","module":"./dist/controluiclaw.js","types":"./dist/controluiclaw.d.ts","exports":{".":{"types":"./dist/controluiclaw.d.ts","import":"./dist/controluiclaw.js","default":"./dist/controluiclaw.js"},"./crypto":{"types":"./dist/crypto.d.ts","import":"./dist/crypto.js","default":"./dist/crypto.js"}},"scripts":{"@test.html":"npm run build && printf '\\n  Test page: http://127.0.0.1:8787/test.html\\n\\n' && npx --yes serve . -l 8787","@test.html:watch":"npm run build && concurrently -k -n tsc,web -c blue,magenta \"nodemon -q -L -C --watch src --ext ts --exec npm run build\" \"live-server . --port=8787 --open=test.html\"","build":"tsc && node -e \"const fs=require('fs'); for(const ext of ['.js','.js.map','.d.ts','.d.ts.map']){const s='dist/index'+ext,d='dist/controluiclaw'+ext; if(fs.existsSync(s)){fs.copyFileSync(s,d)}}\"","prepare":"npm run build","clean":"rm -rf dist","prepublishOnly":"npm run clean && npm run build"},"optionalDependencies":{"@noble/ed25519":"^3.0.1"},"devDependencies":{"concurrently":"^9.2.1","live-server":"^1.2.2","nodemon":"^3.1.14","typescript":"^5.5.0"},"engines":{"node":">=18"},"license":"MIT","keywords":["controluiclaw","websocket","sdk","gateway","chat","ai"],"gitHead":"d55c28b9b97b59b315b582d07fec04de7929cc09","_id":"@controluiclaw/sdk@1.1.0","_nodeVersion":"22.22.0","_npmVersion":"11.12.1","dist":{"integrity":"sha512-vvxSsacnygXdHd9Fk/oUnxbVFqtog4LvVZwIxsPCagUMfkjoWvIu+ZsvSRkag4/smCodNe+u5ixwR/nzS7Fatw==","shasum":"85c6f5eadef2fe2e2a460d0a90c2399c11fa92f8","tarball":"https://registry.npmjs.org/@controluiclaw/sdk/-/sdk-1.1.0.tgz","fileCount":23,"unpackedSize":229878,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIGEfadlqCHqIoDTePlf8b+Nz8lQr69impdr7yVXVbvPrAiEAyuBTybDxVFE8rlTXCZEE+PDV5HecKUH1ZTQvq+aARIA="}]},"_npmUser":{"name":"aliazazalam","email":"ali.azaz.alam@hotmail.com"},"directories":{},"maintainers":[{"name":"aliazazalam","email":"ali.azaz.alam@hotmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/sdk_1.1.0_1784029136142_0.29868414835895796"},"_hasShrinkwrap":false}},"time":{"created":"2026-05-03T13:44:51.842Z","modified":"2026-07-14T11:38:56.444Z","1.0.0":"2026-05-03T13:44:52.170Z","1.0.1":"2026-05-03T13:50:06.853Z","1.0.2":"2026-05-19T17:19:25.203Z","1.0.3":"2026-05-25T17:27:51.081Z","1.1.0":"2026-07-14T11:38:56.280Z"},"license":"MIT","keywords":["controluiclaw","websocket","sdk","gateway","chat","ai"],"description":"TypeScript SDK for connecting to the OpenClaw gateway via WebSocket","maintainers":[{"name":"aliazazalam","email":"ali.azaz.alam@hotmail.com"}],"readme":"# ControlUIClaw SDK — WebSocket Integration Guide (Integrate your openclaw with custom UI)\n\n[![npm](https://img.shields.io/npm/v/@controluiclaw/sdk)](https://www.npmjs.com/package/@controluiclaw/sdk)\n\nA developer guide for connecting to the OpenClaw gateway via the `@controluiclaw/sdk` WebSocket client. Covers initialization, connection lifecycle, event mapping, health monitoring, extended thinking, token usage tracking, skill management, cron job scheduling, and graceful teardown.\n\n## Prerequisites\n\n- Node.js 18+ or a modern browser with WebSocket and Web Crypto support\n- A running OpenClaw gateway (default port `18789`)\n- An auth token (optional if device-only auth is sufficient)\n\n### Gateway Compatibility\n\n| OpenClaw release | Gateway protocol | SDK support |\n| ---------------- | ---------------- | ----------- |\n| ≤ 2026.4.x       | v3               | ✅ (negotiates v3) |\n| ≥ 2026.5.19      | v4 (v4 minimum for clients) | ✅ since SDK 1.1.0 (negotiates v4; verified through 2026.7.1) |\n\nSDK 1.1.0 advertises protocol range `{min: 3, max: 4}` by default, so it works with both older and current gateways. SDK ≤ 1.0.x pins v3 and is **rejected** by gateways from 2026.5.19 onward with a `PROTOCOL_MISMATCH` error.\n\nInstall the SDK:\n\n```bash\nnpm install @controluiclaw/sdk\n```\n\n## Quick Start\n\n```ts\nimport { ControlUIClaw } from \"@controluiclaw/sdk\";\n\n// 1. Initialize (with thinking enabled)\nconst claw = ControlUIClaw.init({\n  url: \"wss://your-gateway-host:18789\",\n  token: \"your-auth-token\",\n  thinking: \"medium\",\n});\n\n// 2. Subscribe to health events before connecting\nconst unsubHealth = claw.sessionHealth((event) => {\n  console.log(`[${event.code}] ${event.message}`);\n});\n\n// 3. Subscribe to chat events (with thinking + usage)\nconst unsubChat = claw.chatEvents((event) => {\n  console.log(event.text);\n  if (event.thinking) console.log(\"Reasoning:\", event.thinking);\n  if (event.usage) console.log(\"Tokens:\", event.usage);\n});\n\n// 4. Connect\nconst result = await claw.connect();\nif (!result.ok) {\n  console.error(\"Connection failed:\", result.error);\n}\n\n// 5. When done, disconnect and clean up\nunsubHealth();\nunsubChat();\nclaw.disconnect();\n```\n\n---\n\n## Initialization\n\nCreate a client instance with `ControlUIClaw.init()`. This does not open a WebSocket — it only configures the client. The connection is established later when you call `connect()`.\n\n```ts\nconst claw = ControlUIClaw.init({\n  url: \"wss://gateway-host:18789\",     // Required — gateway WebSocket URL\n  token: \"your-auth-token\",            // Optional auth token\n  role: \"operator\",                    // Defaults to \"operator\"\n  scopes: [                            // Defaults to full operator scopes\n    \"operator.admin\",\n    \"operator.read\",\n    \"operator.write\",\n    \"operator.approvals\",\n    \"operator.pairing\",\n  ],\n  thinking: \"medium\",                  // Default thinking level for all messages\n  autoReconnect: true,                 // Auto-reconnect on drop (default: true)\n  initialBackoffMs: 800,               // First retry delay (default: 800ms)\n  maxBackoffMs: 15_000,                // Max retry delay cap (default: 15s)\n  protocol: { min: 3, max: 4 },        // Protocol version range\n  caps: [\"tool-events\"],               // Additional capabilities to advertise\n  clientInfo: {                        // Override client identification\n    id: \"my-app\",\n    version: \"2.0.0\",\n    platform: \"web\",\n    mode: \"ui\",                        // must be a gateway-known mode (e.g. \"ui\", \"backend\")\n  },\n});\n```\n\n### `InitOptions` Reference\n\n| Field             | Type              | Default                          | Description                                       |\n| ----------------- | ----------------- | -------------------------------- | ------------------------------------------------- |\n| `url`             | `string`          | —                                | WebSocket URL of the gateway (required)            |\n| `token`           | `string`          | `undefined`                      | Auth token sent during handshake                   |\n| `role`            | `string`          | `\"operator\"`                     | Role claimed during handshake                      |\n| `scopes`          | `string[]`        | Full operator scopes             | Scopes requested during handshake                  |\n| `thinking`        | `ThinkingLevel`   | `\"off\"`                          | Default thinking level for all chat.send requests  |\n| `autoReconnect`   | `boolean`         | `true`                           | Automatically reconnect on disconnection           |\n| `initialBackoffMs`| `number`          | `800`                            | Initial reconnect backoff in milliseconds          |\n| `maxBackoffMs`    | `number`          | `15000`                          | Maximum reconnect backoff in milliseconds          |\n| `protocol`        | `{min, max}`      | `{min: 3, max: 4}`              | Protocol version range for negotiation             |\n| `caps`            | `string[]`        | `[\"tool-events\"]`                | Capabilities to advertise to the gateway           |\n| `clientInfo`      | `Partial<ClientInfo>` | Auto-detected                | Client identification metadata                     |\n| `deviceIdentity`  | `DeviceIdentity`  | Auto-generated                   | Custom Ed25519 device identity for auth            |\n\n---\n\n## Connect\n\nCall `connect()` to open the WebSocket and complete the gateway handshake. The method returns a `ConnectResult` and never throws.\n\n```ts\nconst result = await claw.connect();\n\nif (result.ok) {\n  console.log(\"Protocol:\", result.protocol);       // e.g. 4\n  console.log(\"Server:\", result.serverVersion);     // e.g. \"2026.6.11\"\n  console.log(\"Methods:\", result.features?.methods); // gateway-advertised RPCs\n} else {\n  console.error(result.error?.code, result.error?.message);\n  // Structured details (v4 gateways) explain exactly what failed:\n  if (result.error?.details?.code === \"PROTOCOL_MISMATCH\") {\n    console.error(\"Gateway expects protocol\", result.error.details.expectedProtocol);\n  }\n}\n```\n\n### What Happens During Connect\n\nThe handshake follows a challenge-response flow:\n\n1. The client opens a WebSocket connection to the gateway URL.\n2. The gateway sends a `connect.challenge` event containing a `nonce` and timestamp.\n3. The SDK signs the nonce with the device's Ed25519 private key and sends a `connect` request frame containing client metadata, auth token, device signature, scopes, and capabilities.\n4. The gateway validates the signature and responds with a `hello-ok` payload containing the negotiated protocol version, server info, features, snapshot, and policy limits.\n5. The SDK automatically subscribes to session events (`sessions.subscribe`).\n\nYou do not need to handle any of these steps manually — `connect()` manages the entire flow.\n\n### `ConnectResult`\n\n| Field           | Type     | Description                                         |\n| --------------- | -------- | --------------------------------------------------- |\n| `ok`            | `boolean`| Whether the connection succeeded                     |\n| `protocol`      | `number` | Negotiated protocol version (present when `ok`)      |\n| `serverVersion` | `string` | Gateway server version (present when `ok`)           |\n| `features`      | `object` | `{ methods, events }` advertised by the gateway (v4+) |\n| `error`         | `object` | `{ code, message, details? }` when `ok` is false. `details.code` carries stable identifiers like `PROTOCOL_MISMATCH`, `PAIRING_REQUIRED`, or `DEVICE_AUTH_*` (v4+) |\n\n### Connection State\n\nCheck the current state at any time:\n\n```ts\nclaw.state;        // \"disconnected\" | \"connecting\" | \"connected\"\nclaw.isConnected;  // boolean shorthand\n```\n\n---\n\n## Disconnect\n\nCall `disconnect()` to close the WebSocket, cancel any pending requests, and stop auto-reconnect.\n\n```ts\nclaw.disconnect();\n```\n\nAfter disconnecting, the client emits a `disconnected` health event and transitions to the `\"disconnected\"` state. You can call `connect()` again to re-establish the connection.\n\n### Cleanup Pattern\n\nAlways unsubscribe your listeners when tearing down to prevent memory leaks:\n\n```ts\n// Store unsubscribe handles\nconst unsubHealth = claw.sessionHealth(onHealth);\nconst unsubChat = claw.chatEvents(onChat);\n\n// On teardown\nfunction cleanup() {\n  unsubHealth();\n  unsubChat();\n  claw.disconnect();\n}\n```\n\n---\n\n## Event Mapping\n\nThe SDK maps raw gateway wire-protocol frames into two typed event streams: **health events** and **chat events**. Subscribe to each with a callback that receives structured event objects.\n\n### Health Events — `sessionHealth()`\n\nConnection lifecycle, gateway errors, and session-list changes. Subscribe **before** calling `connect()` so you capture the initial connection events.\n\n```ts\nconst unsub = claw.sessionHealth((event: HealthEvent) => {\n  switch (event.code) {\n    case \"connecting\":\n      // WebSocket opening, handshake in progress\n      break;\n    case \"connected\":\n      // Handshake complete, gateway ready\n      break;\n    case \"disconnected\":\n      // WebSocket closed\n      break;\n    case \"reconnecting\":\n      // Auto-reconnect scheduled (includes backoff delay in message)\n      break;\n    case \"error\":\n      // WebSocket error or gateway-level error event\n      break;\n    case \"sessions_changed\":\n      // The sessions list was updated server-side\n      break;\n    case \"event\":\n      // Any other unhandled gateway event (forwarded as-is)\n      break;\n  }\n});\n```\n\n#### `HealthEvent`\n\n| Field     | Type     | Description                                    |\n| --------- | -------- | ---------------------------------------------- |\n| `code`    | `string` | Event code (see table below)                   |\n| `message` | `string` | Human-readable description                     |\n\n#### Health Event Codes\n\n| Code                | Trigger                                                    |\n| ------------------- | ---------------------------------------------------------- |\n| `connecting`        | WebSocket is opening                                        |\n| `connected`         | Handshake complete; includes protocol version and server version |\n| `disconnected`      | WebSocket closed; includes close code and reason            |\n| `reconnecting`      | Auto-reconnect scheduled; includes backoff delay            |\n| `error`             | WebSocket error, handshake failure, or gateway error event  |\n| `sessions_changed`  | Gateway notified that the sessions list was updated         |\n| `event`             | Catch-all for unhandled gateway events                      |\n\n### Chat Events — `chatEvents()`\n\nStreaming tokens, completed messages, errors, and aborted runs. Chat events now include **thinking content** and **token usage** when available.\n\n```ts\nconst unsub = claw.chatEvents((event: ChatEvent) => {\n  switch (event.type) {\n    case \"stream\":\n      // Streaming chunk arrived. On v4 gateways `text` is the increment;\n      // when `event.replace` is true, reset your buffer to `text` instead\n      // of appending (full-content refresh).\n      console.log(\"Streaming:\", event.text);\n      if (event.thinking) console.log(\"Thinking:\", event.thinking);\n      break;\n    case \"final\":\n      // Run complete — full response available\n      console.log(\"Final:\", event.text);\n      if (event.thinking) console.log(\"Reasoning:\", event.thinking);\n      if (event.usage) console.log(\"Usage:\", event.usage);\n      break;\n    case \"error\":\n      // Run failed\n      console.error(\"Error:\", event.text);\n      break;\n    case \"aborted\":\n      // Run was cancelled\n      console.log(\"Aborted\");\n      break;\n  }\n});\n```\n\n#### `ChatEvent`\n\n| Field        | Type                                           | Description                                      |\n| ------------ | ---------------------------------------------- | ------------------------------------------------ |\n| `type`       | `\"stream\" \\| \"final\" \\| \"error\" \\| \"aborted\" \\| \"tool\"` | Event type                             |\n| `runId`      | `string`                                       | Unique run identifier                            |\n| `sessionKey` | `string`                                       | Session this event belongs to                    |\n| `text`       | `string`                                       | Incremental chunk on `stream` (v4 gateways), full text on `final` |\n| `replace`    | `boolean \\| undefined`                         | On `stream`: `text` is a full refresh — replace your buffer, don't append |\n| `thinking`   | `string \\| undefined`                          | Thinking/reasoning text (when extended thinking is enabled) |\n| `usage`      | `TokenUsage \\| undefined`                      | Token usage counters (typically populated on `final`) |\n| `errorKind`  | `ChatErrorKind \\| undefined`                   | Failure category on `error`: `refusal`, `timeout`, `rate_limit`, `context_length`, `unknown` (v4+) |\n| `stopReason` | `string \\| undefined`                          | Provider stop reason on `final`/`aborted` (v4+)  |\n| `tool`       | `object \\| undefined`                          | `{ phase, name, toolCallId, args }` on `tool` events (phases: `start`, `update`, `result`) |\n| `raw`        | `Record<string, unknown>`                      | Raw gateway payload for advanced use             |\n\n---\n\n## Extended Thinking\n\nExtended thinking enables the model to show its internal reasoning process. Set a thinking level globally or per-message.\n\n### Thinking Levels\n\n| Level       | Description                                    |\n| ----------- | ---------------------------------------------- |\n| `\"off\"`     | No thinking (default)                          |\n| `\"minimal\"` | Very brief internal reasoning                  |\n| `\"low\"`     | Light reasoning                                |\n| `\"medium\"`  | Moderate reasoning                             |\n| `\"high\"`    | Deep reasoning                                 |\n| `\"xhigh\"`   | Maximum reasoning depth                        |\n| `\"adaptive\"`| Provider picks automatically                   |\n\n### Global Default\n\nSet a default thinking level at initialization:\n\n```ts\nconst claw = ControlUIClaw.init({\n  url: \"wss://gateway:18789\",\n  token: \"xxx\",\n  thinking: \"medium\",\n});\n```\n\n### Per-Message Override\n\nOverride the default for a specific message:\n\n```ts\n// Use high thinking for a complex question\nawait claw.sendPrompt(sessionKey, \"Solve this step by step\", { thinking: \"high\" });\n\n// Disable thinking for a simple question\nawait claw.sendPrompt(sessionKey, \"What time is it?\", { thinking: \"off\" });\n```\n\n### Accessing Thinking Content\n\nThinking text arrives in chat events via the `thinking` field:\n\n```ts\nclaw.chatEvents((event) => {\n  // During streaming, thinking may arrive incrementally\n  if (event.type === \"stream\" && event.thinking) {\n    updateThinkingUI(event.thinking);\n  }\n\n  // On final, the complete thinking is available\n  if (event.type === \"final\" && event.thinking) {\n    console.log(\"Full reasoning:\", event.thinking);\n  }\n});\n```\n\n---\n\n## Token Usage\n\nEvery chat event can carry token usage counters. Usage is typically populated on `final` events with the complete totals, though `stream` events may carry partial usage from some providers.\n\n### `TokenUsage`\n\n| Field         | Type                      | Description                              |\n| ------------- | ------------------------- | ---------------------------------------- |\n| `input`       | `number \\| undefined`     | Input / prompt tokens                    |\n| `output`      | `number \\| undefined`     | Output / completion tokens               |\n| `totalTokens` | `number \\| undefined`     | Total tokens (input + output)            |\n| `cacheRead`   | `number \\| undefined`     | Tokens served from prompt cache          |\n| `cacheWrite`  | `number \\| undefined`     | Tokens written to prompt cache           |\n| `cost`        | `Record<string, unknown>` | Provider-reported cost (when available)   |\n\n### Accessing Usage\n\n```ts\nclaw.chatEvents((event) => {\n  if (event.type === \"final\" && event.usage) {\n    console.log(`Input: ${event.usage.input} tokens`);\n    console.log(`Output: ${event.usage.output} tokens`);\n    console.log(`Total: ${event.usage.totalTokens} tokens`);\n\n    if (event.usage.cacheRead) {\n      console.log(`Cache read: ${event.usage.cacheRead} tokens`);\n    }\n  }\n});\n```\n\nThe SDK normalizes usage from different providers, accepting both camelCase (`inputTokens`, `outputTokens`) and snake_case (`input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`) field names.\n\n---\n\n## Session Title Derivation\n\nThe SDK derives human-readable titles for sessions when the gateway doesn't provide one. This uses the same cascading priority as the gateway:\n\n1. **`displayName`** — explicit user-set name\n2. **`label`** — if it looks like a real label (not raw metadata/JSON)\n3. **First user message** — truncated to 60 characters at word boundaries\n4. **Session key prefix + date** — fallback using the first 8 characters of the key\n\n### Automatic Derivation\n\n`listSessions()` automatically derives titles for sessions that are missing a `derivedTitle`:\n\n```ts\nconst sessions = await claw.listSessions();\n\nfor (const session of sessions) {\n  // derivedTitle is always populated — either from the gateway or derived client-side\n  console.log(session.derivedTitle);\n}\n```\n\n### Manual Derivation\n\nUse the static method to derive a title yourself:\n\n```ts\nconst title = ControlUIClaw.deriveSessionTitle(session, \"Hello, how are you?\");\n// → \"Hello, how are you?\"\n\nconst title2 = ControlUIClaw.deriveSessionTitle(session);\n// → Falls back to session key prefix like \"a1b2c3d4 (2026-04-13)\"\n```\n\n---\n\n## Health Events — Deep Dive\n\nHealth events serve as the single observability surface for connection state. Use them to drive UI indicators, trigger reconnection logic, or feed monitoring dashboards.\n\n### Recommended Patterns\n\n**Connection status indicator:**\n\n```ts\nlet isOnline = false;\n\nclaw.sessionHealth((event) => {\n  if (event.code === \"connected\") {\n    isOnline = true;\n    updateStatusDot(\"green\");\n  }\n  if (event.code === \"disconnected\" || event.code === \"error\") {\n    isOnline = false;\n    updateStatusDot(\"red\");\n  }\n  if (event.code === \"reconnecting\") {\n    updateStatusDot(\"yellow\");\n  }\n});\n```\n\n**Error logging:**\n\n```ts\nclaw.sessionHealth((event) => {\n  if (event.code === \"error\") {\n    // event.message contains the human-readable error:\n    // - \"Handshake failed: ...\"\n    // - \"WebSocket error\"\n    // - Gateway-level errors (billing, auth, rate limits)\n    reportError(event.message);\n  }\n});\n```\n\n**Session list refresh:**\n\n```ts\nclaw.sessionHealth(async (event) => {\n  if (event.code === \"sessions_changed\") {\n    const sessions = await claw.listSessions();\n    renderSessionList(sessions);\n  }\n});\n```\n\n### Auto-Reconnect Behavior\n\nWhen `autoReconnect` is enabled (the default), the SDK automatically attempts to reconnect after an unexpected disconnection. The reconnect cycle works as follows:\n\n1. On disconnect, the SDK emits `disconnected`, then `reconnecting`.\n2. After the backoff delay, it opens a new WebSocket and re-runs the handshake.\n3. On success, it emits `connected` and resets the backoff timer.\n4. On failure, it backs off exponentially (factor of 1.7x) up to `maxBackoffMs`, then retries.\n\nCalling `disconnect()` stops the reconnect cycle entirely.\n\n---\n\n## Wire Protocol Reference\n\nThe SDK abstracts the wire protocol, but understanding it helps when debugging or building advanced integrations.\n\n### Frame Types\n\nAll messages over the WebSocket are JSON-encoded frames with a `type` discriminator:\n\n**Request frame** (client to gateway):\n\n```json\n{\n  \"type\": \"req\",\n  \"id\": \"unique-request-id\",\n  \"method\": \"chat.send\",\n  \"params\": { \"sessionKey\": \"...\", \"message\": \"...\", \"thinking\": \"medium\" }\n}\n```\n\n**Response frame** (gateway to client):\n\n```json\n{\n  \"type\": \"res\",\n  \"id\": \"unique-request-id\",\n  \"ok\": true,\n  \"payload\": { ... }\n}\n```\n\n**Event frame** (gateway to client, server-initiated):\n\n```json\n{\n  \"type\": \"event\",\n  \"event\": \"chat\",\n  \"payload\": {\n    \"state\": \"final\",\n    \"runId\": \"...\",\n    \"message\": { \"content\": [{ \"type\": \"text\", \"text\": \"...\" }, { \"type\": \"thinking\", \"thinking\": \"...\" }] },\n    \"usage\": { \"input\": 150, \"output\": 320, \"totalTokens\": 470 }\n  }\n}\n```\n\nOn protocol v4 gateways, `delta` payloads additionally carry the incremental chunk and an optional refresh marker:\n\n```json\n{\n  \"type\": \"event\",\n  \"event\": \"chat\",\n  \"payload\": {\n    \"state\": \"delta\",\n    \"runId\": \"...\",\n    \"deltaText\": \"next chunk\",\n    \"replace\": false,\n    \"message\": { \"role\": \"assistant\", \"content\": [{ \"type\": \"text\", \"text\": \"full accumulated text so far\" }] }\n  }\n}\n```\n\n### Gateway Events\n\n| Event                | Description                                   | SDK Mapping          |\n| -------------------- | --------------------------------------------- | -------------------- |\n| `connect.challenge`  | Handshake nonce from gateway                   | Handled internally   |\n| `tick`               | Periodic keepalive with server timestamp       | Silently ignored     |\n| `chat`               | Chat state change (delta, final, error, abort) | `chatEvents()`       |\n| `sessions.changed`   | Sessions list updated                          | `sessionHealth()`    |\n| `error`              | Gateway-level error                            | `sessionHealth()`    |\n| `session.error`      | Session-scoped error                           | `sessionHealth()`    |\n| `session.tool`       | Tool lifecycle event (start/update/result)     | `chatEvents()` as `type: \"tool\"` |\n| `health`             | Gateway health snapshot with channel status    | `onChannelStatus()`  |\n| *(other)*            | Any unrecognized event                         | `sessionHealth()` as `code: \"event\"` |\n\n---\n\n## Sessions and Chat\n\n### List Sessions\n\n```ts\nconst sessions = await claw.listSessions({ limit: 20 });\n\nfor (const session of sessions) {\n  console.log(session.derivedTitle || session.label || session.key);\n  console.log(\"  Status:\", session.status);\n  console.log(\"  Updated:\", new Date(session.updatedAt));\n}\n```\n\n### Send a Prompt\n\n```ts\nconst sessionKey = ControlUIClaw.createSessionKey();\n\n// Basic send\nawait claw.sendPrompt(sessionKey, \"What is the weather in Berlin?\");\n\n// With thinking enabled\nawait claw.sendPrompt(sessionKey, \"Explain quantum entanglement step by step\", {\n  thinking: \"high\",\n});\n```\n\nResponses arrive asynchronously through `chatEvents()`. The SDK generates a unique idempotency key per send to prevent duplicate processing.\n\n### `SendPromptOptions`\n\n| Field      | Type            | Default                  | Description                                  |\n| ---------- | --------------- | ------------------------ | -------------------------------------------- |\n| `thinking` | `ThinkingLevel` | Inherited from init      | Thinking level override for this message     |\n\n### Abort a Run\n\nCancel the active run for a session (or a specific run by id). The cancelled run emits an `aborted` chat event.\n\n```ts\nawait claw.abortChat(sessionKey);         // abort the active run\nawait claw.abortChat(sessionKey, runId);  // abort a specific run\n```\n\n### Load Chat History\n\n```ts\nconst history = await claw.chatHistory(sessionKey, { limit: 50 });\n\nfor (const msg of history.messages ?? history.items ?? []) {\n  console.log(`${msg.role}: ${ControlUIClaw.extractText(msg)}`);\n}\n```\n\n### Generic Requests\n\nFor gateway methods not covered by the convenience methods above, use `request()` directly:\n\n```ts\nconst result = await claw.request<{ status: string }>(\n  \"agents.status\",\n  { agentId: \"main\" },\n);\n```\n\n---\n\n## Channel Management\n\nThe SDK provides typed methods for connecting, disconnecting, and monitoring messaging channels (WhatsApp, Telegram, Discord, Slack, etc.).\n\n### Channel Enum\n\nUse the `Channel` enum for type-safe channel references:\n\n```ts\nimport { Channel } from \"@controluiclaw/sdk\";\n\nChannel.WhatsApp   // \"whatsapp\"\nChannel.Telegram   // \"telegram\"\nChannel.Discord    // \"discord\"\nChannel.Slack      // \"slack\"\nChannel.Signal     // \"signal\"\nChannel.IMessage   // \"imessage\"\nChannel.GoogleChat // \"googlechat\"\nChannel.Nostr      // \"nostr\"\n```\n\n### Get Channel Status\n\nRetrieve the status of all configured channels and their accounts:\n\n```ts\n// Basic status (no probes)\nconst status = await claw.getChannelsStatus();\nconsole.log(status.channelOrder);                // [\"whatsapp\", \"telegram\", ...]\nconsole.log(status.channels.whatsapp?.connected); // true / false\n\n// With health probes (slower, more accurate)\nconst probed = await claw.getChannelsStatus(true, 10000);\n```\n\n### Connect WhatsApp (QR Code)\n\nWhatsApp uses QR code pairing. `startWhatsAppChannelLogin()` handles the full flow and reports progress via a callback:\n\n```ts\nawait claw.startWhatsAppChannelLogin({\n  onStatus: (event) => {\n    switch (event.step) {\n      case \"qr_ready\":\n        renderQrCode(event.qrDataUrl);          // show QR in your UI\n        break;\n      case \"scanning\":\n        showSpinner(\"Waiting for scan...\");\n        break;\n      case \"authenticating\":\n        showSpinner(\"Authenticating...\");\n        break;\n      case \"connected\":\n        showSuccess(\"WhatsApp connected!\");\n        break;\n      case \"failed\":\n        showError(event.error);\n        break;\n    }\n  },\n  timeoutMs: 120000,\n  force: false,        // set true to force re-login\n});\n```\n\n### Connect Telegram (Bot Token)\n\n```ts\nawait claw.setTelegramChannelToken(\"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\");\n\n// With a specific account\nawait claw.setTelegramChannelToken(\"token\", \"bot2\");\n```\n\n### Connect Discord (Bot Token)\n\n```ts\nawait claw.setDiscordChannelToken(\"MTIzNDU2Nzg5MDEy...\");\n```\n\n### Connect Slack (Bot + App Tokens)\n\n```ts\nawait claw.setSlackChannelTokens(\"xoxb-...\", \"xapp-...\");\n```\n\n### Disconnect Any Channel\n\n```ts\nawait claw.logoutChannel(Channel.WhatsApp);\nawait claw.logoutChannel(Channel.Telegram, { accountId: \"bot2\" });\n```\n\n### Real-Time Channel Status\n\nSubscribe to live channel status changes. The callback fires whenever the gateway health snapshot updates:\n\n```ts\nconst unsub = claw.onChannelStatus((event) => {\n  const wa = event.channels.whatsapp;\n  console.log(\"WhatsApp connected:\", wa?.connected);\n  console.log(\"Telegram running:\", event.channels.telegram?.running);\n});\n\n// Later:\nunsub();\n```\n\n---\n\n## Skills Management\n\nList installed skills for the default workspace and enable or disable them by key. Requires an active connection (`claw.isConnected`).\n\n### List Skill Status\n\n```ts\nconst report = await claw.getSkillsStatus();\n\nconsole.log(\"Workspace:\", report.workspaceDir);\nconsole.log(\"Managed skills dir:\", report.managedSkillsDir);\n\nfor (const skill of report.skills) {\n  console.log(\n    skill.skillKey,\n    skill.disabled ? \"(disabled)\" : \"(enabled)\",\n    skill.eligible ? \"\" : \"(missing requirements)\",\n  );\n  if (!skill.eligible) {\n    console.log(\"  Missing bins:\", skill.missing.bins);\n    console.log(\"  Missing env:\", skill.missing.env);\n  }\n}\n```\n\nEach `SkillStatusEntry` includes metadata (`name`, `description`, `source`, `filePath`), eligibility flags (`eligible`, `disabled`, `blockedByAllowlist`, `always`), and `requirements` / `missing` breakdowns for bins, env vars, config keys, and OS constraints.\n\n### Enable or Disable a Skill\n\n```ts\nconst result = await claw.updateSkill(\"my-skill-key\", true);\nconsole.log(result.ok, result.skillKey);\n```\n\n---\n\n## Cron Job Management\n\nCreate, list, update, and remove scheduled jobs on the gateway. Cron jobs can run agent turns or inject system events into a session on a cron expression, fixed interval, or one-shot schedule.\n\nAll cron methods require an active connection.\n\n### List Cron Jobs\n\nBy default, `listCronJobs()` includes disabled jobs. Use options to paginate or filter:\n\n```ts\nconst { jobs, total, hasMore } = await claw.listCronJobs({\n  includeDisabled: true,\n  limit: 50,\n  offset: 0,\n  query: \"daily\",\n});\n\nfor (const job of jobs) {\n  console.log(job.id, job.name, job.enabled ? \"on\" : \"off\");\n  if (job.state?.nextRunAtMs) {\n    console.log(\"  Next run:\", new Date(job.state.nextRunAtMs).toISOString());\n  }\n}\n```\n\n### Create a Cron Job\n\n`CronJobCreate` omits server-assigned fields (`id`, `createdAtMs`, `updatedAtMs`, `state`). Schedules use a discriminated `kind`:\n\n| `kind`    | Fields                          | Use case                          |\n| --------- | ------------------------------- | --------------------------------- |\n| `\"cron\"`  | `expr`, optional `tz`, `staggerMs` | Standard cron expression       |\n| `\"every\"` | `everyMs`, optional `anchorMs`   | Fixed interval in milliseconds |\n| `\"at\"`    | `at` (ISO timestamp)               | One-shot run                   |\n\nPayloads are either an **agent turn** (sends a message to the agent) or a **system event** (injects text into the session):\n\n```ts\nimport type { CronJobCreate } from \"@controluiclaw/sdk\";\n\n// Recurring agent turn every day at 9:00 UTC\nconst daily: CronJobCreate = {\n  name: \"Morning briefing\",\n  enabled: true,\n  schedule: { kind: \"cron\", expr: \"0 9 * * *\", tz: \"UTC\" },\n  sessionTarget: \"main\",\n  wakeMode: \"now\",\n  payload: {\n    kind: \"agentTurn\",\n    message: \"Summarize overnight activity.\",\n    thinking: \"low\",\n    timeoutSeconds: 120,\n  },\n  delivery: { mode: \"none\" },\n};\n\nconst created = await claw.addCronJob(daily);\nconsole.log(\"Created job:\", created.id);\n\n// One-shot system event\nconst once: CronJobCreate = {\n  name: \"Reminder\",\n  enabled: true,\n  schedule: { kind: \"at\", at: \"2026-12-31T23:59:00Z\" },\n  sessionTarget: \"isolated\",\n  wakeMode: \"next-heartbeat\",\n  payload: { kind: \"systemEvent\", text: \"Year-end checkpoint.\" },\n  deleteAfterRun: true,\n};\n\nawait claw.addCronJob(once);\n```\n\n`sessionTarget` can be `\"main\"`, `\"isolated\"`, `\"current\"`, or `` `session:${sessionKey}` ``. `wakeMode` is `\"now\"` or `\"next-heartbeat\"`. Optional `delivery` supports `mode: \"none\" | \"announce\" | \"webhook\"` with channel routing fields.\n\n### Update, Enable, Disable, and Remove\n\n```ts\n// Partial update\nconst updated = await claw.updateCronJob(jobId, {\n  name: \"Morning briefing (v2)\",\n  payload: { kind: \"agentTurn\", message: \"Updated prompt.\" },\n});\n\n// Toggle without rebuilding the patch object\nawait claw.setCronJobEnabled(jobId, false);\n\n// Delete\nconst removed = await claw.removeCronJob(jobId);\nconsole.log(removed.ok, removed.removed);\n```\n\n### Run Now, Run History, and Scheduler Status\n\n```ts\n// Trigger a job immediately (\"force\" is the default), or only if due\nconst run = await claw.runCronJob(jobId);\nconst dueRun = await claw.runCronJob(jobId, \"due\");\nconsole.log(run.ok, run.ran);\n\n// Run history for one job, or gateway-wide\nconst history = await claw.listCronRuns({ id: jobId, limit: 20 });\nfor (const entry of history.entries ?? history.runs ?? []) {\n  console.log(entry.jobName, entry.status, new Date(entry.ts).toISOString());\n}\n\n// Overall scheduler status\nconst status = await claw.getCronStatus();\n```\n\n---\n\n## Device Authentication\n\nThe SDK uses Ed25519 key pairs for device authentication. By default, a key pair is auto-generated on first connect and cached in `sessionStorage` (browser) or in memory (Node.js).\n\n### How It Works\n\n1. On first connection, the SDK generates an Ed25519 key pair.\n2. The public key's SHA-256 fingerprint becomes the device ID.\n3. During handshake, the SDK signs a payload containing the device ID, client info, role, scopes, timestamp, auth token, and challenge nonce.\n4. The gateway verifies the signature against the public key.\n\n### Custom Device Identity\n\nProvide your own key pair for persistent device identity across sessions:\n\n```ts\nimport { getOrCreateDeviceIdentity, clearCachedIdentity } from \"@controluiclaw/sdk\";\n\n// Generate and retrieve a device identity\nconst identity = await getOrCreateDeviceIdentity();\nconsole.log(\"Device ID:\", identity.deviceId);\n\n// Use a custom identity\nconst claw = ControlUIClaw.init({\n  url: \"wss://gateway:18789\",\n  deviceIdentity: {\n    deviceId: \"my-device-fingerprint\",\n    publicKey: \"base64url-encoded-public-key\",\n    privateKey: \"base64url-encoded-private-key\",\n  },\n});\n\n// Clear cached identity (for key rotation or testing)\nclearCachedIdentity();\n```\n\n### Crypto Backend\n\nThe SDK uses the Web Crypto API's native Ed25519 support when available. On older browsers that lack native Ed25519, it falls back to the `@noble/ed25519` library (listed as an optional dependency).\n\n---\n\n## Full Integration Example\n\nA complete example showing initialization with thinking, health monitoring, chat streaming with usage tracking, and cleanup:\n\n```ts\nimport { ControlUIClaw } from \"@controluiclaw/sdk\";\n\nasync function main() {\n  const claw = ControlUIClaw.init({\n    url: \"wss://gateway-host:18789\",\n    token: \"your-token\",\n    thinking: \"medium\",\n  });\n\n  // Health monitoring\n  const unsubHealth = claw.sessionHealth((event) => {\n    console.log(`[health] ${event.code}: ${event.message}`);\n\n    if (event.code === \"connected\") {\n      console.log(\"Ready to send messages\");\n    }\n\n    if (event.code === \"error\") {\n      console.error(\"Gateway error:\", event.message);\n    }\n  });\n\n  // Chat event streaming with thinking + usage\n  const streamBuffers = new Map<string, string>();\n\n  const unsubChat = claw.chatEvents((event) => {\n    switch (event.type) {\n      case \"stream\": {\n        const buffer = (streamBuffers.get(event.runId) ?? \"\") + event.text;\n        streamBuffers.set(event.runId, buffer);\n        process.stdout.write(event.text);\n        break;\n      }\n      case \"final\":\n        streamBuffers.delete(event.runId);\n        console.log(\"\\n[complete]\");\n\n        // Show thinking if present\n        if (event.thinking) {\n          console.log(\"\\n--- Reasoning ---\");\n          console.log(event.thinking);\n        }\n\n        // Show token usage\n        if (event.usage) {\n          console.log(\"\\n--- Usage ---\");\n          console.log(`  Input:  ${event.usage.input} tokens`);\n          console.log(`  Output: ${event.usage.output} tokens`);\n          console.log(`  Total:  ${event.usage.totalTokens} tokens`);\n          if (event.usage.cacheRead) console.log(`  Cache:  ${event.usage.cacheRead} read`);\n        }\n        break;\n      case \"error\":\n        streamBuffers.delete(event.runId);\n        console.error(\"\\n[error]\", event.text);\n        break;\n      case \"aborted\":\n        streamBuffers.delete(event.runId);\n        console.log(\"\\n[aborted]\");\n        break;\n    }\n  });\n\n  // Connect\n  const result = await claw.connect();\n  if (!result.ok) {\n    console.error(\"Failed to connect:\", result.error);\n    return;\n  }\n\n  // List sessions with derived titles\n  const sessions = await claw.listSessions();\n  for (const s of sessions) {\n    console.log(`${s.derivedTitle} [${s.status}]`);\n  }\n\n  // Send a message with high thinking\n  const sessionKey = ControlUIClaw.createSessionKey();\n  await claw.sendPrompt(sessionKey, \"Explain the P vs NP problem\", {\n    thinking: \"high\",\n  });\n\n  // Disconnect after 30 seconds\n  setTimeout(() => {\n    unsubHealth();\n    unsubChat();\n    claw.disconnect();\n    console.log(\"Disconnected\");\n  }, 30_000);\n}\n\nmain();\n```\n\n---\n\n## API Reference Summary\n\n### `ControlUIClaw` Instance Methods\n\n| Method | Returns | Description |\n| --- | --- | --- |\n| `connect()` | `Promise<ConnectResult>` | Open WebSocket and complete handshake |\n| `disconnect()` | `void` | Close connection and stop reconnects |\n| `listSessions(options?)` | `Promise<Session[]>` | Fetch sessions with derived titles |\n| `sendPrompt(key, msg, opts?)` | `Promise<void>` | Send a message with optional thinking level |\n| `sendImagePrompt(key, msg, opts)` | `Promise<void>` | Send a message with image attachments |\n| `abortChat(key, runId?)` | `Promise<void>` | Cancel the active (or a specific) run |\n| `chatHistory(key, options?)` | `Promise<ChatHistoryResult>` | Load chat history for a session |\n| `sessionHealth(cb)` | `Unsubscribe` | Subscribe to health/connection events |\n| `chatEvents(cb)` | `Unsubscribe` | Subscribe to chat events with thinking + usage |\n| `getChannelsStatus(probe?, timeoutMs?)` | `Promise<ChannelsStatusResult>` | Get all channel statuses |\n| `startWhatsAppChannelLogin(opts)` | `Promise<void>` | Full WhatsApp QR login flow with callback |\n| `setTelegramChannelToken(token, acct?)` | `Promise<void>` | Set Telegram bot token |\n| `setDiscordChannelToken(token, acct?)` | `Promise<void>` | Set Discord bot token |\n| `setSlackChannelTokens(bot, app, acct?)` | `Promise<void>` | Set Slack bot + app tokens |\n| `logoutChannel(channel, opts?)` | `Promise<ChannelLogoutResult>` | Disconnect any channel |\n| `onChannelStatus(cb)` | `Unsubscribe` | Subscribe to real-time channel status |\n| `getSkillsStatus()` | `Promise<SkillStatusReport>` | List installed skills and eligibility |\n| `updateSkill(skillKey, enabled)` | `Promise<SkillUpdateResult>` | Enable or disable a skill by key |\n| `listCronJobs(opts?)` | `Promise<CronJobsListResult>` | List cron jobs with optional pagination |\n| `addCronJob(job)` | `Promise<CronJob>` | Create a new cron job |\n| `updateCronJob(id, patch)` | `Promise<CronJob>` | Patch an existing cron job |\n| `removeCronJob(id)` | `Promise<CronRemoveResult>` | Delete a cron job |\n| `setCronJobEnabled(id, enabled)` | `Promise<CronJob>` | Enable or disable a cron job |\n| `runCronJob(id, mode?)` | `Promise<CronRunResult>` | Run a job now (`\"force\"`) or only if due (`\"due\"`) |\n| `listCronRuns(opts?)` | `Promise<CronRunsResult>` | Fetch cron run history |\n| `getCronStatus()` | `Promise<CronStatusResult>` | Get scheduler status |\n| `request<T>(method, params?)` | `Promise<T>` | Generic gateway request |\n\n### `ControlUIClaw` Static Methods\n\n| Method | Returns | Description |\n| --- | --- | --- |\n| `init(options)` | `ControlUIClaw` | Create a new client instance |\n| `createSessionKey(prefix?)` | `string` | Generate a unique session key |\n| `extractText(msg)` | `string` | Extract readable text from any message shape |\n| `deriveSessionTitle(session, firstMsg?)` | `string` | Derive a human-readable session title |\n\n### Exported Types & Enums\n\n| Export | Kind | Description |\n| --- | --- | --- |\n| `Channel` | enum | Channel identifiers (`WhatsApp`, `Telegram`, `Discord`, etc.) |\n| `GatewayRequestError` | class | Error thrown by failed requests; carries gateway `code` and `details` |\n| `SDK_VERSION` | const | SDK release version advertised to the gateway |\n| `ConnectErrorDetails` | type | Structured connect-failure detail (`PROTOCOL_MISMATCH` bounds, pairing info) |\n| `ChatErrorKind` | type | Failure category on `error` chat events |\n| `CronRunMode` | type | `\"due\"` \\| `\"force\"` |\n| `CronRunResult` | type | Result from `runCronJob()` |\n| `CronRunsOptions` | type | Options for `listCronRuns()` |\n| `CronRunsResult` | type | Run history from `listCronRuns()` |\n| `CronRunLogEntry` | type | Single cron run history entry |\n| `CronStatusResult` | type | Scheduler status from `getCronStatus()` |\n| `InitOptions` | type | Configuration for `init()` |\n| `ConnectResult` | type | Result of `connect()` |\n| `HealthEvent` | type | Health/connection event |\n| `ChatEvent` | type | Chat event with text, thinking, and usage |\n| `Session` | type | Session metadata |\n| `SendPromptOptions` | type | Options for `sendPrompt()` |\n| `SendImagePromptOptions` | type | Options for `sendImagePrompt()` |\n| `ImageAttachment` | type | Image attachment data for `sendImagePrompt()` |\n| `TokenUsage` | type | Token usage counters |\n| `ThinkingLevel` | type | Thinking level union |\n| `ChatMessage` | type | Chat message from history |\n| `ContentBlock` | type | Message content block (text or thinking) |\n| `ChatHistoryResult` | type | Chat history response |\n| `ClientInfo` | type | Client identification |\n| `DeviceIdentity` | type | Ed25519 device key pair |\n| `ChannelsStatusResult` | type | Full result from `getChannelsStatus()` |\n| `ChannelsChannelData` | type | Per-channel status map |\n| `ChannelAccountSnapshot` | type | Generic per-account status |\n| `WhatsAppChannelStatus` | type | WhatsApp-specific status fields |\n| `TelegramChannelStatus` | type | Telegram-specific status fields |\n| `DiscordChannelStatus` | type | Discord-specific status fields |\n| `SlackChannelStatus` | type | Slack-specific status fields |\n| `WhatsAppLoginOptions` | type | Options for `startWhatsAppChannelLogin()` |\n| `WhatsAppLoginStatusEvent` | type | Progress events during WhatsApp login |\n| `ChannelLogoutResult` | type | Result from `logoutChannel()` |\n| `ChannelStatusEvent` | type | Real-time channel status event |\n| `SkillStatusEntry` | type | Single skill metadata and eligibility |\n| `SkillStatusReport` | type | Workspace skill listing from `getSkillsStatus()` |\n| `SkillUpdateResult` | type | Result from `updateSkill()` |\n| `CronJob` | type | Full cron job record |\n| `CronJobCreate` | type | Fields required to create a cron job |\n| `CronJobPatch` | type | Partial update for `updateCronJob()` |\n| `CronJobState` | type | Runtime state (last/next run, status) |\n| `CronJobsListResult` | type | Paginated list from `listCronJobs()` |\n| `CronRemoveResult` | type | Result from `removeCronJob()` |\n| `CronSchedule` | type | Schedule union (`cron` \\| `every` \\| `at`) |\n| `CronScheduleKind` | type | `\"cron\"` \\| `\"every\"` \\| `\"at\"` |\n| `CronScheduleCron` | type | Cron expression schedule |\n| `CronScheduleEvery` | type | Interval schedule |\n| `CronScheduleAt` | type | One-shot schedule |\n| `CronPayload` | type | `agentTurn` or `systemEvent` payload |\n| `CronPayloadAgentTurn` | type | Agent message payload |\n| `CronPayloadSystemEvent` | type | System event text payload |\n| `CronPayloadKind` | type | `\"agentTurn\"` \\| `\"systemEvent\"` |\n| `CronDelivery` | type | Optional delivery routing |\n| `CronDeliveryMode` | type | `\"none\"` \\| `\"announce\"` \\| `\"webhook\"` |\n| `CronSessionTarget` | type | Target session for the job |\n| `CronWakeMode` | type | `\"next-heartbeat\"` \\| `\"now\"` |\n| `CronThinkingLevel` | type | Thinking level for agent-turn payloads |\n| `CronEveryUnit` | type | Unit hint for interval schedules |\n\n---\n\n## Troubleshooting\n\n**\"Already connected or connecting\"** — You called `connect()` while the client is already connected or mid-handshake. Call `disconnect()` first if you need to reconnect.\n\n**\"Not connected\"** — You called `request()`, `sendPrompt()`, `listSessions()`, `getSkillsStatus()`, or a cron method before the connection was established. Wait for `connect()` to resolve with `ok: true`, or check `claw.isConnected` before making requests.\n\n**\"Request timed out\"** — The gateway did not respond within 30 seconds. This may indicate the gateway is overloaded or the network connection is unstable.\n\n**\"Ed25519 not available\"** — Neither native Web Crypto Ed25519 nor `@noble/ed25519` could be loaded. Install the optional dependency: `npm install @noble/ed25519`.\n\n**Thinking not appearing** — Ensure you set a thinking level either globally (`thinking: \"medium\"` in `InitOptions`) or per-message (`{ thinking: \"high\" }` in `sendPrompt`). The gateway must support extended thinking for the configured model.\n\n**Usage showing undefined** — Token usage is provider-dependent. Not all providers report usage on every event. Check `event.usage` on `final` events for the most complete data.\n\n**Session titles showing raw metadata** — The SDK automatically filters out raw JSON/metadata labels and derives titles from the first user message instead. If titles are still not appearing, ensure `listSessions()` has access to `chat.history` for fallback derivation.\n\n**Handshake failures** — Check that your gateway URL is correct and reachable. Verify that `wss://` is used for TLS endpoints and `ws://` only for private LAN addresses. Ensure your auth token is valid if one is required.\n\n**`PROTOCOL_MISMATCH` on connect** — Your SDK and gateway protocol ranges don't overlap. Gateways from OpenClaw 2026.5.19 onward require protocol v4; upgrade to SDK 1.1.0+ (default range `{min: 3, max: 4}`). The `result.error.details` object reports `clientMinProtocol`, `clientMaxProtocol`, and `expectedProtocol` so you can see which side needs updating.\n\n**Streamed text appears duplicated** — You are appending `event.text` per `stream` event against a pre-1.1.0 SDK talking to a v4 gateway (which repeats the accumulated text). Upgrade to SDK 1.1.0+, where `stream` events carry the increment; also honor `event.replace === true` by resetting your buffer instead of appending.\n","readmeFilename":"README.md"}