Adding on_raw_inbound Plugin Hook for Passive Message Observation
Guide for implementing a new plugin hook that fires before channel-level filtering, enabling ambient awareness across all incoming messages without triggering responses.
π Symptoms
Problem Manifestation
Developers attempting to build ambient awareness systems encounter a fundamental architectural limitation: no visibility into messages that fail channel-level filters.
Scenario 1: Ambient Context Collector
typescript // Intended usage - wanting to observe ALL messages api.on(“message”, async (event) => { // This only fires for messages that pass: // 1. Group membership allowlist // 2. Sender policy (groupPolicy + groupAllowFrom) // 3. Mention/activation gating await contextCollector.addToKnowledgeGraph(event); });
Actual behavior: Messages from non-allowlisted groups, blocked senders, or messages lacking required mentions are silently discarded before reaching any plugin hook.
Scenario 2: WhatsApp Passive Listener
typescript // Want to monitor group conversations without responding api.on(“message”, async (msg) => { await auditLog.record(msg); // Only fires for allowed messages });
Result: The bot sees no messages unless users specifically mention it, defeating the purpose of passive monitoring.
Scenario 3: Cross-Channel Pattern Detection
typescript // Attempting to correlate signals across channels api.on(“message”, async (event) => { if (event.channel === ‘whatsapp’) { await patternAnalyzer.process(event); } });
Limitation: WhatsApp messages require mentions due to requireMention configuration; without mentions, no events fire.
Current Workarounds (Problematic)
- Widen allowlist + requireMention: Mention still triggers response pipeline
- Patch compiled JS: Fragile, breaks on updates, unsupported
- Secondary baileys connection: Conflicts with single-session WhatsApp Web architecture
- Gateway log parsing: Unstructured format, not stable API, high overhead
Error Patterns
# Attempting to access filtered messages
[ChannelRouter] Message from unknown sender blocked: sender=sender_id channel=whatsapp
[ChannelRouter] Chat not in allowlist: chat_id=group_id action=drop
# No hook fires - silent drop
[FilterChain] Skipping message - failed allowlist check
[FilterChain] Skipping message - failed mention gate
π§ Root Cause
Architectural Analysis
The current OpenClaw message processing pipeline applies filters before plugin hooks execute. This creates a hard boundary that prevents observation of rejected messages.
Current Flow
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β MESSAGE PROCESSING PIPELINE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β [Transport Layer] β
β β β
β βΌ β
β βββββββββββββββ β
β β Raw Message β β on_raw_inbound SHOULD fire here β
β βββββββββββββββ β
β β β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β FILTER CHAIN β β
β β βββββββββββββββββββββββββββββββββββ β β
β β β 1. Allowlist Check β β β
β β β 2. Sender Policy Check β β β
β β β 3. Mention Gating β β β
β β βββββββββββββββββββββββββββββββββββ β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β β β
β βΌ (pass) βΌ (fail/drop) β
β βββββββββββββββ βββββββββββββββββββ β
β β Plugin Hooksβ β Message Dropped β β
β β (on_message)β β No Hooks Fire β β
β βββββββββββββββ βββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Filter Chain Implementation
The filtering occurs in the channel router at the transport abstraction layer:
typescript
// Current architecture (simplified)
class ChannelRouter {
async routeMessage(rawMessage: RawMessage): Promise
// 2. APPLY FILTERS BEFORE HOOKS
if (!this.checkAllowlist(parsed.chatId)) {
return; // Message dropped - no hook fires
}
if (!this.checkSenderPolicy(parsed.senderId)) {
return; // Message dropped - no hook fires
}
if (!this.checkMentionGating(parsed)) {
return; // Message dropped - no hook fires
}
// 3. Only now do hooks fire
await this.emitToPlugins(parsed);
} }
Why This Design Exists
- Performance: Rejecting messages early avoids expensive plugin processing
- Security: Prevents information leakage from unauthorized sources
- Simplicity: Plugins only see "actionable" messages
Architectural Gap
The disconnect occurs because:
- Plugin hooks are treated as part of the agent pipeline
- Channel filters operate at the transport layer (below plugin abstraction)
- No observation path exists between raw message receipt and filter application
Technology-Specific Constraints
WhatsApp (Baileys): typescript // Baileys emits messages β OpenClaw receives β filters applied β hooks fire // No interception point for observing before filters
Telegram: typescript // Bot API β Update received β filters β hooks // Webhook/polling mechanism provides no pre-filter access
Discord: typescript // Gateway event β OpenClaw handler β filters β hooks // Discord’s gateway protocol has no pre-filter event system
π οΈ Step-by-Step Fix
Implementation Strategy
Add a new plugin hook on_raw_inbound that fires immediately after raw message parsing but before any filter chain application.
Phase 1: Core Hook Registration
File: packages/core/src/plugins/hook-registry.ts
typescript // Add new hook type export enum PluginHookType { // … existing hooks ON_MESSAGE = ‘on_message’, ON_RAW_INBOUND = ‘on_raw_inbound’, // NEW BEFORE_PROMPT_BUILD = ‘before_prompt_build’, // … }
// Update hook registration export interface RawInboundEvent { readonly channel: string; readonly chatId: string; readonly senderId: string; readonly senderName?: string; readonly text?: string; readonly timestamp: number; readonly raw: Record<string, unknown>; // Channel-specific raw payload readonly metadata: { groupName?: string; isGroup: boolean; mentions?: string[]; rawSource: ’telegram’ | ‘whatsapp’ | ‘discord’ | ‘signal’ | ‘webhook’; }; }
Phase 2: Channel Router Modification
File: packages/channels/src/channel-router.ts
typescript
class ChannelRouter {
async routeMessage(rawMessage: RawMessage): Promise
// 2. FIRE on_raw_inbound HOOK (NEW)
await this.emitRawInbound(parsed);
// 3. Continue with existing filter chain
if (!this.checkAllowlist(parsed.chatId)) {
this.logger.debug('Message dropped: allowlist check failed', {
chatId: parsed.chatId
});
return;
}
if (!this.checkSenderPolicy(parsed.senderId)) {
this.logger.debug('Message dropped: sender policy check failed', {
senderId: parsed.senderId
});
return;
}
if (!this.checkMentionGating(parsed)) {
this.logger.debug('Message dropped: mention gating failed', {
chatId: parsed.chatId
});
return;
}
// 4. Existing hook emission
await this.emitToPlugins(parsed);
}
// NEW: Raw inbound emission
private async emitRawInbound(event: RawInboundEvent): Promise
for (const hook of hooks) {
try {
await hook.handler(event);
} catch (error) {
this.logger.error('on_raw_inbound hook failed', {
hook: hook.name,
error: error.message
});
// Never propagate - observation hooks must not break processing
}
}
} }
Phase 3: Plugin API Update
File: packages/core/src/plugins/plugin-api.ts
typescript export class PluginApi { // … existing methods
/**
- Register handler for raw inbound messages.
- Fires BEFORE channel filtering (allowlist, sender policy, mention gating).
- Return value is ignored - this is observe-only.
- @param handler - Async function to handle raw messages
*/
onRawInbound(
handler: (event: RawInboundEvent) => Promise
): void { this.registerHook({ type: PluginHookType.ON_RAW_INBOUND, handler, name: handler.name || ‘anonymous_on_raw_inbound’ }); }
// Alternative: Event emitter style
on(event: ‘on_raw_inbound’, handler: (event: RawInboundEvent) => Promise
Phase 4: TypeScript Type Definitions
File: packages/types/src/plugin-events.ts
typescript export interface RawInboundEvent { /** Source channel (telegram, whatsapp, discord, signal) */ channel: ChannelType;
/** Unique chat/group identifier */ chatId: string;
/** Message sender’s unique identifier */ senderId: string;
/** Display name of sender if available */ senderName?: string;
/** Message text content (if text-based channel) */ text?: string;
/** Unix timestamp of message receipt */ timestamp: number;
/** Channel-specific raw payload for advanced processing */ raw: ChannelRawPayload;
/** Additional message metadata */ metadata: RawInboundMetadata; }
export interface RawInboundMetadata { /** Whether message originated from a group/channel */ isGroup: boolean;
/** Group display name (if group) */ groupName?: string;
/** User IDs mentioned in message */ mentions?: string[];
/** Transport source */ rawSource: ’telegram’ | ‘whatsapp’ | ‘discord’ | ‘signal’ | ‘webhook’; }
Configuration Integration
File: packages/config/src/channel-config.ts
typescript export interface ChannelGroupConfig { /** Enable raw inbound observation for this group */ observe?: boolean;
/** Sink for observed messages when observe is enabled */ observeSink?: ‘hook’ | ‘jsonl’ | ‘webhook’;
/** Webhook URL for observeSink: ‘webhook’ */ observeWebhookUrl?: string;
/** JSONL file path for observeSink: ‘jsonl’ */ observeJsonlPath?: string; }
Usage Example
typescript // Plugin: ambient-context-collector export default { name: ‘ambient-context-collector’, version: ‘1.0.0’,
async onLoad(api: PluginApi): Promise
async processAmbientEvent(event: RawInboundEvent): Promise
// Track conversation patterns
await this.patternTracker.record({
chatId: event.chatId,
sender: event.senderName,
activity: 'message_sent'
});
} };
Configuration Example
yaml
openclaw.yaml
channels: whatsapp: groups: - id: “family-group-123” allowFrom: ["+1234567890"] requireMention: true # NEW: Enable raw observation without response observe: true observeSink: jsonl observeJsonlPath: “./logs/family-ambient.jsonl”
- id: "work-team-456"
allowFrom: ["+0987654321"]
observe: true
observeSink: webhook
observeWebhookUrl: "https://analytics.internal/events"
π§ͺ Verification
Unit Tests
File: packages/core/src/plugins/__tests__/on-raw-inbound.test.ts
typescript
describe(‘on_raw_inbound hook’, () => {
let mockPluginApi: PluginApi;
let mockPluginManager: jest.Mocked
beforeEach(() => { mockPluginManager = { getHooks: jest.fn().mockReturnValue([]), registerHook: jest.fn(), } as any; mockPluginApi = new PluginApi(mockPluginManager); });
test(‘fires before allowlist check’, async () => { const rawEvents: RawInboundEvent[] = [];
mockPluginApi.onRawInbound(async (event) => {
rawEvents.push(event);
});
// Process message that would normally be filtered
await channelRouter.routeMessage({
chatId: 'blocked-chat',
senderId: 'unknown-sender',
text: 'hello'
});
// Verify hook fired despite blocked chat
expect(rawEvents).toHaveLength(1);
expect(rawEvents[0].chatId).toBe('blocked-chat');
});
test(‘does not influence routing’, async () => { let routingOccurred = false;
mockPluginApi.onRawInbound(async () => {
// Attempt to modify routing - should have no effect
routingOccurred = true;
});
await channelRouter.routeMessage({
chatId: 'blocked-chat',
senderId: 'unknown-sender',
text: 'test'
});
// Message should still be dropped
expect(routingOccurred).toBe(true);
// But on_message should NOT fire (filter still applies)
expect(pluginsFired).toHaveLength(0);
});
test(‘handler errors do not break message processing’, async () => { mockPluginApi.onRawInbound(async () => { throw new Error(‘Hook error’); });
// Should not throw
await expect(
channelRouter.routeMessage({ chatId: 'test', senderId: 'user', text: 'hi' })
).resolves.not.toThrow();
// Message should still be processed
expect(processedMessages).toHaveLength(1);
}); });
Integration Test
bash
Start OpenClaw with test configuration
OPENCLAW_CONFIG_PATH=./test-config.yaml npm run start
Send message from non-allowlisted group
Verify on_raw_inbound fires (check logs)
Send message from allowlisted group
Verify both on_raw_inbound AND on_message fire
Send message with required mention (if configured)
Verify behavior matches expectations
Verification Checklist
- Hook Execution:
on_raw_inboundfires for all incoming messages - Filter Independence: Hook fires regardless of allowlist/sender policy/mention status
- No Routing Influence: Return value/throws have no effect on message routing
- Error Isolation: Hook errors do not crash message processing
- Channel Parity: Works identically across Telegram, WhatsApp, Discord, Signal
- Performance: Hook adds <10ms latency to message processing
- Memory Safety: No memory leaks from long-running observation handlers
Log Verification
# Should see raw inbound log entries
[ChannelRouter] on_raw_inbound fired: channel=whatsapp chatId=group-123 senderId=user-456
# Followed by filter decisions (regardless of hook result)
[ChannelRouter] Message dropped: allowlist check failed chatId=group-123
# OR
[ChannelRouter] Message passed filters, routing to agent chatId=group-123
β οΈ Common Pitfalls
Implementation Pitfalls
- Blocking Hook Execution
typescript // BAD: Synchronous blocking in handler api.onRawInbound(async (event) => { await fs.promises.writeFile(’/tmp/all-messages.log’, JSON.stringify(event)); // fs.writeFile is synchronous - blocks processing });
// GOOD: Non-blocking api.onRawInbound(async (event) => { writeToLogQueue(event); // Queue-based, non-blocking });
- Memory Leaks from Unbounded State**
typescript // BAD: Accumulating all messages const allMessages: RawInboundEvent[] = [];
api.onRawInbound(async (event) => { allMessages.push(event); // Unbounded growth });
// GOOD: Sliding window or sampling const recentMessages = new CircularBuffer
(1000); api.onRawInbound(async (event) => { recentMessages.push(event); }); - Assuming Hook Fires for All Messages**
The hook fires at the channel router level. Edge cases:
- Malformed messages that fail parsing (hook does NOT fire)
- System messages (join/leave) may have different behavior per channel
- Messages received during connection loss may be batch-processed differently
- Overwriting Plugin State**
typescript // BAD: Shared mutable state across handler invocations let lastProcessedChat: string;
api.onRawInbound(async (event) => { lastProcessedChat = event.chatId; // Race condition await processChat(event.chatId); });
Configuration Pitfalls
- observe: true without sink: Messages logged but no output destination configured
- Webhook timeout: Long-running webhooks block subsequent hook executions
- JSONL file rotation: Without logrotate, files grow indefinitely
- Permission errors: JSONL path directory may not exist or be writable
Environment-Specific Concerns
Docker:
# Volume mount JSONL paths correctly
volumes:
- ./logs:/app/logs # Ensure directory exists before container start
# Webhook connectivity from inside container
# Use host.docker.internal or Docker network aliases
macOS:
# File watching may behave differently
# Test JSONL writes on macOS NFS mounts
# Path separator differences (though Node handles this)
Windows:
# Path handling: Use path.join() for cross-platform
# Line endings in JSONL: Always use \n (LF), not \r\n
# Permission issues: Run container as non-root if possible
Performance Considerations
| Scenario | Risk | Mitigation |
|---|---|---|
| High message volume (>1000/min) | Hook overhead compounds | Add sampling, async queuing |
| Database writes per message | Connection pool exhaustion | Batch inserts |
| File I/O per message | Disk I/O bottleneck | Buffer writes, async flush |
| Regex matching per message | CPU saturation | Precompile patterns, use efficient matching |
π Related Errors
Related Feature Requests
- #247: Channel-level message mirroring
Similar need for observing messages without response, proposes webhook-based mirroring - #189: Mention gating bypass for specific roles
Related to filtering before mention check, different use case but same architectural constraint - #312: Per-group response policies
Proposes granular control over which messages trigger agent pipeline - #156: WhatsApp passive mode
Direct WhatsApp-specific request for observing without responding
Historical Issues
- Message silently dropped with no logging
Users confused by messages not appearing - was due to filter chain, not bugs - on_message not firing for Telegram groups
Root cause: Missing bot mention in groups requires specific configuration - WhatsApp multi-device conflicts
Secondary baileys connections conflict with primary session - prevents workaround
Complementary Features
| Feature | Relationship | Implementation Note |
|---|---|---|
before_prompt_build | Fires AFTER filters | Does not solve raw observation |
on_message | Fires for filtered messages only | Use with on_raw_inbound for layered observation |
| Channel allowlists | Blocks messages before hook | on_raw_inbound provides visibility before block |
requireMention | Filter mechanism | on_raw_inbound bypasses this entirely |
Architecture Links
- Channel Router: `packages/channels/src/channel-router.ts` - Filter chain implementation
- Plugin Manager: `packages/core/src/plugins/plugin-manager.ts` - Hook registry
- Hook Types: `packages/types/src/plugin-events.ts` - Event type definitions
- Baileys Integration: `packages/channels/whatsapp/src/baileys-adapter.ts` - WhatsApp transport
External References
- Baileys library: WhiskeySockets/Baileys - Single session constraint
- Telegram Bot API: Message filtering - Groups vs channels
- Discord Gateway: Intent system - Message content access