The short answer
Register a function invocation filter. It runs every time a Semantic Kernel function is invoked, sees the function and its arguments, and calls next to continue. If it doesn’t call next, the function doesn’t run; it can set the result itself instead.1 That’s a tool-call guardrail: refuse, or return “this needs a person,” before any side effect.
For agents that call functions automatically, the auto function invocation filter adds the chat history, the list of functions about to run and iteration counters, and can terminate the whole auto-calling loop.1
Deedglass
A tool call meets your rules before it runs
Asks to run
DELETE FROM visits;
Deletes every row in visits
Deedglass checks your rules
- Reading dataAllowed
- Deleting a whole table (DROP, TRUNCATE)Never
- Changing or deleting rows without saying which (WHERE)This one
- Other changesWait for a person
The three filter types
| Filter | Runs | Useful for |
|---|---|---|
| Function invocation | Every time a KernelFunction is invoked |
Checking arguments, overriding the result before or after, handling errors, retrying1 |
| Prompt render | Before a prompt is rendered (prompt functions only) | Viewing or changing the prompt; skipping the model call by setting the result1 |
| Auto function invocation | Only inside automatic function calling | Seeing the whole planned batch; terminating the loop1 |
Each filter gets a context and a next callback. The docs are explicit: calling next is essential to proceed, and without it the operation won’t run.1 In Python you register filters with kernel.add_filter(FilterTypes..., fn) or the @kernel.filter(...) decorator.1
A filter that holds large refunds
We installed semantic-kernel 1.45.0 in a scratch environment and gave a kernel one plugin function, billing.refund, which prints a line when it actually runs. Then a filter:
Code Program code, for developers. The text around it says what it does.
@kernel.filter(FilterTypes.FUNCTION_INVOCATION)
async def approval_gate(context: FunctionInvocationContext, next):
if context.function.name == "refund" and float(context.arguments["amount"]) > 100:
context.result = FunctionResult(function=context.function.metadata,
value="Refund over 100 needs a person. Not run.")
return # no next(): the function does not run
await next(context)
The filter reads the arguments the model chose, decides, and either continues or answers in the function’s place. The message becomes the function’s result, so a model in an auto-calling loop reads it as a tool result and can tell the user.
What we saw
We invoked the function directly with two amounts:
Code What the screen or file shows, copied as is.
[tool ran] refund ord_42 40.0
amount=40 result='refunded 40.0'
amount=250 result='Refund over 100 needs a person. Not run.'
The 40 refund ran: the [tool ran] line printed. The 250 refund didn’t: no [tool ran] line, and the result is the filter’s message. Nothing about the function changed; the rule lives entirely in the filter.
To turn “needs a person” into an actual approval, record the pending call (function, arguments, user) somewhere a reviewer sees it, and run the function from your own code once approved. A filter that blocks the model’s call is the gate; the approval flow is yours. The docs’ own example of filters in an approval flow is checking the permissions of the person submitting an approval before the flow begins.1
Using the auto function invocation filter
When an agent calls functions automatically, the model may plan several calls in one turn. The auto function invocation filter sees that whole batch, with the chat history and iteration counters, and can set terminate to stop the loop.1 Two guardrail uses follow:
- A budget per conversation. Count calls or iterations and terminate past a limit, so a confused agent can’t loop through a hundred refunds.
- A batch check. If the planned functions include a delete next to a lookup, stop and hand the turn back to the user before any of them runs.
Keep per-call argument checks in the function invocation filter, where every call passes regardless of how it was triggered, and use the auto filter for limits on the loop itself.
Test filters like code
Because filters are plain async functions, they’re easy to test. Invoke the function through the kernel, as we did above, with one case that should run and one that shouldn’t, and assert both the result and whether the function’s side effect happened. Our run’s [tool ran] line is that second check in its simplest form. Without it, a filter that sets a result after calling next looks like it blocked the call while the refund has already gone out.
Order matters
In Python, filters run in the order they’re added to the kernel, whether through add_filter or the decorator, nested around the function: the docs’ example prints “before filter 1, before filter 2, function, after filter 2, after filter 1.”1 In .NET, filters added through dependency injection run in no fixed order; add them to the kernel’s filter properties directly when order matters.1
For guardrails, that gives three rules:
- Blocking filters first. If a logging or caching filter runs before your guardrail and sets a result, your guardrail may never see the call.
- Logging outermost, if you want blocked calls logged. The outer filter’s “after” code runs whether or not inner filters called
next. - In .NET, don’t rely on dependency-injection order for anything security-relevant.1
Where filters don’t reach
- Calls that bypass the kernel. In .NET, when
IChatCompletionServiceis used directly, filters run only if aKernelis passed to the call, because filters are attached to the kernel instance.1 Code paths that call your plugin methods directly skip them too. - Streaming results. In streaming mode, an overriding result must also be a stream; the context has an
is_streamingflag to check.1 A guardrail that replaces results needs to handle both modes. - Other programs. Filters live in your process. Another agent or script calling the same API isn’t under them. A check in front of the tool itself, as a tool-call policy at a gateway, covers every caller.
A checklist
- A function invocation filter checks every function that writes, sends or spends.
- It refuses by not calling
nextand sets a clear result for the model. - Guardrail filters are added first; logging wraps them.
- In .NET, guardrails are added to kernel properties, not only through dependency injection.
- Direct
IChatCompletionServicecalls pass the kernel. - Streaming and non-streaming results both handled.
- Held calls are recorded for a human-in-the-loop approval, and run by your code once approved.
For comparable hooks in other frameworks, see Google ADK before_tool_callback and CrewAI tool call hooks. To see what the agents configured on your machine can change, paste the config into the check page or run npx deedglass scan (how the grade works); to try rules in your browser, open the playground.
Written with AI assistance and checked against our claims register.