Human-in-the-Loop for Microsoft Semantic Kernel Agents
Add a human approval gate to Semantic Kernel function calling so a plugin can't send, post, or spend money until a person taps approve on the actual generated payload.
Where the gap is in Semantic Kernel
Semantic Kernel's FunctionInvocationFilter gives you a hook that runs before and after any plugin function the kernel decides to call. That's the right layer to intercept — but "intercept" usually means either an in-process Console.ReadLine() prompt (useless once the agent runs unattended on a server) or nothing at all. Neither gets you a real approval record, a mobile-friendly approve/reject, or the ability to let a reviewer edit the draft before it goes out.
Impri fills that specific gap: it's a gate that stores the proposed function call, notifies a human on their phone or desktop, and holds the decision — nothing more. Semantic Kernel still owns planning and function orchestration; Impri only owns the yes/no.
Wiring the filter
Register an IFunctionInvocationFilter that intercepts sensitive plugin functions — here, an EmailPlugin.SendEmail function — and routes the call through Impri instead of letting it execute directly.
public class ImpriApprovalFilter : IFunctionInvocationFilter
{
private readonly HttpClient _http;
private static readonly HashSet<string> GatedFunctions = new() { "SendEmail", "PostToSlack" };
public ImpriApprovalFilter(HttpClient http) => _http = http;
public async Task OnFunctionInvocationAsync(
FunctionInvocationContext context, Func<FunctionInvocationContext, Task> next)
{
if (!GatedFunctions.Contains(context.Function.Name))
{
await next(context);
return;
}
var body = string.Join("\n", context.Arguments.Select(a => $"{a.Key}: {a.Value}"));
var createResp = await _http.PostAsJsonAsync("https://api.impri.dev/v1/actions", new
{
kind = "email.send",
title = $"Semantic Kernel: {context.Function.Name}",
preview = new { format = "markdown", body },
expires_in = 3600,
editable = new[] { "preview.body" }
});
var action = await createResp.Content.ReadFromJsonAsync<ImpriAction>();
ImpriDecision decision;
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(10));
var poll = await _http.GetFromJsonAsync<ImpriDecision>(
$"https://api.impri.dev/v1/actions/{action.Id}");
if (poll.Status != "pending") { decision = poll; break; }
}
if (decision.Status != "approved")
{
context.Result = new FunctionResult(context.Function, $"Not sent: {decision.Status}");
return; // next() is never called — SendEmail never runs
}
// Swap in the human-edited body before the real function runs
context.Arguments["body"] = decision.Decision.FinalPreview.Body;
await next(context);
await _http.PostAsJsonAsync(
$"https://api.impri.dev/v1/actions/{action.Id}/result", new { status = "executed" });
}
}Register it on the kernel:
var kernel = Kernel.CreateBuilder()
.AddOpenAIChatCompletion(modelId, apiKey)
.Build();
kernel.FunctionInvocationFilters.Add(new ImpriApprovalFilter(httpClient));
kernel.ImportPluginFromType<EmailPlugin>();The critical line is if (decision.Status != "approved") { ...; return; } — the filter's next(context) delegate is what actually invokes EmailPlugin.SendEmail, and it's simply never called on rejection or expiry. There's no code path where a non-approved call reaches the plugin.
Why the filter, not the planner
You could try gating at the planner level instead — inspecting the plan before the kernel executes any step. That works for stopping a whole plan, but it loses granularity: a plan might legitimately read a calendar, summarize a document, and send an email, and you only want to gate the last one. The FunctionInvocationFilter runs per-function-call, so read-only plugins run freely and only the ones you list in GatedFunctions pause for a human.
This is also why Impri only works as a real gate if EmailPlugin (or whatever wraps the actual side effect) has no other way to fire — if the kernel or a separate service account can call the mail API directly outside this filter, the agent has a bypass. See the Semantic Kernel integration notes for wrapping the underlying client so the filter is the only path.
What you get, and what you don't
| Capability | Provided by |
|---|---|
| Deciding whether the plan should include sending an email | Semantic Kernel planner — Impri has no opinion here |
| Holding execution until a human decides | Impri |
| Letting the reviewer edit the email body before it sends | Impri (editable + final_preview) |
| Audit record of who approved what, when | Impri |
| Retrying or branching the plan on rejection | Your filter's else branch — Impri just reports the status |
Impri doesn't understand Semantic Kernel plans, functions, or plugins — it sees a title, a markdown preview, and a decision. All the C#-specific wiring above is yours to write; Impri's job stops at { status: "approved" | "rejected" | "expired" }.
Next step
Start with the quickstart to get an API key, then read the MCP integration if you'd rather have Semantic Kernel call impri_push_action as a plugin function directly instead of hand-rolling the HTTP calls above.