Skip to content

Feature Request: Add before_dispatch plugin hook for universal pre-LLM message interception #43418

@loveyana

Description

@loveyana

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

  1. Modifying message_received to support blocking: Would require changing the fire-and-forget semantics of an existing hook, potentially breaking existing plugins.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions