@clawhub-dougzl-30ee17cdf2
Browser-side request inspection and reporting for user-authorized web debugging. Use when you want one skill to observe page fetch/XHR/WebSocket activity, in...
---
name: browser-network-inspector
description: Browser-side request inspection and reporting for user-authorized web debugging. Use when you want one skill to observe page fetch/XHR/WebSocket activity, inspect login/register/API flows, and generate clean reports from browser interactions. This is for browser debugging, not raw system-wide packet sniffing, credential extraction, or auth bypass.
---
# Browser Network Inspector
A **single-skill browser debugging capture tool** for inspecting page requests and turning them into readable reports.
Use this when you want to:
- see which APIs a page or button triggers
- inspect login, registration, or form-submit flows
- capture page-level `fetch` / `XMLHttpRequest` / basic WebSocket activity
- export a clean JSON + Markdown report after browser actions
This skill is packaged as **one workflow**. Internally it uses a local browser runtime, but the user-facing experience should be treated as one skill from start to finish.
## Use this skill for
- Debugging requests from a web page you are authorized to inspect
- Understanding login / registration / submit flows at the page level
- Seeing which APIs a button click or form submit triggers
- Capturing and summarizing `fetch` / `XMLHttpRequest` activity from the current page
- Producing a redacted request summary for later analysis
## Do not use this skill for
- Extracting or replaying third-party login sessions
- Capturing full system traffic
- Pulling access tokens, cookies, session secrets, or passwords for reuse
- Bypassing captchas, auth, or platform protections
## About the capture model
This skill provides **browser-side request capture**, not raw packet sniffing.
It is designed to help with:
- page request inspection
- form submission debugging
- login / registration flow analysis
- browser API tracing around user actions
It can observe:
- page-level `fetch`
- page-level `XMLHttpRequest`
- basic WebSocket lifecycle + message events
It does **not** try to replace system/network tools such as:
- Wireshark
- mitmproxy
- Fiddler
It also does not inspect arbitrary native processes or full machine traffic.
In short: this skill is best described as a **browser debugging capture and reporting tool**.
## Runtime expectation
- This skill expects a local `agent-browser` CLI runtime to be installed on the machine.
- The helper script `scripts/capture-session.js` auto-detects the local binary and drives the browser runtime for you.
- If `agent-browser` is missing, install it first before using this skill.
## Workflow
1. Open the target page with the browser runtime managed by this skill.
2. Inject the network collector into the page:
```powershell
$collector = Get-Content "$env:USERPROFILE\.openclaw\workspace\skills\browser-network-inspector\scripts\collect-network.js" -Raw
agent-browser eval $collector
```
3. Optional: configure include/exclude host filters before the flow:
```powershell
agent-browser eval "window.__bniSetConfig({ includeHosts: ['example.com'], excludeHosts: ['ads.example.com'], captureWebSocket: true })"
```
4. Perform the desired browser actions (open, click, fill, submit, wait).
5. Read back the captured records:
```powershell
agent-browser eval "JSON.stringify(window.__bniExport ? window.__bniExport() : [])"
```
6. Save the JSON output to a local file.
7. Run the summarizer:
```powershell
node "$env:USERPROFILE\.openclaw\workspace\skills\browser-network-inspector\scripts\summarize-network.js" <input.json> [output.md]
```
8. Or use the bundled one-shot helper after you finish the browser actions:
```bash
node "$env:USERPROFILE/.openclaw/workspace/skills/browser-network-inspector/scripts/capture-session.js" --json-out ".capture/session.json" --md-out ".capture/session.md" --include-hosts "example.com,api.example.com"
```
## v2 additions
- Host include/exclude filtering
- Basic WebSocket event logging
- One-shot export helper: `scripts/capture-session.js`
- Improved summary report with source breakdown
## Polished workflow helpers
Additional JS-only helpers included:
- `scripts/clear-session.js` — clear the in-page capture buffer
- `scripts/capture-and-report.js` — create a timestamped report directory and save both JSON + Markdown
- Export helpers sanitize non-JSON wrapper output before writing capture files
Example:
```bash
node "$env:USERPROFILE/.openclaw/workspace/skills/browser-network-inspector/scripts/clear-session.js"
node "$env:USERPROFILE/.openclaw/workspace/skills/browser-network-inspector/scripts/capture-and-report.js" --include-hosts "example.com,api.example.com" --label login-flow --open-report
```
## Notes
- This captures **page-level JS network activity**. It is not a full packet sniffer.
- `fetch` and `XMLHttpRequest` are supported in v1.
- v2 adds basic WebSocket event logging and a Node.js export helper.
- Request and response bodies are truncated and redacted before export.
- If a site uses service workers, native browser internals, or non-page network paths, results may be incomplete.
- Keep helper scripts in JavaScript or Python only for this environment.
- The bundled export helper is Node.js-based, auto-detects the local browser runtime executable, and injects the collector in chunks to avoid Windows command-length limits.
## Redaction defaults
The collector and summarizer should redact or suppress obvious sensitive values such as:
- `authorization`
- `cookie`
- `set-cookie`
- `password`
- `token`
- `accessToken`
- `refreshToken`
- `session`
- `csrf`
## Output expectation
The summary should tell you:
- how many requests were captured
- which endpoints were hit
- which requests failed
- rough timing and status distribution
- likely key requests in the user flow
If the user wants raw logs, save them locally first and avoid echoing full sensitive payloads into chat.
FILE:scripts/capture-and-report.js
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
function usage() {
console.error('Usage: node capture-and-report.js [--base-dir reports/browser-network-inspector] [--include-hosts a,b] [--exclude-hosts a,b] [--no-websocket] [--open-report] [--label name]');
process.exit(1);
}
const args = process.argv.slice(2);
function getFlag(name) {
const idx = args.indexOf(name);
if (idx === -1) return null;
return args[idx + 1] ?? null;
}
function hasFlag(name) {
return args.includes(name);
}
if (hasFlag('--help')) usage();
const workspace = path.join(process.env.USERPROFILE || process.env.HOME || '.', '.openclaw', 'workspace');
const skillDir = path.join(workspace, 'skills', 'browser-network-inspector');
const captureScript = path.join(skillDir, 'scripts', 'capture-session.js');
const baseDirArg = getFlag('--base-dir') || 'reports/browser-network-inspector';
const baseDir = path.isAbsolute(baseDirArg) ? baseDirArg : path.join(workspace, baseDirArg);
const includeHosts = getFlag('--include-hosts') || '';
const excludeHosts = getFlag('--exclude-hosts') || '';
const noWebSocket = hasFlag('--no-websocket');
const openReport = hasFlag('--open-report');
const label = (getFlag('--label') || 'capture').replace(/[^a-zA-Z0-9-_]+/g, '-');
function stamp() {
const d = new Date();
const p = n => String(n).padStart(2, '0');
return `d.getFullYear()p(d.getMonth() + 1)p(d.getDate())-p(d.getHours())p(d.getMinutes())p(d.getSeconds())`;
}
const outDir = path.join(baseDir, `stamp()-label`);
fs.mkdirSync(outDir, { recursive: true });
const jsonOut = path.join(outDir, 'session.json');
const mdOut = path.join(outDir, 'session.md');
const cmd = [
process.execPath,
captureScript,
'--json-out', jsonOut,
'--md-out', mdOut,
];
if (includeHosts) cmd.push('--include-hosts', includeHosts);
if (excludeHosts) cmd.push('--exclude-hosts', excludeHosts);
if (noWebSocket) cmd.push('--no-websocket');
execFileSync(cmd[0], cmd.slice(1), {
cwd: workspace,
encoding: 'utf8',
stdio: ['ignore', 'inherit', 'inherit'],
maxBuffer: 20 * 1024 * 1024,
});
if (openReport) {
execFileSync('cmd.exe', ['/c', 'start', '', mdOut], {
cwd: workspace,
windowsHide: true,
stdio: ['ignore', 'ignore', 'ignore'],
});
}
console.log(`Report directory: outDir`);
console.log(`JSON: jsonOut`);
console.log(`Markdown: mdOut`);
FILE:scripts/capture-session.js
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
function usage() {
console.error('Usage: node capture-session.js --json-out <path> [--md-out <path>] [--include-hosts a,b] [--exclude-hosts a,b] [--no-websocket]');
process.exit(1);
}
const args = process.argv.slice(2);
function getFlag(name) {
const idx = args.indexOf(name);
if (idx === -1) return null;
return args[idx + 1] ?? null;
}
function hasFlag(name) {
return args.includes(name);
}
const jsonOut = getFlag('--json-out');
const mdOut = getFlag('--md-out');
if (!jsonOut) usage();
const includeHosts = (getFlag('--include-hosts') || '').split(',').map(s => s.trim()).filter(Boolean);
const excludeHosts = (getFlag('--exclude-hosts') || '').split(',').map(s => s.trim()).filter(Boolean);
const captureWebSocket = !hasFlag('--no-websocket');
const workspace = path.join(process.env.USERPROFILE || process.env.HOME || '.', '.openclaw', 'workspace');
const skillDir = path.join(workspace, 'skills', 'browser-network-inspector');
const collectorPath = path.join(skillDir, 'scripts', 'collect-network.js');
const summarizerPath = path.join(skillDir, 'scripts', 'summarize-network.js');
const collector = fs.readFileSync(collectorPath, 'utf8');
const configExpr = `(() => { window.__bniSetConfig(JSON.stringify({ includeHosts, excludeHosts, captureWebSocket)}); return 'ok'; })()`;
function resolveAgentBrowser() {
const pathCandidates = [
path.join(process.env.USERPROFILE || '', '.nvm', 'nodejs', 'node_modules', 'agent-browser', 'bin', 'agent-browser-win32-x64.exe'),
path.join(process.env.APPDATA || '', 'npm', 'agent-browser.cmd'),
path.join(process.env.APPDATA || '', 'npm', 'agent-browser'),
path.join(process.env.USERPROFILE || '', '.nvm', 'nodejs', 'agent-browser.cmd'),
path.join(process.env.USERPROFILE || '', '.nvm', 'nodejs', 'agent-browser'),
].filter(Boolean);
for (const candidate of pathCandidates) {
if (candidate && fs.existsSync(candidate)) return candidate;
}
const probe = spawnSync('where', ['agent-browser'], { encoding: 'utf8', shell: false });
if (!probe.error && probe.status === 0 && probe.stdout) {
const first = probe.stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean)[0];
if (first) return first;
}
return 'agent-browser';
}
const agentBrowserBin = resolveAgentBrowser();
function runAgentBrowserEval(js) {
return execFileSync(agentBrowserBin, ['eval', js], {
cwd: workspace,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 20 * 1024 * 1024,
shell: false,
windowsHide: true,
});
}
function injectLargeScript(js) {
const chunkSize = 6000;
runAgentBrowserEval('(() => { window.__bniChunkSrc = ""; return "ok"; })()');
for (let i = 0; i < js.length; i += chunkSize) {
const chunk = JSON.stringify(js.slice(i, i + chunkSize));
runAgentBrowserEval(`(() => { window.__bniChunkSrc += chunk; return "ok"; })()`);
}
return runAgentBrowserEval('(() => { const out = eval(window.__bniChunkSrc); delete window.__bniChunkSrc; return out; })()');
}
injectLargeScript(collector);
runAgentBrowserEval(configExpr);
const exportedRaw = runAgentBrowserEval("JSON.stringify(window.__bniExport ? window.__bniExport() : [])");
const exported = (() => {
const trimmed = String(exportedRaw || '').trim();
const start = trimmed.indexOf('[');
const end = trimmed.lastIndexOf(']');
if (start !== -1 && end !== -1 && end >= start) return trimmed.slice(start, end + 1);
return '[]';
})();
fs.mkdirSync(path.dirname(jsonOut), { recursive: true });
fs.writeFileSync(jsonOut, exported, 'utf8');
if (mdOut) {
execFileSync(process.execPath, [summarizerPath, jsonOut, mdOut], {
cwd: workspace,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 20 * 1024 * 1024,
});
}
console.log(`Saved JSON: jsonOut`);
if (mdOut) console.log(`Saved Markdown: mdOut`);
FILE:scripts/clear-session.js
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const workspace = path.join(process.env.USERPROFILE || process.env.HOME || '.', '.openclaw', 'workspace');
function resolveAgentBrowser() {
const candidates = [
path.join(process.env.USERPROFILE || '', '.nvm', 'nodejs', 'node_modules', 'agent-browser', 'bin', 'agent-browser-win32-x64.exe'),
path.join(process.env.APPDATA || '', 'npm', 'agent-browser.cmd'),
path.join(process.env.APPDATA || '', 'npm', 'agent-browser'),
path.join(process.env.USERPROFILE || '', '.nvm', 'nodejs', 'agent-browser.cmd'),
path.join(process.env.USERPROFILE || '', '.nvm', 'nodejs', 'agent-browser'),
].filter(Boolean);
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) return candidate;
}
const probe = spawnSync('where', ['agent-browser'], { encoding: 'utf8', shell: false });
if (!probe.error && probe.status === 0 && probe.stdout) {
const first = probe.stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean)[0];
if (first) return first;
}
return 'agent-browser';
}
const agentBrowserBin = resolveAgentBrowser();
const out = execFileSync(agentBrowserBin, ['eval', 'window.__bniClear ? window.__bniClear() : false'], {
cwd: workspace,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
shell: false,
windowsHide: true,
});
console.log(String(out).trim() || 'cleared');
FILE:scripts/collect-network.js
(() => {
const existing = !!window.__bniInstalled;
const defaults = {
maxText: 2000,
includeHosts: [],
excludeHosts: [],
captureWebSocket: true,
};
const priorConfig = window.__bniConfig || {};
const config = { ...defaults, ...priorConfig };
const logs = window.__bniLogs || [];
const SENSITIVE_KEYS = new Set([
'authorization', 'cookie', 'set-cookie', 'password', 'token',
'accesstoken', 'refreshtoken', 'session', 'csrf', 'x-csrf-token'
]);
function truncate(value, max = config.maxText) {
if (value == null) return value;
const str = typeof value === 'string' ? value : JSON.stringify(value);
return str.length > max ? str.slice(0, max) + '…<truncated>' : str;
}
function redactValue(key, value) {
if (!key) return truncate(value);
const normalized = String(key).toLowerCase();
if (SENSITIVE_KEYS.has(normalized) || normalized.includes('token') || normalized.includes('cookie') || normalized.includes('pass') || normalized.includes('secret')) {
return '<REDACTED>';
}
return truncate(value);
}
function redactObject(input) {
if (!input || typeof input !== 'object') return input;
const out = Array.isArray(input) ? [] : {};
for (const [key, value] of Object.entries(input)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
out[key] = redactObject(value);
} else if (Array.isArray(value)) {
out[key] = value.map(v => (v && typeof v === 'object' ? redactObject(v) : redactValue(key, v)));
} else {
out[key] = redactValue(key, value);
}
}
return out;
}
function tryParseJson(text) {
if (!text || typeof text !== 'string') return null;
try {
return JSON.parse(text);
} catch {
return null;
}
}
function normalizeHeaders(headersLike) {
const out = {};
if (!headersLike) return out;
if (headersLike instanceof Headers) {
for (const [key, value] of headersLike.entries()) out[key] = redactValue(key, value);
return out;
}
if (Array.isArray(headersLike)) {
for (const pair of headersLike) {
if (Array.isArray(pair) && pair.length >= 2) out[pair[0]] = redactValue(pair[0], pair[1]);
}
return out;
}
if (typeof headersLike === 'object') {
for (const [key, value] of Object.entries(headersLike)) out[key] = redactValue(key, value);
}
return out;
}
function normalizeBody(body) {
if (body == null) return null;
if (typeof body === 'string') {
const parsed = tryParseJson(body);
return parsed ? redactObject(parsed) : truncate(body);
}
if (body instanceof URLSearchParams) {
const obj = {};
for (const [key, value] of body.entries()) obj[key] = redactValue(key, value);
return obj;
}
if (body instanceof FormData) {
const obj = {};
for (const [key, value] of body.entries()) {
obj[key] = value instanceof File ? `<File:value.name>` : redactValue(key, value);
}
return obj;
}
if (typeof body === 'object') return redactObject(body);
return truncate(String(body));
}
function hostOf(url) {
try {
return new URL(url, location.href).host;
} catch {
return '';
}
}
function matchesHostRule(host, rules) {
if (!rules || !rules.length) return false;
return rules.some(rule => host === rule || host.endsWith(`.rule`));
}
function shouldCapture(url) {
const host = hostOf(url);
if (!host) return true;
if (config.includeHosts?.length && !matchesHostRule(host, config.includeHosts)) return false;
if (config.excludeHosts?.length && matchesHostRule(host, config.excludeHosts)) return false;
return true;
}
function pushLog(entry) {
if (entry?.url && !shouldCapture(entry.url)) return;
logs.push({ ts: new Date().toISOString(), ...entry });
}
if (!existing) {
const originalFetch = window.fetch.bind(window);
window.fetch = async function bniFetch(input, init = {}) {
const startedAt = Date.now();
const url = typeof input === 'string' ? input : input?.url;
const method = init.method || (typeof input !== 'string' && input?.method) || 'GET';
const requestHeaders = normalizeHeaders(init.headers || (typeof input !== 'string' ? input?.headers : undefined));
const requestBody = normalizeBody(init.body);
try {
const response = await originalFetch(input, init);
const cloned = response.clone();
let responseText = null;
try { responseText = await cloned.text(); } catch {}
const parsed = tryParseJson(responseText);
pushLog({
source: 'fetch', url, method, durationMs: Date.now() - startedAt,
requestHeaders, requestBody, status: response.status, ok: response.ok,
responseHeaders: normalizeHeaders(response.headers),
responseBody: parsed ? redactObject(parsed) : truncate(responseText),
});
return response;
} catch (error) {
pushLog({
source: 'fetch', url, method, durationMs: Date.now() - startedAt,
requestHeaders, requestBody, error: String(error),
});
throw error;
}
};
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function bniOpen(method, url, ...rest) {
this.__bni = { method, url, headers: {}, startedAt: 0, body: null };
return originalOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.setRequestHeader = function bniSetRequestHeader(key, value) {
if (this.__bni) this.__bni.headers[key] = redactValue(key, value);
return originalSetRequestHeader.call(this, key, value);
};
XMLHttpRequest.prototype.send = function bniSend(body) {
if (this.__bni) {
this.__bni.startedAt = Date.now();
this.__bni.body = normalizeBody(body);
}
this.addEventListener('loadend', () => {
const meta = this.__bni || {};
const allHeaders = this.getAllResponseHeaders?.() || '';
const responseHeaders = {};
allHeaders.trim().split(/\r?\n/).forEach(line => {
if (!line) return;
const idx = line.indexOf(':');
if (idx === -1) return;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
responseHeaders[key] = redactValue(key, value);
});
let responseBody = null;
try {
if (typeof this.responseText === 'string') {
const parsed = tryParseJson(this.responseText);
responseBody = parsed ? redactObject(parsed) : truncate(this.responseText);
}
} catch {}
pushLog({
source: 'xhr', url: meta.url, method: meta.method || 'GET',
durationMs: meta.startedAt ? Date.now() - meta.startedAt : null,
requestHeaders: meta.headers || {}, requestBody: meta.body,
status: this.status, ok: this.status >= 200 && this.status < 300,
responseHeaders, responseBody,
});
}, { once: true });
return originalSend.call(this, body);
};
if (config.captureWebSocket && 'WebSocket' in window) {
const NativeWebSocket = window.WebSocket;
window.WebSocket = function BNIWebSocket(url, protocols) {
const startedAt = Date.now();
const ws = protocols ? new NativeWebSocket(url, protocols) : new NativeWebSocket(url);
pushLog({ source: 'websocket', phase: 'open-call', url, method: 'WS', durationMs: 0 });
ws.addEventListener('open', () => {
pushLog({ source: 'websocket', phase: 'open', url, method: 'WS', durationMs: Date.now() - startedAt, ok: true });
});
ws.addEventListener('message', event => {
pushLog({ source: 'websocket', phase: 'message-in', url, method: 'WS', message: truncate(event.data) });
});
ws.addEventListener('close', event => {
pushLog({ source: 'websocket', phase: 'close', url, method: 'WS', code: event.code, reason: truncate(event.reason), ok: event.wasClean });
});
ws.addEventListener('error', () => {
pushLog({ source: 'websocket', phase: 'error', url, method: 'WS', error: 'WebSocket error' });
});
const nativeSend = ws.send.bind(ws);
ws.send = function bniSend(data) {
pushLog({ source: 'websocket', phase: 'message-out', url, method: 'WS', message: truncate(data) });
return nativeSend(data);
};
return ws;
};
window.WebSocket.prototype = NativeWebSocket.prototype;
}
}
window.__bniLogs = logs;
window.__bniConfig = config;
window.__bniSetConfig = (patch = {}) => Object.assign(window.__bniConfig, patch || {});
window.__bniExport = () => JSON.parse(JSON.stringify(logs));
window.__bniClear = () => { logs.length = 0; return true; };
window.__bniInstalled = true;
return existing ? 'browser-network-inspector reconfigured' : 'browser-network-inspector installed';
})();
FILE:scripts/summarize-network.js
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
function usage() {
console.error('Usage: node summarize-network.js <input.json> [output.md]');
process.exit(1);
}
const inputPath = process.argv[2];
const outputPath = process.argv[3];
if (!inputPath) usage();
const raw = fs.readFileSync(inputPath, 'utf8');
const logs = JSON.parse(raw);
if (!Array.isArray(logs)) throw new Error('Input JSON must be an array');
const total = logs.length;
const failures = logs.filter(x => x.error || (typeof x.status === 'number' && x.status >= 400));
const byMethod = new Map();
const byStatus = new Map();
const byHost = new Map();
const bySource = new Map();
function inc(map, key) { map.set(key, (map.get(key) || 0) + 1); }
function hostOf(url) { try { return new URL(url).host; } catch { return '(invalid-url)'; } }
function topEntries(map, limit = 10) { return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit); }
function short(v, max = 160) { if (v == null) return ''; const s = typeof v === 'string' ? v : JSON.stringify(v); return s.length > max ? s.slice(0, max) + '…' : s; }
for (const item of logs) {
inc(byMethod, item.method || 'UNKNOWN');
inc(bySource, item.source || 'unknown');
if (typeof item.status === 'number') inc(byStatus, String(item.status));
inc(byHost, hostOf(item.url || ''));
}
const lines = [];
lines.push('# Browser Network Inspector Report');
lines.push('');
lines.push(`- Total captured events: total`);
lines.push(`- Failed/error events: failures.length`);
lines.push('');
lines.push('## Sources');
for (const [key, value] of topEntries(bySource)) lines.push(`- key: value`);
lines.push('');
lines.push('## Methods');
for (const [key, value] of topEntries(byMethod)) lines.push(`- key: value`);
lines.push('');
lines.push('## Status codes');
for (const [key, value] of topEntries(byStatus)) lines.push(`- key: value`);
lines.push('');
lines.push('## Hosts');
for (const [key, value] of topEntries(byHost)) lines.push(`- key: value`);
lines.push('');
const keyFlows = logs.filter(x => ['fetch','xhr'].includes(x.source)).slice(-20);
lines.push('## Recent HTTP requests');
for (const item of keyFlows) {
const status = item.error ? `ERROR item.error` : (item.status ?? 'N/A');
lines.push(`- [item.source] item.method || 'GET' item.url || '' -> status (item.durationMs ?? 'n/a' ms)`);
}
lines.push('');
const wsEvents = logs.filter(x => x.source === 'websocket').slice(-20);
if (wsEvents.length) {
lines.push('## Recent WebSocket events');
for (const item of wsEvents) {
lines.push(`- [item.phase || 'event'] item.url || '' item.code ? `code=${item.code` : ''} item.message ? `msg=${short(item.message)` : ''}`.trim());
}
lines.push('');
}
if (failures.length) {
lines.push('## Failures');
for (const item of failures.slice(0, 20)) {
lines.push(`- item.method || 'GET' item.url || ''`);
if (item.source) lines.push(` - Source: item.source`);
if (item.phase) lines.push(` - Phase: item.phase`);
if (item.error) lines.push(` - Error: short(item.error)`);
if (item.status) lines.push(` - Status: item.status`);
if (item.requestBody != null) lines.push(` - Request body: short(item.requestBody)`);
if (item.responseBody != null) lines.push(` - Response body: short(item.responseBody)`);
}
lines.push('');
}
const markdown = lines.join('\n');
if (outputPath) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, markdown, 'utf8');
} else {
process.stdout.write(markdown + '\n');
}
Send native Windows desktop notifications for local reminders, alerts, and background-attention events. Use when the user wants a Windows popup, a local toas...
---
name: windows-notifier
description: Send native Windows desktop notifications for local reminders, alerts, and background-attention events. Use when the user wants a Windows popup, a local toast notification, or when reminder/alert messages should prefer local desktop notification instead of only chat delivery.
---
# Windows Notifier
Send a local **Windows desktop notification** on this machine.
This skill is a Windows-focused alias/wrapper around the shared desktop notification flow so agents can trigger a popup consistently when the user asks for a Windows reminder or when a reminder/alert should not rely only on chat visibility.
## Use this skill for
- Local reminder popups
- Timer / study / schedule alerts
- Attention-needed notifications when chat may be in the background
- Windows-specific notification tests
## Command
Run this from PowerShell with `exec`:
```powershell
node "$env:USERPROFILE\.openclaw\workspace\skills\windows-notifier\scripts\send-notification.js" --title "<TITLE>" --message "<MESSAGE>" --timeout 10
```
Modern card / persistent dialog mode also goes through the same JS entry:
```powershell
node "$env:USERPROFILE\.openclaw\workspace\skills\windows-notifier\scripts\send-notification.js" --title "<TITLE>" --message "<MESSAGE>" --mode modern --appName "OpenClaw"
```
Behavior summary:
- **Windows + WPF available** → prefer the built-in WPF modern card path in `send-notification.js`
- **Windows fallback** → if WPF is unavailable or launch fails, automatically fall back to `node-notifier`
- **Linux / macOS** → use the existing `node-notifier` path
- Current modern card behavior: auto-close after about 60 seconds; click the card itself to dismiss
Optional flags:
- `--wait true|false`
- `--timeout <seconds|false|permanent>`
- `--sound true|false` (default: `true`)
- `--mode modern|card|dialog`
- `--appName <name>`
## Notes
- Keep the title short and the message concise.
- On Windows, `--mode modern|card|dialog` prefers the built-in WPF modern card path when available, and otherwise falls back to `node-notifier` automatically.
- `--timeout false|permanent|sticky|0` is treated as a persistent request intent, but final behavior depends on the active backend (built-in WPF on capable Windows, otherwise `node-notifier`).
- Prefer this over chat-only reminders when the request is for a local popup.
- If a reminder or alert may be missed because OpenClaw is running in the background, prefer triggering this notifier in addition to or instead of chat delivery, depending on user intent.
- `node-notifier` remains the default non-Windows path and the cross-platform fallback mechanism.
- On first run after install, the script auto-installs dependencies in this skill directory if needed, so users do not need to run npm manually.
FILE:package-lock.json
{
"name": "windows-notifier-skill",
"version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windows-notifier-skill",
"version": "1.0.3",
"license": "UNLICENSED",
"dependencies": {
"node-notifier": "^10.0.1"
}
},
"node_modules/growly": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
"integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==",
"license": "MIT"
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/node-notifier": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz",
"integrity": "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==",
"license": "MIT",
"dependencies": {
"growly": "^1.3.0",
"is-wsl": "^2.2.0",
"semver": "^7.3.5",
"shellwords": "^0.1.1",
"uuid": "^8.3.2",
"which": "^2.0.2"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shellwords": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
"integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
}
}
}
FILE:package.json
{
"name": "windows-notifier-skill",
"private": true,
"version": "1.0.3",
"description": "Windows notifier wrapper for OpenClaw workspace skill",
"license": "UNLICENSED",
"dependencies": {
"node-notifier": "^10.0.1"
}
}
FILE:scripts/send-notification.js
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith('--')) continue;
const key = token.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
args[key] = true;
continue;
}
args[key] = next;
i += 1;
}
return args;
}
function ensureNodeNotifierInstalled(skillDir) {
const modulePath = path.join(skillDir, 'node_modules', 'node-notifier');
if (fs.existsSync(modulePath)) return;
const result = process.platform === 'win32'
? spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'npm.cmd install --no-fund --no-audit'], {
cwd: skillDir,
stdio: 'inherit',
windowsHide: true,
})
: spawnSync('npm', ['install', '--no-fund', '--no-audit'], {
cwd: skillDir,
stdio: 'inherit',
});
if (result.error) {
throw new Error(`npm install failed: result.error.message || String(result.error)`);
}
if (result.status !== 0) {
throw new Error(`npm install failed with exit code result.status ?? 'unknown'`);
}
}
function escapePsSingleQuoted(value) {
return String(value).replace(/'/g, "''");
}
function canUseWpfWindowsDialog() {
if (process.platform !== 'win32') return false;
const command = 'powershell.exe';
const probeScript = [
"$ErrorActionPreference = 'Stop'",
'Add-Type -AssemblyName PresentationCore',
'Add-Type -AssemblyName PresentationFramework',
'Add-Type -AssemblyName WindowsBase',
"Write-Output 'WPF_OK'",
].join('; ');
const result = spawnSync(command, [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-STA',
'-Command', probeScript,
], {
windowsHide: true,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return result.status === 0 && String(result.stdout || '').includes('WPF_OK');
}
function buildModernDialogPowerShell(title, message, appName, sound = true) {
return String.raw`
param()
$Title = 'escapePsSingleQuoted(title)'
$Message = 'escapePsSingleQuoted(message)'
$AppName = 'escapePsSingleQuoted(appName)'
$PlaySound = '$false'
$DurationSeconds = 60
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName PresentationFramework
Add-Type -AssemblyName WindowsBase
if ($PlaySound) {
try {
[System.Media.SystemSounds]::Exclamation.Play()
} catch {
try {
[console]::Beep(880, 180)
} catch {
# ignore sound failures
}
}
}
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class Win32WindowStyles
{
public const int GWL_EXSTYLE = -20;
public const int WS_EX_TOOLWINDOW = 0x00000080;
public const int WS_EX_APPWINDOW = 0x00040000;
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)]
public static extern IntPtr GetWindowLongPtr64(IntPtr hWnd, int nIndex);
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)]
public static extern IntPtr SetWindowLongPtr64(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
[DllImport("user32.dll", EntryPoint = "GetWindowLongW", SetLastError = true)]
public static extern int GetWindowLong32(IntPtr hWnd, int nIndex);
[DllImport("user32.dll", EntryPoint = "SetWindowLongW", SetLastError = true)]
public static extern int SetWindowLong32(IntPtr hWnd, int nIndex, int dwNewLong);
public static IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex)
{
return IntPtr.Size == 8 ? GetWindowLongPtr64(hWnd, nIndex) : new IntPtr(GetWindowLong32(hWnd, nIndex));
}
public static IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong)
{
return IntPtr.Size == 8 ? SetWindowLongPtr64(hWnd, nIndex, dwNewLong) : new IntPtr(SetWindowLong32(hWnd, nIndex, dwNewLong.ToInt32()));
}
}
"@
function New-Brush([string]$Color) {
return ([System.Windows.Media.BrushConverter]::new()).ConvertFromString($Color)
}
$bg = New-Brush '#FF1C1C1C'
$border = New-Brush '#FF525252'
$headerText = New-Brush '#F3F3F3'
$bodyText = New-Brush '#EAEAEA'
$avatarBg = New-Brush '#FF3E332C'
$avatarBorder = New-Brush '#D8C8A06A'
$avatarText = New-Brush '#F5E8D2'
if ($Message.Length -gt 30) {
$Message = $Message.Substring(0, 30) + '...'
}
$window = New-Object System.Windows.Window
$window.Width = 360
$window.Height = 86
$window.WindowStartupLocation = [System.Windows.WindowStartupLocation]::Manual
$window.ResizeMode = [System.Windows.ResizeMode]::NoResize
$window.Background = [System.Windows.Media.Brushes]::Transparent
$window.WindowStyle = [System.Windows.WindowStyle]::None
$window.ShowInTaskbar = $false
$window.Topmost = $true
$window.AllowsTransparency = $true
$window.ShowActivated = $true
$window.Focusable = $true
$window.Title = $Title
$outerBorder = New-Object System.Windows.Controls.Border
$outerBorder.Width = 360
$outerBorder.Height = 86
$outerBorder.Background = $bg
$outerBorder.BorderBrush = $border
$outerBorder.BorderThickness = [System.Windows.Thickness]::new(1.2)
$outerBorder.CornerRadius = [System.Windows.CornerRadius]::new(10)
$outerBorder.SnapsToDevicePixels = $true
$outerBorder.Opacity = 0.95
$shadow = New-Object System.Windows.Media.Effects.DropShadowEffect
$shadow.Color = [System.Windows.Media.Color]::FromArgb(255, 0, 0, 0)
$shadow.BlurRadius = 24
$shadow.ShadowDepth = 6
$shadow.Opacity = 0.24
$outerBorder.Effect = $shadow
$canvas = New-Object System.Windows.Controls.Canvas
$canvas.Width = 360
$canvas.Height = 86
$appNameBlock = New-Object System.Windows.Controls.TextBlock
$appNameBlock.Text = $AppName
$appNameBlock.FontSize = 10
$appNameBlock.Foreground = $headerText
[System.Windows.Controls.Canvas]::SetLeft($appNameBlock, 10)
[System.Windows.Controls.Canvas]::SetTop($appNameBlock, 6)
$canvas.Children.Add($appNameBlock) | Out-Null
$avatarBox = New-Object System.Windows.Controls.Border
$avatarBox.Width = 35
$avatarBox.Height = 35
$avatarBox.CornerRadius = [System.Windows.CornerRadius]::new(7)
$avatarBox.Background = $avatarBg
$avatarBox.BorderBrush = $avatarBorder
$avatarBox.BorderThickness = [System.Windows.Thickness]::new(1)
$avatarTextBlock = New-Object System.Windows.Controls.TextBlock
$avatarTextBlock.Text = 'Zz'
$avatarTextBlock.FontSize = 15
$avatarTextBlock.FontWeight = [System.Windows.FontWeights]::SemiBold
$avatarTextBlock.Foreground = $avatarText
$avatarTextBlock.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center
$avatarTextBlock.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
$avatarBox.Child = $avatarTextBlock
[System.Windows.Controls.Canvas]::SetLeft($avatarBox, 10)
[System.Windows.Controls.Canvas]::SetTop($avatarBox, 34)
$canvas.Children.Add($avatarBox) | Out-Null
$textLeft = 52
$titleBlock = New-Object System.Windows.Controls.TextBlock
$titleBlock.Text = $Title
$titleBlock.FontSize = 13
$titleBlock.FontWeight = [System.Windows.FontWeights]::SemiBold
$titleBlock.Foreground = $headerText
[System.Windows.Controls.Canvas]::SetLeft($titleBlock, $textLeft)
[System.Windows.Controls.Canvas]::SetTop($titleBlock, 35)
$canvas.Children.Add($titleBlock) | Out-Null
$messageBlock = New-Object System.Windows.Controls.TextBlock
$messageBlock.Text = $Message
$messageBlock.TextWrapping = [System.Windows.TextWrapping]::NoWrap
$messageBlock.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis
$messageBlock.FontSize = 10.5
$messageBlock.Foreground = $bodyText
$messageBlock.Width = 250
[System.Windows.Controls.Canvas]::SetLeft($messageBlock, $textLeft)
[System.Windows.Controls.Canvas]::SetTop($messageBlock, 57)
$canvas.Children.Add($messageBlock) | Out-Null
$outerBorder.Child = $canvas
$window.Content = $outerBorder
$window.Add_SourceInitialized({
$helper = New-Object System.Windows.Interop.WindowInteropHelper($window)
$hwnd = $helper.Handle
if ($hwnd -ne [IntPtr]::Zero) {
$exStyle = [Win32WindowStyles]::GetWindowLongPtr($hwnd, [Win32WindowStyles]::GWL_EXSTYLE).ToInt64()
$newStyle = ($exStyle -bor [Win32WindowStyles]::WS_EX_TOOLWINDOW) -band (-bnot [Win32WindowStyles]::WS_EX_APPWINDOW)
[void][Win32WindowStyles]::SetWindowLongPtr($hwnd, [Win32WindowStyles]::GWL_EXSTYLE, [IntPtr]$newStyle)
}
})
$outerBorder.Cursor = [System.Windows.Input.Cursors]::Hand
$outerBorder.Add_MouseLeftButtonUp({
$window.Close()
})
$window.Add_KeyDown({
param($sender, $eventArgs)
if ($eventArgs.Key -eq [System.Windows.Input.Key]::Escape -or $eventArgs.Key -eq [System.Windows.Input.Key]::Enter) {
$window.Close()
}
})
$window.Add_Loaded({
$window.UpdateLayout()
$workArea = [System.Windows.SystemParameters]::WorkArea
$marginRight = 16
$marginTop = 12
$window.Left = $workArea.Right - $window.ActualWidth - $marginRight
$window.Top = $workArea.Top + $marginTop
})
$window.Add_Closed({
[System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvokeShutdown([System.Windows.Threading.DispatcherPriority]::Background)
})
if ($DurationSeconds -gt 0) {
$timer = New-Object System.Windows.Threading.DispatcherTimer
$timer.Interval = [TimeSpan]::FromSeconds($DurationSeconds)
$timer.Add_Tick({
$timer.Stop()
$window.Close()
})
$timer.Start()
}
$window.ShowDialog() | Out-Null
`;
}
function showModernWindowsDialog(title, message, appName = 'OpenClaw', wait = false, sound = true) {
if (process.platform !== 'win32') {
throw new Error('modern dialog is only supported on Windows');
}
if (!canUseWpfWindowsDialog()) {
throw new Error('WPF is unavailable in the current Windows session');
}
const command = 'powershell.exe';
const script = buildModernDialogPowerShell(title, message, appName, sound);
const encodedScript = Buffer.from(script, 'utf16le').toString('base64');
const psArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-EncodedCommand', encodedScript];
if (wait) {
const result = spawnSync(command, psArgs, {
windowsHide: false,
stdio: 'ignore',
});
if (result.status !== 0) {
throw new Error(`modern dialog failed with exit code result.status ?? 'unknown'`);
}
return;
}
const launcherScript = `Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-EncodedCommand', 'encodedScript') -WindowStyle Hidden`;
const result = spawnSync(command, [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-Command', launcherScript,
], {
windowsHide: true,
stdio: 'ignore',
});
if (result.status !== 0) {
throw new Error(`modern dialog launcher failed with exit code result.status ?? 'unknown'`);
}
}
function showNodeNotifier(title, message, appName, wait, timeout, sound, skillDir) {
ensureNodeNotifierInstalled(skillDir);
const notifier = require(path.join(skillDir, 'node_modules', 'node-notifier'));
return new Promise((resolve, reject) => {
notifier.notify({
title,
message,
wait,
timeout,
appID: appName,
appName,
sound,
}, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
(async () => {
try {
const skillDir = path.resolve(__dirname, '..');
const args = parseArgs(process.argv);
const title = args.title || 'OpenClaw 提醒';
const message = args.message || '你有一条新的提醒。';
const appName = args.appName || args.app || 'OpenClaw';
const timeoutArg = String(args.timeout ?? 'false').toLowerCase();
const timeout = (timeoutArg === 'false' || timeoutArg === 'permanent' || timeoutArg === 'sticky' || timeoutArg === '0')
? false
: Number(timeoutArg);
const wait = String(args.wait || 'false').toLowerCase() === 'true';
const soundArg = String(args.sound || 'true').toLowerCase();
const sound = !(soundArg === 'false' || soundArg === '0' || soundArg === 'off');
const modeArg = String(args.mode || '').toLowerCase();
const wantsModern = modeArg === 'modern' || modeArg === 'card' || modeArg === 'dialog';
if (process.platform === 'win32' && wantsModern) {
try {
showModernWindowsDialog(title, message, appName, wait, sound);
process.exit(0);
return;
} catch {
// Fall through to node-notifier fallback.
}
}
await showNodeNotifier(title, message, appName, wait, timeout, sound, skillDir);
process.exit(0);
} catch (error) {
console.error('WINDOWS_NOTIFY_ERROR');
console.error(error?.message || String(error));
process.exit(1);
}
})();
FILE:_meta.json
{
"ownerId": "kn7f0kjm8pc2n4g1hekgg8cjjx83479b",
"slug": "windows-notifier",
"version": "1.1.0",
"publishedAt": 1774413247485
}