diff --git a/memory/2026-03-12.md b/memory/2026-03-12.md index 0288ff8..8394dec 100644 --- a/memory/2026-03-12.md +++ b/memory/2026-03-12.md @@ -9,4 +9,5 @@ - `openclaw-ping` webhook path tested end-to-end - Operating note: prefer narrow webhook-first integration rather than broad n8n admin/API access. - Will clarified the primary host LAN IP to use/document is `192.168.153.113`. -- Drafted local skill `skills/n8n-webhook` for authenticated webhook-first n8n integration, including `scripts/call-webhook.sh`, `scripts/call-action.sh`, payload notes, and a successful package/validation run to `/tmp/n8n-skill-dist/n8n-webhook.skill`. +- Finished local skill `skills/n8n-webhook` for authenticated webhook-first n8n integration, including `scripts/call-webhook.sh`, `scripts/call-action.sh`, `scripts/validate-workflow.py`, an importable `assets/openclaw-action.workflow.json`, sample payloads, payload notes, and a successful package/validation run to `/tmp/n8n-skill-dist/n8n-webhook.skill`. +- The shipped `openclaw-action` workflow intentionally leaves Webhook authentication unset in export JSON; after import, bind local n8n Header Auth credentials manually using `x-openclaw-secret` so secrets are not embedded in the skill asset. diff --git a/skills/n8n-webhook/SKILL.md b/skills/n8n-webhook/SKILL.md index ba4195b..64fd6e6 100644 --- a/skills/n8n-webhook/SKILL.md +++ b/skills/n8n-webhook/SKILL.md @@ -20,7 +20,18 @@ Keep the integration narrow: let OpenClaw decide what to do, and let n8n execute 4. Use header auth by default (`x-openclaw-secret`). 5. Use `/webhook-test/` only while building/editing a workflow. 6. Surface non-2xx responses clearly instead of pretending success. -7. If a new workflow is needed, define its request/response contract before wiring callers. +7. Keep secrets in n8n credentials or local env vars, never inside shareable workflow JSON. +8. If a new workflow is needed, define its request/response contract before wiring callers. + +## What ships with this skill + +- direct webhook caller: `scripts/call-webhook.sh` +- action-bus caller: `scripts/call-action.sh` +- workflow validator: `scripts/validate-workflow.py` +- importable router workflow: `assets/openclaw-action.workflow.json` +- sample payloads: + - `assets/test-append-log.json` + - `assets/test-notify.json` ## Quick usage @@ -39,7 +50,7 @@ scripts/call-webhook.sh openclaw-ping --data '{"message":"hello from OpenClaw"}' Call the preferred action-bus route: ```bash -scripts/call-action.sh append_log --args '{"text":"backup complete"}' +scripts/call-action.sh append_log --args '{"text":"backup complete"}' --request-id auto ``` Call a test webhook while editing a flow: @@ -48,6 +59,12 @@ Call a test webhook while editing a flow: scripts/call-action.sh notify --args '{"message":"hello from OpenClaw"}' --test --pretty ``` +Validate the shipped workflow asset: + +```bash +python3 scripts/validate-workflow.py assets/openclaw-action.workflow.json +``` + ## Workflow ### Call an existing safe webhook directly @@ -76,12 +93,28 @@ Payload shape: This keeps the external surface small while letting n8n route internally. +### Import the shipped router workflow + +Use the included workflow asset when you want a ready-made starter router for: + +- `append_log` +- `notify` +- normalized JSON success/failure responses +- unknown-action handling + +Important: +- the workflow export intentionally leaves Webhook authentication unset +- after import, manually set **Authentication = Header Auth** on the Webhook node and bind a local credential using `x-openclaw-secret` + +See `references/openclaw-action.md` for import and test steps. + ### Add a new webhook-backed capability 1. Write down the webhook path, required auth, request JSON, and response JSON. 2. If the path should become part of the shared action bus, document the `action` name and `args` shape in `references/payloads.md`. -3. Keep the first version small and explicit. -4. Only add the new endpoint to regular use after a successful `/webhook-test/` run. +3. If the shipped workflow should support it, update `assets/openclaw-action.workflow.json` and rerun `scripts/validate-workflow.py`. +4. Keep the first version small and explicit. +5. Only add the new endpoint to regular use after a successful `/webhook-test/` run. ## Environment variables @@ -94,4 +127,7 @@ This keeps the external surface small while letting n8n route internally. - `scripts/call-webhook.sh` — authenticated POST helper for direct local n8n webhooks - `scripts/call-action.sh` — wrapper for action-bus style calls against `openclaw-action` +- `scripts/validate-workflow.py` — local structural validator for the shipped workflow asset +- `assets/openclaw-action.workflow.json` — importable starter workflow for the action bus +- `references/openclaw-action.md` — import, auth-binding, and testing guide - `references/payloads.md` — request/response contracts and naming conventions diff --git a/skills/n8n-webhook/assets/openclaw-action.workflow.json b/skills/n8n-webhook/assets/openclaw-action.workflow.json new file mode 100644 index 0000000..54b4006 --- /dev/null +++ b/skills/n8n-webhook/assets/openclaw-action.workflow.json @@ -0,0 +1,369 @@ +{ + "name": "openclaw-action", + "nodes": [ + { + "id": "webhook-openclaw-action", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -820, + 0 + ], + "parameters": { + "httpMethod": "POST", + "path": "openclaw-action", + "authentication": "none", + "responseMode": "responseNode", + "options": {} + } + }, + { + "id": "normalize-request", + "name": "normalize-request", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + -560, + 0 + ], + "parameters": { + "mode": "manual", + "includeOtherFields": false, + "assignments": { + "assignments": [ + { + "id": "action", + "name": "action", + "type": "string", + "value": "={{$json.body.action || ''}}" + }, + { + "id": "args", + "name": "args", + "type": "object", + "value": "={{$json.body.args || {}}}" + }, + { + "id": "request_id", + "name": "request_id", + "type": "string", + "value": "={{$json.body.request_id || ''}}" + } + ] + }, + "options": { + "dotNotation": false + } + } + }, + { + "id": "route-action", + "name": "route-action", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + -300, + 0 + ], + "parameters": { + "mode": "rules", + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "leftValue": "={{$json.action}}", + "rightValue": "append_log", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "append_log" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "leftValue": "={{$json.action}}", + "rightValue": "notify", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "notify" + } + ] + }, + "options": { + "fallbackOutput": "extra", + "renameFallbackOutput": "unknown" + } + } + }, + { + "id": "append-log-response", + "name": "append-log-response", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + -20, + -180 + ], + "parameters": { + "mode": "manual", + "includeOtherFields": false, + "assignments": { + "assignments": [ + { + "id": "status_code", + "name": "status_code", + "type": "number", + "value": "={{$json.args.text ? 200 : 400}}" + }, + { + "id": "response_body", + "name": "response_body", + "type": "object", + "value": "={{ $json.args.text ? { ok: true, request_id: $json.request_id || '', result: { action: 'append_log', status: 'accepted', preview: { text: $json.args.text } } } : { ok: false, request_id: $json.request_id || '', error: { code: 'invalid_request', message: 'required args are missing' } } }}" + } + ] + }, + "options": { + "dotNotation": false + } + } + }, + { + "id": "respond-append-log", + "name": "respond-append-log", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 240, + -180 + ], + "parameters": { + "respondWith": "json", + "responseBody": "={{$json.response_body}}", + "options": { + "responseCode": "={{$json.status_code}}" + } + } + }, + { + "id": "notify-response", + "name": "notify-response", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + -20, + 0 + ], + "parameters": { + "mode": "manual", + "includeOtherFields": false, + "assignments": { + "assignments": [ + { + "id": "status_code", + "name": "status_code", + "type": "number", + "value": "={{$json.args.message ? 200 : 400}}" + }, + { + "id": "response_body", + "name": "response_body", + "type": "object", + "value": "={{ $json.args.message ? { ok: true, request_id: $json.request_id || '', result: { action: 'notify', status: 'accepted', preview: { title: $json.args.title || '', message: $json.args.message } } } : { ok: false, request_id: $json.request_id || '', error: { code: 'invalid_request', message: 'required args are missing' } } }}" + } + ] + }, + "options": { + "dotNotation": false + } + } + }, + { + "id": "respond-notify", + "name": "respond-notify", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 240, + 0 + ], + "parameters": { + "respondWith": "json", + "responseBody": "={{$json.response_body}}", + "options": { + "responseCode": "={{$json.status_code}}" + } + } + }, + { + "id": "unknown-action-response", + "name": "unknown-action-response", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + -20, + 180 + ], + "parameters": { + "mode": "manual", + "includeOtherFields": false, + "assignments": { + "assignments": [ + { + "id": "status_code", + "name": "status_code", + "type": "number", + "value": 400 + }, + { + "id": "response_body", + "name": "response_body", + "type": "object", + "value": "={{ { ok: false, request_id: $json.request_id || '', error: { code: 'unknown_action', message: 'action is not supported' } } }}" + } + ] + }, + "options": { + "dotNotation": false + } + } + }, + { + "id": "respond-unknown-action", + "name": "respond-unknown-action", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 240, + 180 + ], + "parameters": { + "respondWith": "json", + "responseBody": "={{$json.response_body}}", + "options": { + "responseCode": "={{$json.status_code}}" + } + } + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "normalize-request", + "type": "main", + "index": 0 + } + ] + ] + }, + "normalize-request": { + "main": [ + [ + { + "node": "route-action", + "type": "main", + "index": 0 + } + ] + ] + }, + "route-action": { + "main": [ + [ + { + "node": "append-log-response", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "notify-response", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "unknown-action-response", + "type": "main", + "index": 0 + } + ] + ] + }, + "append-log-response": { + "main": [ + [ + { + "node": "respond-append-log", + "type": "main", + "index": 0 + } + ] + ] + }, + "notify-response": { + "main": [ + [ + { + "node": "respond-notify", + "type": "main", + "index": 0 + } + ] + ] + }, + "unknown-action-response": { + "main": [ + [ + { + "node": "respond-unknown-action", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {}, + "settings": { + "executionOrder": "v1" + }, + "staticData": null, + "meta": { + "templateCredsSetupCompleted": false, + "note": "After import, set Webhook authentication to Header Auth and bind a local credential using x-openclaw-secret. Secrets are intentionally not embedded in the workflow export." + }, + "active": false, + "versionId": "openclaw-action-v1" +} diff --git a/skills/n8n-webhook/assets/test-append-log.json b/skills/n8n-webhook/assets/test-append-log.json new file mode 100644 index 0000000..23213b8 --- /dev/null +++ b/skills/n8n-webhook/assets/test-append-log.json @@ -0,0 +1,7 @@ +{ + "action": "append_log", + "args": { + "text": "backup complete" + }, + "request_id": "test-append-log-001" +} diff --git a/skills/n8n-webhook/assets/test-notify.json b/skills/n8n-webhook/assets/test-notify.json new file mode 100644 index 0000000..838050e --- /dev/null +++ b/skills/n8n-webhook/assets/test-notify.json @@ -0,0 +1,8 @@ +{ + "action": "notify", + "args": { + "title": "Workflow finished", + "message": "n8n router test" + }, + "request_id": "test-notify-001" +} diff --git a/skills/n8n-webhook/references/openclaw-action.md b/skills/n8n-webhook/references/openclaw-action.md new file mode 100644 index 0000000..8b12722 --- /dev/null +++ b/skills/n8n-webhook/references/openclaw-action.md @@ -0,0 +1,148 @@ +# openclaw-action workflow + +This skill ships an importable draft workflow at: + +- `assets/openclaw-action.workflow.json` + +It implements the first safe router contract for local OpenClaw → n8n calls. + +## What it does + +- accepts `POST /webhook/openclaw-action` +- normalizes incoming JSON into: + - `action` + - `args` + - `request_id` +- routes two known actions: + - `append_log` + - `notify` +- returns normalized JSON responses +- returns `400` for unknown actions +- returns `400` when required branch args are missing + +## Intentional security choice + +The exported workflow leaves the Webhook node auth unset in the JSON file. + +Why: +- n8n credentials are instance-local +- secrets should not be embedded in a shareable skill asset + +After import, set this manually in n8n: + +- Webhook node → **Authentication** → `Header Auth` +- bind a credential with: + - header name: `x-openclaw-secret` + - header value: your generated shared secret + +## Import steps + +1. In n8n, create or open a workflow. +2. Import `assets/openclaw-action.workflow.json`. +3. Open the **Webhook** node. +4. Set **Authentication** to `Header Auth`. +5. Bind your local credential. +6. Save. +7. Use **Listen for test event** and call the test URL first. +8. Once successful, activate the workflow for production URL use. + +## Expected URLs + +Assuming the current local service address: + +- test: `http://192.168.153.113:18808/webhook-test/openclaw-action` +- prod: `http://192.168.153.113:18808/webhook/openclaw-action` + +## Test payloads included + +- `assets/test-append-log.json` +- `assets/test-notify.json` + +## Example tests + +Direct curl: + +```bash +curl -i -X POST 'http://192.168.153.113:18808/webhook-test/openclaw-action' \ + -H 'Content-Type: application/json' \ + -H 'x-openclaw-secret: YOUR_SECRET_HERE' \ + --data @assets/test-append-log.json +``` + +Via skill helper: + +```bash +export N8N_WEBHOOK_SECRET='YOUR_SECRET_HERE' +scripts/call-action.sh append_log --args '{"text":"backup complete"}' --test --pretty +``` + +## Expected success examples + +### append_log + +```json +{ + "ok": true, + "request_id": "test-append-log-001", + "result": { + "action": "append_log", + "status": "accepted", + "preview": { + "text": "backup complete" + } + } +} +``` + +### notify + +```json +{ + "ok": true, + "request_id": "test-notify-001", + "result": { + "action": "notify", + "status": "accepted", + "preview": { + "title": "Workflow finished", + "message": "n8n router test" + } + } +} +``` + +## Expected failure examples + +### unknown action + +```json +{ + "ok": false, + "request_id": "", + "error": { + "code": "unknown_action", + "message": "action is not supported" + } +} +``` + +### missing required args + +```json +{ + "ok": false, + "request_id": "", + "error": { + "code": "invalid_request", + "message": "required args are missing" + } +} +``` + +## Validation + +Run the local validator before import/package changes: + +```bash +python3 scripts/validate-workflow.py assets/openclaw-action.workflow.json +``` diff --git a/skills/n8n-webhook/references/payloads.md b/skills/n8n-webhook/references/payloads.md index 8cd47ca..57d04c2 100644 --- a/skills/n8n-webhook/references/payloads.md +++ b/skills/n8n-webhook/references/payloads.md @@ -33,6 +33,9 @@ Purpose: - keep the external n8n surface small - route several agent-safe operations behind one authenticated webhook +Shipped workflow asset: +- `assets/openclaw-action.workflow.json` + Recommended request shape: ```json @@ -87,6 +90,9 @@ Request: Purpose: - append a short line to a known log or tracking sink +Sample payload file: +- `assets/test-append-log.json` + ### `notify` Request: @@ -104,6 +110,9 @@ Request: Purpose: - send a small notification through a known downstream channel +Sample payload file: +- `assets/test-notify.json` + ## Naming guidance - Use lowercase kebab-case for webhook paths. diff --git a/skills/n8n-webhook/scripts/call-action.sh b/skills/n8n-webhook/scripts/call-action.sh index e63d66f..10531b1 100755 --- a/skills/n8n-webhook/scripts/call-action.sh +++ b/skills/n8n-webhook/scripts/call-action.sh @@ -4,7 +4,7 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - scripts/call-action.sh [--args '{"k":"v"}'] [--args-file args.json] [--request-id ] [--path openclaw-action] [--test] [--pretty] [--dry-run] + scripts/call-action.sh [--args '{"k":"v"}'] [--args-file args.json] [--request-id ] [--path openclaw-action] [--test] [--pretty] [--dry-run] Environment: N8N_ACTION_PATH Default router webhook path (default: openclaw-action) @@ -13,7 +13,7 @@ Environment: N8N_SECRET_HEADER Header name (default: x-openclaw-secret) Examples: - scripts/call-action.sh append_log --args '{"text":"backup complete"}' + scripts/call-action.sh append_log --args '{"text":"backup complete"}' --request-id auto scripts/call-action.sh notify --args-file notify.json --test --pretty EOF } @@ -98,6 +98,14 @@ if [[ -n "$ARGS_FILE" ]]; then ARGS="$(cat "$ARGS_FILE")" fi +if [[ "$REQUEST_ID" == "auto" ]]; then + REQUEST_ID="$(python3 - <<'PY' +import uuid +print(uuid.uuid4()) +PY +)" +fi + PAYLOAD="$({ python3 - <<'PY' "$ACTION" "$ARGS" "$REQUEST_ID" import json, sys diff --git a/skills/n8n-webhook/scripts/validate-workflow.py b/skills/n8n-webhook/scripts/validate-workflow.py new file mode 100755 index 0000000..8501c45 --- /dev/null +++ b/skills/n8n-webhook/scripts/validate-workflow.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +import json +import sys +from pathlib import Path + +REQUIRED_NODE_NAMES = { + 'Webhook', + 'normalize-request', + 'route-action', + 'append-log-response', + 'respond-append-log', + 'notify-response', + 'respond-notify', + 'unknown-action-response', + 'respond-unknown-action', +} + +EXPECTED_DIRECT_TYPES = { + 'Webhook': 'n8n-nodes-base.webhook', + 'normalize-request': 'n8n-nodes-base.set', + 'route-action': 'n8n-nodes-base.switch', + 'append-log-response': 'n8n-nodes-base.set', + 'respond-append-log': 'n8n-nodes-base.respondToWebhook', + 'notify-response': 'n8n-nodes-base.set', + 'respond-notify': 'n8n-nodes-base.respondToWebhook', + 'unknown-action-response': 'n8n-nodes-base.set', + 'respond-unknown-action': 'n8n-nodes-base.respondToWebhook', +} + + +def fail(msg: str): + print(f'ERROR: {msg}', file=sys.stderr) + raise SystemExit(1) + + +def load_json(path: Path): + try: + return json.loads(path.read_text()) + except Exception as e: + fail(f'failed to parse {path}: {e}') + + +def main(): + path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('assets/openclaw-action.workflow.json') + data = load_json(path) + + if not isinstance(data, dict): + fail('workflow file must decode to a JSON object') + + nodes = data.get('nodes') + connections = data.get('connections') + if not isinstance(nodes, list): + fail('workflow is missing a nodes array') + if not isinstance(connections, dict): + fail('workflow is missing a connections object') + + by_name = {} + for node in nodes: + if not isinstance(node, dict): + fail('every node entry must be an object') + name = node.get('name') + if not name: + fail('every node must have a name') + by_name[name] = node + + missing = sorted(REQUIRED_NODE_NAMES - set(by_name)) + if missing: + fail(f'missing required nodes: {", ".join(missing)}') + + for name, node_type in EXPECTED_DIRECT_TYPES.items(): + actual = by_name[name].get('type') + if actual != node_type: + fail(f'node {name!r} should have type {node_type!r}, got {actual!r}') + + webhook = by_name['Webhook'] + webhook_params = webhook.get('parameters', {}) + if webhook_params.get('path') != 'openclaw-action': + fail('Webhook.path must be openclaw-action') + if webhook_params.get('httpMethod') != 'POST': + fail('Webhook.httpMethod must be POST') + if webhook_params.get('responseMode') != 'responseNode': + fail('Webhook.responseMode must be responseNode') + + normalize = by_name['normalize-request'].get('parameters', {}) + normalize_assignments = normalize.get('assignments', {}).get('assignments', []) + normalize_fields = {a.get('name') for a in normalize_assignments if isinstance(a, dict)} + for field in ('action', 'args', 'request_id'): + if field not in normalize_fields: + fail(f'normalize-request must assign {field!r}') + + route = by_name['route-action'].get('parameters', {}) + rule_values = route.get('rules', {}).get('values', []) + if len(rule_values) < 2: + fail('route-action must define at least two routing rules') + rule_names = {rule.get('outputKey') for rule in rule_values if isinstance(rule, dict)} + for action in ('append_log', 'notify'): + if action not in rule_names: + fail(f'route-action must have a routing rule for {action!r}') + + route_outputs = connections.get('route-action', {}).get('main', []) + if len(route_outputs) < 3: + fail('route-action must expose append_log, notify, and fallback outputs') + + for responder_name in ('respond-append-log', 'respond-notify', 'respond-unknown-action'): + params = by_name[responder_name].get('parameters', {}) + if params.get('respondWith') != 'json': + fail(f'{responder_name} must respondWith json') + + sample_paths = [ + path.parent / 'test-append-log.json', + path.parent / 'test-notify.json', + ] + for sample in sample_paths: + sample_data = load_json(sample) + if not isinstance(sample_data, dict): + fail(f'sample payload must be an object: {sample}') + if 'action' not in sample_data or 'args' not in sample_data: + fail(f'sample payload missing action/args: {sample}') + + print('OK: workflow asset structure looks consistent') + print(f'- workflow: {path}') + print(f'- nodes: {len(nodes)}') + print(f'- rules: {len(rule_values)} + fallback') + print('- samples: test-append-log.json, test-notify.json') + + +if __name__ == '__main__': + main()