Summary
Plugin authors currently lack a reliable way to block or intercept inbound messages before LLM processing across all channels. This is critical for security plugins (e.g., identity/authentication gates) that need to prevent unauthenticated users from consuming LLM tokens.
Problem
The existing plugin hooks each have limitations that prevent universal message interception:
| Hook |
Issue |
message_received |
Fire-and-forget; cannot block or modify the dispatch flow |
message_sending |
Only fires in src/infra/outbound/deliver.ts; channel plugins (e.g., Feishu, webchat) that use custom ReplyDispatcher implementations bypass this path entirely |
before_message_write |
Fires when writing to session JSONL; can replace message content but cannot prevent LLM invocation — the model has already run and tokens are spent |
before_agent_start |
Can inject context/tools but cannot block the dispatch; the LLM still runs even for unauthenticated sessions |
Real-world impact: An identity plugin that requires user authentication can inject a "please login" prompt on the first message via before_agent_start, but on subsequent messages from the same unauthenticated user, the LLM is invoked every time — wasting tokens and potentially leaking information through prompt injection.
Proposed Solution
Add a new before_dispatch plugin hook, invoked inside dispatchReplyFromConfig() after message_received hooks and before markProcessing() / LLM invocation.
Hook Signature
// New types in src/plugins/types.ts
export type PluginHookBeforeDispatchEvent = {
sessionKey: string;
channelId: string;
senderId?: string;
conversationId?: string;
isGroup: boolean;
content: string;
messageId?: string;
};
export type PluginHookBeforeDispatchResult = {
/** If true, the dispatch is aborted — no LLM invocation occurs. */
block?: boolean;
/** If block is true, this text is sent as a direct reply to the user. */
replyText?: string;
};
Integration Point
In src/auto-reply/reply/dispatch-from-config.ts, after the message_received hook block (~line 196) and before markProcessing() (~line 275):
// Run before_dispatch plugin hooks (blocking — can abort dispatch)
if (hookRunner?.hasHooks("before_dispatch")) {
const beforeDispatchResult = await hookRunner.runBeforeDispatch(
{
sessionKey: sessionKey ?? "",
channelId: channel,
senderId: ctx.SenderId,
conversationId: hookContext.conversationId,
isGroup,
content: hookContext.content,
messageId: messageIdForHook,
},
toPluginMessageContext(hookContext),
);
if (beforeDispatchResult?.block) {
if (beforeDispatchResult.replyText) {
dispatcher.sendFinalReply({ text: beforeDispatchResult.replyText });
}
recordProcessed("skipped", { reason: "before_dispatch_blocked" });
return { queuedFinal: Boolean(beforeDispatchResult.replyText), counts: dispatcher.getQueuedCounts() };
}
}
Why This Location?
dispatchReplyFromConfig is the universal entry point for all channel message dispatches — webchat, Feishu, Telegram, Discord, Slack, etc. All channels converge here before LLM invocation. This makes it the only reliable place for a universal pre-LLM blocking hook.
The context available at this point is rich: SessionKey, SenderId, AccountId, ChatType, isGroup, channelId, etc. — everything a security plugin needs to make an informed decision.
Use Case: Authentication Gate
1st message (unauthenticated):
→ before_dispatch: session not yet marked → PASS
→ before_agent_start: no credential found → mark session as unauthenticated, inject login prompt
→ LLM generates login URL response
2nd+ message (still unauthenticated):
→ before_dispatch: session is marked unauthenticated → BLOCK + reply "Please login first"
→ LLM is NOT invoked → zero token cost
After login:
→ before_dispatch: session cleared → PASS
→ before_agent_start: credential found → normal flow
→ LLM processes message normally
Alternatives Considered
- Modifying
message_received to support blocking: Would require changing the fire-and-forget semantics of an existing hook, potentially breaking existing plugins.
- Adding blocking support to each channel's custom dispatcher: Would require changes across every channel extension (Feishu, Telegram, Discord, etc.) — fragile and hard to maintain.
- Using
registerHook (internal hooks): These are fire-and-forget notifications, not designed for blocking.
Additional Context
- This was discovered while building the agent-identity-plugin which requires authentication before allowing agent interactions.
- The
sendPolicy: "deny" mechanism in dispatch-from-config.ts (line ~330) already demonstrates a similar blocking pattern at the same location — before_dispatch generalizes this for plugins.
- Happy to submit a PR implementing this if the approach is acceptable.
Summary
Plugin authors currently lack a reliable way to block or intercept inbound messages before LLM processing across all channels. This is critical for security plugins (e.g., identity/authentication gates) that need to prevent unauthenticated users from consuming LLM tokens.
Problem
The existing plugin hooks each have limitations that prevent universal message interception:
message_receivedmessage_sendingsrc/infra/outbound/deliver.ts; channel plugins (e.g., Feishu, webchat) that use customReplyDispatcherimplementations bypass this path entirelybefore_message_writebefore_agent_startReal-world impact: An identity plugin that requires user authentication can inject a "please login" prompt on the first message via
before_agent_start, but on subsequent messages from the same unauthenticated user, the LLM is invoked every time — wasting tokens and potentially leaking information through prompt injection.Proposed Solution
Add a new
before_dispatchplugin hook, invoked insidedispatchReplyFromConfig()aftermessage_receivedhooks and beforemarkProcessing()/ LLM invocation.Hook Signature
Integration Point
In
src/auto-reply/reply/dispatch-from-config.ts, after themessage_receivedhook block (~line 196) and beforemarkProcessing()(~line 275):Why This Location?
dispatchReplyFromConfigis the universal entry point for all channel message dispatches — webchat, Feishu, Telegram, Discord, Slack, etc. All channels converge here before LLM invocation. This makes it the only reliable place for a universal pre-LLM blocking hook.The context available at this point is rich:
SessionKey,SenderId,AccountId,ChatType,isGroup,channelId, etc. — everything a security plugin needs to make an informed decision.Use Case: Authentication Gate
Alternatives Considered
message_receivedto support blocking: Would require changing the fire-and-forget semantics of an existing hook, potentially breaking existing plugins.registerHook(internal hooks): These are fire-and-forget notifications, not designed for blocking.Additional Context
sendPolicy: "deny"mechanism indispatch-from-config.ts(line ~330) already demonstrates a similar blocking pattern at the same location —before_dispatchgeneralizes this for plugins.