feat(skill): finish n8n webhook skill

This commit is contained in:
zap
2026-03-12 07:07:21 +00:00
parent 295809b161
commit e0a3694430
9 changed files with 721 additions and 7 deletions

View File

@@ -9,4 +9,5 @@
- `openclaw-ping` webhook path tested end-to-end - `openclaw-ping` webhook path tested end-to-end
- Operating note: prefer narrow webhook-first integration rather than broad n8n admin/API access. - 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`. - 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.

View File

@@ -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`). 4. Use header auth by default (`x-openclaw-secret`).
5. Use `/webhook-test/` only while building/editing a workflow. 5. Use `/webhook-test/` only while building/editing a workflow.
6. Surface non-2xx responses clearly instead of pretending success. 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 ## Quick usage
@@ -39,7 +50,7 @@ scripts/call-webhook.sh openclaw-ping --data '{"message":"hello from OpenClaw"}'
Call the preferred action-bus route: Call the preferred action-bus route:
```bash ```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: 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 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 ## Workflow
### Call an existing safe webhook directly ### Call an existing safe webhook directly
@@ -76,12 +93,28 @@ Payload shape:
This keeps the external surface small while letting n8n route internally. 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 ### Add a new webhook-backed capability
1. Write down the webhook path, required auth, request JSON, and response JSON. 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`. 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. 3. If the shipped workflow should support it, update `assets/openclaw-action.workflow.json` and rerun `scripts/validate-workflow.py`.
4. Only add the new endpoint to regular use after a successful `/webhook-test/` run. 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 ## 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-webhook.sh` — authenticated POST helper for direct local n8n webhooks
- `scripts/call-action.sh` — wrapper for action-bus style calls against `openclaw-action` - `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 - `references/payloads.md` — request/response contracts and naming conventions

View File

@@ -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"
}

View File

@@ -0,0 +1,7 @@
{
"action": "append_log",
"args": {
"text": "backup complete"
},
"request_id": "test-append-log-001"
}

View File

@@ -0,0 +1,8 @@
{
"action": "notify",
"args": {
"title": "Workflow finished",
"message": "n8n router test"
},
"request_id": "test-notify-001"
}

View File

@@ -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
```

View File

@@ -33,6 +33,9 @@ Purpose:
- keep the external n8n surface small - keep the external n8n surface small
- route several agent-safe operations behind one authenticated webhook - route several agent-safe operations behind one authenticated webhook
Shipped workflow asset:
- `assets/openclaw-action.workflow.json`
Recommended request shape: Recommended request shape:
```json ```json
@@ -87,6 +90,9 @@ Request:
Purpose: Purpose:
- append a short line to a known log or tracking sink - append a short line to a known log or tracking sink
Sample payload file:
- `assets/test-append-log.json`
### `notify` ### `notify`
Request: Request:
@@ -104,6 +110,9 @@ Request:
Purpose: Purpose:
- send a small notification through a known downstream channel - send a small notification through a known downstream channel
Sample payload file:
- `assets/test-notify.json`
## Naming guidance ## Naming guidance
- Use lowercase kebab-case for webhook paths. - Use lowercase kebab-case for webhook paths.

View File

@@ -4,7 +4,7 @@ set -euo pipefail
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: Usage:
scripts/call-action.sh <action> [--args '{"k":"v"}'] [--args-file args.json] [--request-id <id>] [--path openclaw-action] [--test] [--pretty] [--dry-run] scripts/call-action.sh <action> [--args '{"k":"v"}'] [--args-file args.json] [--request-id <id|auto>] [--path openclaw-action] [--test] [--pretty] [--dry-run]
Environment: Environment:
N8N_ACTION_PATH Default router webhook path (default: openclaw-action) N8N_ACTION_PATH Default router webhook path (default: openclaw-action)
@@ -13,7 +13,7 @@ Environment:
N8N_SECRET_HEADER Header name (default: x-openclaw-secret) N8N_SECRET_HEADER Header name (default: x-openclaw-secret)
Examples: 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 scripts/call-action.sh notify --args-file notify.json --test --pretty
EOF EOF
} }
@@ -98,6 +98,14 @@ if [[ -n "$ARGS_FILE" ]]; then
ARGS="$(cat "$ARGS_FILE")" ARGS="$(cat "$ARGS_FILE")"
fi fi
if [[ "$REQUEST_ID" == "auto" ]]; then
REQUEST_ID="$(python3 - <<'PY'
import uuid
print(uuid.uuid4())
PY
)"
fi
PAYLOAD="$({ PAYLOAD="$({
python3 - <<'PY' "$ACTION" "$ARGS" "$REQUEST_ID" python3 - <<'PY' "$ACTION" "$ARGS" "$REQUEST_ID"
import json, sys import json, sys

View File

@@ -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()