May 05, 2026

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 { // 1. Parse raw message const parsed = this.parseMessage(rawMessage);

// 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

  1. Performance: Rejecting messages early avoids expensive plugin processing
  2. Security: Prevents information leakage from unauthorized sources
  3. 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 { // 1. Parse raw message const parsed = this.parseMessage(rawMessage);

// 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 { const hooks = this.pluginManager.getHooks(PluginHookType.ON_RAW_INBOUND);

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): void; }

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 { // Register raw inbound handler api.onRawInbound(async (event) => { // This fires for EVERY message before filtering await this.processAmbientEvent(event); }); },

async processAmbientEvent(event: RawInboundEvent): Promise { // Build knowledge graph from all observed messages await this.graph.addNode({ type: ‘observed_message’, channel: event.channel, chatId: event.chatId, senderId: event.senderId, timestamp: event.timestamp, content: event.text });

// 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_inbound fires 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

  1. 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 });

  2. 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); });

  3. 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
  4. 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

ScenarioRiskMitigation
High message volume (>1000/min)Hook overhead compoundsAdd sampling, async queuing
Database writes per messageConnection pool exhaustionBatch inserts
File I/O per messageDisk I/O bottleneckBuffer writes, async flush
Regex matching per messageCPU saturationPrecompile patterns, use efficient matching
  • #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

FeatureRelationshipImplementation Note
before_prompt_buildFires AFTER filtersDoes not solve raw observation
on_messageFires for filtered messages onlyUse with on_raw_inbound for layered observation
Channel allowlistsBlocks messages before hookon_raw_inbound provides visibility before block
requireMentionFilter mechanismon_raw_inbound bypasses this entirely
  • 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

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.