Middleware & hooks
Two interception points: hooks wrap lifecycle events (messages, skills, tool-set assembly, compaction), and chat middleware wraps every model call. Both are ordered chains a plugin joins — ASP.NET-style.
Hooks
A hook is (ctx, next) middleware over a typed context. Mutate the context and call next to continue, or short-circuit by setting a flag and not calling it.
public interface IHook<TCtx>
{
Task<TCtx> Handle(TCtx ctx, Func<TCtx, Task<TCtx>> next);
}Registering a hook
Register in Configure, by instance or by DI type. Hooks run in registration order — first registered is outermost.
// in Configure ctx.Hooks.Use<SkillInvokingContext>(new AuditEverySkill()); // instance ctx.Hooks.Use<ToolSetBuildingContext, DiscoveryContributor>(); // DI-resolved type
The hook contexts
Every context derives from HookContext (which carries a Stop flag to short-circuit). The built-in ones:
| Context | Fires | Levers |
|---|---|---|
MessageReceivedContext | Inbound human message | Drop to discard it |
MessageSendingContext | Outbound message to a channel | Suppress to cancel send |
SkillInvokingContext | Before a skill runs | Denied + Reason |
SkillInvokedContext | After a skill runs | Observe / adjust the result |
ToolSetBuildingContext | Per-turn tool-set assembly | Add/remove exposed skills |
SessionCompactingContext | Before compaction | Stop to veto |
AgentMessageSendingContext | Peer→peer send | Inspect/edit the body |
AgentMessageReceivedContext | Peer→peer receive | Inspect/edit the body |
Example: a tool-set hook
This is the real hook that injects the always-on core skills into every turn before discovery:
using AgentParley.Abstractions.Hooks;
public sealed class CoreToolsContributor : IHook<ToolSetBuildingContext>
{
private static readonly HashSet<string> Core = new(StringComparer.OrdinalIgnoreCase)
{
"send_message", "ask_user", "read_file", "write_file", "edit_file", "shell",
"todo_write", "todo_update", "search_skills", "recall",
};
public Task<ToolSetBuildingContext> Handle(
ToolSetBuildingContext ctx, Func<ToolSetBuildingContext, Task<ToolSetBuildingContext>> next)
{
foreach (var manifest in ctx.Candidates.Where(c => Core.Contains(c.Name)))
ctx.Tools.Add(manifest);
return next(ctx); // continue the chain
}
}ctx.Denied = true and ctx.Reason = "..." on a SkillInvokingContext, then return without calling next.Chat-client middleware
Model calls run through a Microsoft.Extensions.AI pipeline. A plugin can insert a delegating IChatClient stage — for logging, caching, prompt rewriting, guardrails, anything.
// in Configure ctx.Models.UseChatMiddleware(inner => new RedactSecretsChatClient(inner));
The stack wraps in order, outer → inner:
OpenTelemetry → your plugin middleware → retry → raw provider
The first UseChatMiddleware registered is the outermost stage (closest to the caller); the raw provider (Anthropic, OpenAI, …) is innermost. Per-session metering rides on top of all of it. Implement a stage by deriving from DelegatingChatClient and overriding the call you care about.