Developer guide / Middleware & hooks

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.

csharp
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.

csharp
// 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:

ContextFiresLevers
MessageReceivedContextInbound human messageDrop to discard it
MessageSendingContextOutbound message to a channelSuppress to cancel send
SkillInvokingContextBefore a skill runsDenied + Reason
SkillInvokedContextAfter a skill runsObserve / adjust the result
ToolSetBuildingContextPer-turn tool-set assemblyAdd/remove exposed skills
SessionCompactingContextBefore compactionStop to veto
AgentMessageSendingContextPeer→peer sendInspect/edit the body
AgentMessageReceivedContextPeer→peer receiveInspect/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:

CoreToolsContributor.cscsharp
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
    }
}
A guard hook denies a skill like this: set 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.

csharp
// in Configure
ctx.Models.UseChatMiddleware(inner => new RedactSecretsChatClient(inner));

The stack wraps in order, outer → inner:

text
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.