Make the agent keep quiet: pre_llm_call can now veto a turn before any model call


Two in the morning, and someone pastes a support ticket screenshot into a bot session — customer phone number, last four digits of an ID, the works. Your gateway is watching a dozen channels. A second later that message goes to the model, into the transcript, then through compaction summaries, the memory system, and the background review pass. By the time you think “that should never have left the building”, it already did.

That is the flaw in every after-the-fact guardrail: you can only delete data once the model has answered and the logs have landed. In v0.22.6, Hermes gave the pre_llm_call hook a new return value that moves the interception point to before the inference request is made — the turn ends there, with zero model calls.

What exactly gets stopped

pre_llm_call fires once per turn, after context compression and before the tool loop begins. Its original job was to put things into a turn: recalled memories, current git status, what day it is. You return a string or an object with a context key, and the text is appended to the user message (never the system prompt, so the prompt cache survives).

Now it can also refuse the turn:

{"action": "block", "message": "This message contains customer identity data and was blocked by policy. Please handle it in a 1:1 channel."}

According to the official docs, here is what happens next:

  • No inference request is made. The turn costs zero model calls.
  • message is shown to the user as the assistant reply and saved as the assistant reply, so the transcript keeps a normal user-then-assistant pair and --resume still works.
  • The result carries turn_exit_reason: "blocked_by_plugin_pre_llm_call", so you can tell “blocked” apart from “the model answered” in logs and results.
  • The first block wins; later callbacks cannot change the verdict. A block with no message is ignored.

What happens to the text you typed

This is the part that decides whether the feature is genuinely usable as a privacy guard.

The blocked prompt is stored in the transcript as a placeholder:

[Message withheld: a pre_llm_call policy hook blocked it.]

Everything that reads history later — subsequent turns, compaction summaries, session_search, external memory providers, the background memory review — reads that placeholder instead of the original. In other words, the content never reaches a model in this turn or any later one.

What you actually typed survives only as display metadata: the CLI --resume recap, the TUI, the desktop app and the dashboard still show it, so you can confirm afterwards what was asked.

Two side effects the docs also spell out: the hook runs before turn-start compaction, so a blocked turn skips compaction, session titling, and external-memory prefetch/sync. For a turn that did not happen, none of that work is worth doing anyway.

Writing one: a Python plugin or a shell hook

Python plugin (register it and it is live):

import re

CUSTOMER_ID = re.compile(r"\b(?:cust|acct)-[0-9]{6,}\b", re.I)

def policy(session_id, user_message, platform, **kwargs):
    text = user_message if isinstance(user_message, str) else ""
    if CUSTOMER_ID.search(text):
        return {
            "action": "block",
            "message": "This message contains a customer identifier and was blocked by policy. Handle it in the ticket system, or resend a redacted description.",
        }
    return None

def register(ctx):
    ctx.register_hook("pre_llm_call", policy)

Shell hook (in the profile’s config.yaml, no Python needed):

hooks:
  pre_llm_call:
    - command: "~/.hermes/agent-hooks/block-customer-ids.sh"
      timeout: 5

The script gets the event payload on stdin and prints one line of JSON to stdout. Both {"action": "block", "message": "..."} and the Claude Code style {"decision": "block", "reason": "..."} are accepted; print {} when nothing should be blocked.

Three boundaries you have to know

One: it fails open. If a callback raises, or runs past plugins.hook_callback_timeout (30 seconds by default, 0–600 configurable), Hermes logs it and skips it — the turn proceeds. Compare pre_tool_call, where a timeout fails closed and blocks the tool. The docs are explicit about the consequence: a policy hook should catch its own errors and return a block when it cannot decide, otherwise a broken script looks like a guardrail while blocking nothing.

Related: fail_closed: true currently applies only to pre_tool_call. Setting it on another event logs a warning at config-parse time and is ignored.

Two: exit code 2 is not a block here. That convention belongs to pre_tool_call alone. A pre_llm_call shell hook that exits with 2 is treated like any other non-zero exit: warn, then still parse stdout.

Three: this is an entrance filter, not a content filter. It sees this turn’s user_message and the conversation history — not which tools the model is about to call. Blocking tools is pre_tool_call’s job; judging what the model said is post_llm_call’s. You want both layers: the entrance stops what should never arrive, the tool layer stops dangerous actions.

When it is worth turning on

Three cases pay off immediately:

  • Privacy and compliance up front. Ticket IDs, national ID numbers, internal hostnames, customer emails: match and block, with a message that tells the person which channel to use instead.
  • A cost gate. When quota is nearly gone, or it is the middle of the night, refuse the requests that would drag in a huge retrieval instead of letting the model answer half of it and aborting.
  • Prompt-injection defence. Pasted web pages or emails carrying obvious injection patterns (“ignore all previous instructions”) get sent back before the model ever sees them.

The capability is in the v0.22.6 tag, so upgrading to that version is enough. The full event list, return contracts and timeout rules live in the official hooks documentation. If you want the distinction between hooks and middleware first, our plugin hooks and lifecycle guide is a good starting point, and for pause points that need a human, see the human-input hooks post.