| name | add-skill-webhook |
| description | Add a webhook HTTP endpoint so external services (Home Assistant, uptime monitors, Proxmox) can push events that trigger agent turns. Avoids token-wasting cron polling. Triggers on "webhook", "add webhook", "http endpoint", "push events", "webhook endpoint". |
Add Webhook Endpoint
HTTP webhook endpoint for NanoTars. External services POST events to per-group endpoints, which get injected into the message pipeline — no cron polling needed.
Each group gets its own URL path (/webhook/<group-folder>) and unique secret token for isolation.
Preflight
Before installing, verify NanoTars is set up:
[ -d node_modules ] && echo "DEPS: ok" || echo "DEPS: missing"
docker image inspect nanoclaw-agent:latest &>/dev/null && echo "IMAGE: ok" || echo "IMAGE: not built"
if grep -q "ANTHROPIC_API_KEY\|CLAUDE_CODE_OAUTH_TOKEN" .env 2>/dev/null || [ -f "$HOME/.claude/.credentials.json" ]; then echo "AUTH: ok"; else echo "AUTH: missing"; fi
If any check fails, tell the user to run /nanotars-setup first and stop.
Prerequisites
- At least one channel group must be registered (via
/nanotars-add-group)
Install / Add Route
1. Check current state
[ -d plugins/webhook ] && echo "PLUGIN_EXISTS" || echo "NEED_PLUGIN"
[ -f data/webhook-routes.json ] && echo "ROUTES_EXIST" || echo "NEED_ROUTES"
If NEED_PLUGIN: copy plugin files first:
cp -r ${CLAUDE_PLUGIN_ROOT}/files/ plugins/webhook/
1a. Plugin Configuration
Ask the user which groups should have access to this plugin:
- All groups (default) -- every group's agent can use this
- Specific groups only -- e.g., only
main
If restricting, update plugins/webhook/plugin.json to set "groups" to the list of group folder names.
Also ask about channel types. Leave "channels": ["*"] for all, or set to specific types (e.g., ["whatsapp"]).
2. Show existing routes (if any)
If data/webhook-routes.json exists, read and display current routes:
cat data/webhook-routes.json
Show the user which groups already have webhook endpoints (group name + creation date). Never display full tokens — show only the first 8 characters.
3. Ask the user what to do
If no routes exist (first run): skip to step 4.
If routes exist (re-run), ask:
- Add a new group endpoint — proceed to step 4
- Regenerate token for an existing group — proceed to step 6
4. Choose target group
List registered groups by reading the groups/ directory. Each subfolder is a registered group. Show the user the list and ask which group should receive webhook events.
ls -1d groups/*/ 2>/dev/null | xargs -n1 basename
For richer detail (channel mappings, agent names) ask the user to run /nanotars-groups in another session.
5. Generate route and save
TOKEN="whk_$(openssl rand -hex 32)"
if [ -f data/webhook-routes.json ]; then
jq --arg folder "GROUP_FOLDER" --arg secret "$TOKEN" --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.routes[$folder] = {secret: $secret, createdAt: $date}' data/webhook-routes.json > data/webhook-routes.tmp \
&& mv data/webhook-routes.tmp data/webhook-routes.json
else
cat > data/webhook-routes.json << EOF
{
"routes": {
"GROUP_FOLDER": {
"secret": "$TOKEN",
"createdAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
}
}
EOF
fi
Replace GROUP_FOLDER with the actual group folder name (e.g., main, family-chat).
5a. Network config (first run only)
If WEBHOOK_PORT and WEBHOOK_HOST are not already in .env:
Ask the user which network interface to bind on:
- Localhost only (recommended) —
WEBHOOK_HOST=127.0.0.1. Only services on this machine can reach the webhook. Use this if external services connect via a VPN/tunnel (WireGuard, Tailscale) that terminates locally, or if you use a reverse proxy (nginx, Caddy).
- All interfaces —
WEBHOOK_HOST=0.0.0.0. Services on the LAN can reach the webhook directly. Convenient for Home Assistant, n8n, etc. on the same network.
If the user chooses all interfaces, warn them:
Security note: Binding to all interfaces exposes the webhook to your entire network. The endpoint is protected by Bearer token auth, but the token is sent in cleartext over HTTP. Recommendations:
- Do not expose the port to the internet — use a firewall rule to restrict access to your LAN/VPN only
- For remote access, use a VPN (WireGuard, Tailscale, Pangolin) rather than port-forwarding
- If you must expose it publicly, put it behind a reverse proxy with TLS (nginx, Caddy)
Save the choice:
echo "WEBHOOK_PORT=3457" >> .env
echo "WEBHOOK_HOST=127.0.0.1" >> .env
5b. Show the user their webhook details
Webhook endpoint created:
URL: http://HOST:PORT/webhook/GROUP_FOLDER
Token: whk_...full token here...
Test:
curl -s -X POST http://HOST:PORT/webhook/GROUP_FOLDER \
-H "Authorization: Bearer whk_..." \
-H "Content-Type: application/json" \
-d '{"source": "test", "text": "Hello from webhook test!"}' | jq .
For the main group, also mention: POST /webhook (no path suffix) routes to main as a shorthand.
5c. Restart
npm run build && nanotars restart
The routes file is also hot-reloaded — after the first restart, adding new routes takes effect without restarting.
6. Regenerate token (re-run)
If the user wants to regenerate a token for an existing group:
- Warn: "This will invalidate the current token. Any external services using the old token will stop working and need to be updated. Continue?"
- If confirmed:
TOKEN="whk_$(openssl rand -hex 32)"
jq --arg folder "GROUP_FOLDER" --arg secret "$TOKEN" --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'.routes[$folder].secret = $secret | .routes[$folder].createdAt = $date' data/webhook-routes.json > data/webhook-routes.tmp \
&& mv data/webhook-routes.tmp data/webhook-routes.json
- Show the new token.
- The routes file is hot-reloaded — no restart needed.
Migration from NANOCLAW_WEBHOOK_SECRET
If .env contains NANOCLAW_WEBHOOK_SECRET but no data/webhook-routes.json exists:
- Read the existing secret:
SECRET=$(grep "^NANOCLAW_WEBHOOK_SECRET=" .env | cut -d= -f2)
- Create routes file with it as the main route:
cat > data/webhook-routes.json << EOF
{
"routes": {
"main": {
"secret": "$SECRET",
"createdAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
}
}
EOF
- Tell the user: "Migrated your existing webhook secret to the new per-group routes system. Your existing callers will continue working. You can remove
NANOCLAW_WEBHOOK_SECRET from .env."
Verify
Read the token for the target group:
TOKEN=$(jq -r '.routes["GROUP_FOLDER"].secret' data/webhook-routes.json)
Test auth rejection (should return 401):
curl -s -X POST http://localhost:3457/webhook/GROUP_FOLDER \
-H "Content-Type: application/json" \
-d '{"source": "test", "text": "hello"}' | jq .
Test wrong token (should return 401):
curl -s -X POST http://localhost:3457/webhook/GROUP_FOLDER \
-H "Authorization: Bearer wrong-token" \
-H "Content-Type: application/json" \
-d '{"source": "test", "text": "hello"}' | jq .
Test successful injection (should return 200):
curl -s -X POST http://localhost:3457/webhook/GROUP_FOLDER \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source": "test", "text": "This is a test webhook message. Reply with OK if you received it."}' | jq .
Verify the message landed:
The agent should respond in the target group's chat within a few seconds. If it doesn't, tail service logs to confirm the webhook was received and dispatched:
nanotars logs --tail 30
Usage Examples
Home Assistant Automation
automation:
- alias: "Notify NanoTars on motion"
trigger:
- platform: state
entity_id: binary_sensor.front_door_motion
to: "on"
action:
- service: rest_command.nanotars_webhook
data:
source: home-assistant
text: "Motion detected on front door camera at {{ now().strftime('%H:%M') }}"
rest_command:
nanotars_webhook:
url: "http://NANOCLAW_IP:3457/webhook/main"
method: POST
headers:
Authorization: "Bearer YOUR_WEBHOOK_TOKEN"
Content-Type: "application/json"
payload: '{"source": "{{ source }}", "text": "{{ text }}"}'
Uptime Kuma / Generic Monitor
curl -X POST http://NANOCLAW_IP:3457/webhook/admin \
-H "Authorization: Bearer YOUR_ADMIN_WEBHOOK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"source": "uptime-kuma", "text": "ALERT: website.com is DOWN. Status: 503."}'
How It Works
- Routes are stored in
data/webhook-routes.json — each group gets a unique path and token
- Server starts only if routes file exists with at least one route (safe default = off)
POST /webhook/<group-folder> validates the per-group Bearer token, parses JSON body { source, text }
POST /webhook (no suffix) routes to the main group as a shorthand
- Message is inserted into the group's pipeline via
ctx.insertMessage()
- Routes file is watched for changes — adding new routes takes effect without restart
- No new npm dependencies — uses Node.js built-in
http, crypto, fs
Security
- Per-group auth: Each group has its own unique Bearer token — compromising one doesn't affect others
- Path isolation: Groups only receive webhooks on their own path
- Payload limit: 64KB max body size prevents memory exhaustion
- Network: Defaults to localhost only (
127.0.0.1). Can be opened to LAN via WEBHOOK_HOST=0.0.0.0
- Default off: Server doesn't start without routes configured
- Not internet-safe: Tokens are sent in cleartext over HTTP. For remote access, use a VPN or reverse proxy with TLS
Remove
Remove a single group's webhook:
jq 'del(.routes["GROUP_FOLDER"])' data/webhook-routes.json > data/webhook-routes.tmp \
&& mv data/webhook-routes.tmp data/webhook-routes.json
Remove webhook plugin entirely:
rm -rf plugins/webhook/
rm -f data/webhook-routes.json
- Remove env vars from
.env:
sed -i '/^NANOCLAW_WEBHOOK_SECRET=/d' .env
sed -i '/^WEBHOOK_PORT=/d' .env
sed -i '/^WEBHOOK_HOST=/d' .env
sed -i '/^NANOCLAW_WEBHOOK_URL=/d' .env
- Rebuild and restart.