Universal event router that dispatches events to plugin handlers. Auto-discovers plugins, configurable via JSON, extensible for any event source.
Architecture:
External Event → Pub/Sub/Webhook → OpenClaw → event-router.py → Plugin → Action
cd ~/.openclaw/workspace/tools
echo '{"source":"gmail","account":"test@example.com","messages":[]}' | python3 event-router.py
_template.py to {source}.py (e.g., calendar.py)can_handle() and handle() methods ../event-config.jsonEvery plugin must implement:
class MyEventHandler:
def can_handle(self, event) → bool:
"""Return True if this plugin handles this event"""
return event.get('source') == 'my_source'
def handle(self, event, config) → dict:
"""Process event and return action"""
return {
'action': 'alert|digest|silent|error',
'message': 'Alert text',
'priority': 'critical|informational|noise'
}
def get_config_schema(self) → dict:
"""Return configuration schema"""
return {'enabled': True, 'cost_estimate_monthly': 0.00}
Standard event structure:
{
"source": "gmail|calendar|drive|zoho|custom",
"timestamp": "2026-02-11T06:00:00Z",
"data": {
... source-specific data ...
}
}
{
"source": "gmail",
"account": "quan@ztag.com",
"messages": [
{
"id": "19c4...",
"from": "sender@example.com",
"to": "quan@ztag.com",
"subject": "Email subject",
"snippet": "Preview text...",
"date": "Mon, 10 Feb 2026 10:00:00 +0000",
"unread": true
}
]
}
{
"source": "calendar",
"event_type": "event_starting_soon",
"data": {
"summary": "Meeting with Brian",
"start": "2026-02-11T14:00:00Z",
"attendees": ["brian@calbt.com"]
}
}
{
"source": "drive",
"event_type": "file_created",
"data": {
"file_id": "1abc...",
"name": "Meeting Notes 2026-02-11.md",
"mime_type": "text/markdown",
"folder": "/meeting-notes"
}
}
Edit ../event-config.json:
{
"plugins": {
"GmailEventHandler": {
"enabled": true,
"shadow_mode": false,
"accounts": ["quan@ztag.com"],
"model": "anthropic/claude-haiku-4-5"
}
},
"router": {
"default_model": "anthropic/claude-haiku-4-5",
"flood_threshold": 30
}
}
cd ~/.openclaw/workspace/tools
# Create test event
cat > test-gmail-event.json << 'EOF'
{
"source": "gmail",
"account": "quan@ztag.com",
"messages": [
{
"id": "test123",
"from": "Faye Nesheiwat <faye@calbt.com>",
"to": "quan@ztag.com",
"subject": "Loan closing needs letter",
"snippet": "We need a letter of explanation...",
"date": "Mon, 10 Feb 2026 10:00:00 +0000",
"unread": true
}
]
}
EOF
# Route through event router
cat test-gmail-event.json | python3 event-router.py
Expected output:
{
"action": "alert",
"message": "📧 [quan@ztag.com] 1 critical: Faye Nesheiwat - Loan closing needs letter",
"priority": "critical"
}
# Test via OpenClaw webhook (requires gateway running)
curl -X POST http://127.0.0.1:18789/hooks/agent \
-H 'Authorization: Bearer YOUR_HOOK_TOKEN' \
-H 'Content-Type: application/json' \
-d @test-gmail-event.json
Router logs per-plugin costs to ../../cost-tracking/event-costs.json:
{
"date": "2026-02-11",
"GmailEventHandler": {
"events_processed": 45,
"tokens_used": 27000,
"cost_usd": 0.045,
"alerts_sent": 2
},
"total_day": 0.045
}
Minnie-Review generates weekly ROI reports automatically.
# Run router with debug output
cat event.json | python3 event-router.py 2>&1 | tee debug.log
# View Gmail classifications
cat ../../working/ops/gmail-shadow-log.json | jq '.[-5:]'
# Check which emails have been alerted
cat ../../working/ops/alerted-email-ids.json | jq '.[-10:]'
Plugins should be idempotent - processing the same event twice should be safe.
def handle(self, event, config):
event_id = event.get('id')
if self.already_processed(event_id):
return {'action': 'silent'}
# ... process event ...
self.mark_processed(event_id)
Always catch exceptions and return error action:
def handle(self, event, config):
try:
# ... process event ...
except Exception as e:
return {
'action': 'error',
'message': f'Plugin failed: {str(e)}'
}
Use files for persistent state:
def __init__(self):
self.state_path = 'working/ops/my-plugin-state.json'
self.load_state()
def load_state(self):
try:
with open(self.state_path, 'r') as f:
self.state = json.load(f)
except FileNotFoundError:
self.state = {}
def save_state(self):
with open(self.state_path, 'w') as f:
json.dump(self.state, f, indent=2)
All plugin behavior should be configurable:
def handle(self, event, config):
enabled = config.get('enabled', True)
if not enabled:
return {'action': 'silent'}
threshold = config.get('threshold', 10)
model = config.get('model', 'anthropic/claude-haiku-4-5')
Log token usage for cost tracking:
def handle(self, event, config):
result = self.process(event)
# Log cost
self.log_cost({
'tokens': result['tokens_used'],
'cost': result['tokens_used'] * 0.0000005 # Haiku rate
})
return result
Issues or questions? Check:
debug.log for router errorsgmail-shadow-log.json for classification decisionsevent-config.json for plugin settingsFor architecture questions, see ../../working/ops/tier1-implementation-plan.md