Skip to content

Middleware Events

Emit events and build interactive middleware with request/response patterns.

Overview

Middleware can communicate via events:

  • One-way events - Fire-and-forget notifications
  • Request/response - Interactive workflows (permissions, approvals)
  • Typed events - Compile-time safety
  • Async responses - Wait for user input mid-execution

Quick Example

csharp
public class PermissionMiddleware : IAgentMiddleware
{
    public async Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
    {
        // Emit permission request
        var request = new PermissionRequestEvent
        {
            FunctionName = context.Function.Name,
            Arguments = context.Arguments,
            RequestId = Guid.NewGuid().ToString()
        };

        context.Emit(request);

        // Wait for user response
        var response = await context.WaitForResponseAsync<PermissionResponseEvent>(
            request.RequestId,
            ct
        );

        if (!response.Approved)
        {
            context.BlockExecution = true;
            context.OverrideResult = "User denied permission";
        }
    }
}

One-Way Events

Emitting Events

csharp
public Task BeforeIterationAsync(BeforeIterationContext context, CancellationToken ct)
{
    // Emit text for UI
    context.Emit(new TextDeltaEvent
    {
        Text = "🔍 Analyzing query..."
    });

    // Emit custom telemetry
    context.Emit(new CustomTelemetryEvent
    {
        MetricName = "iteration_started",
        Value = context.Iteration
    });

    return Task.CompletedTask;
}

Built-in Event Types

csharp
// Text output
context.Emit(new TextDeltaEvent { Text = "Processing..." });

// Tool call started
context.Emit(new ToolCallStartedEvent
{
    ToolName = "SearchWeb",
    CallId = callId
});

// Tool call completed
context.Emit(new ToolCallCompletedEvent
{
    ToolName = "SearchWeb",
    CallId = callId,
    Result = result
});

// Custom events
context.Emit(new MyCustomEvent { /* ... */ });

Request/Response Pattern

Step 1: Define Events

csharp
// Request event
public class PermissionRequestEvent : AgentEvent
{
    public required string FunctionName { get; init; }
    public required IReadOnlyDictionary<string, object?> Arguments { get; init; }
    public required string RequestId { get; init; }
}

// Response event
public class PermissionResponseEvent : AgentEvent
{
    public required string RequestId { get; init; }
    public required bool Approved { get; init; }
    public string? DenialReason { get; init; }
}

Step 2: Emit Request and Wait

csharp
public async Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
    var requestId = Guid.NewGuid().ToString();

    // Emit request
    var request = new PermissionRequestEvent
    {
        FunctionName = context.Function.Name,
        Arguments = context.Arguments,
        RequestId = requestId
    };

    context.Emit(request);

    // Wait for response (blocks middleware until user responds)
    var response = await context.WaitForResponseAsync<PermissionResponseEvent>(
        requestId,
        ct
    );

    // Act on response
    if (!response.Approved)
    {
        context.BlockExecution = true;
        context.OverrideResult = response.DenialReason ?? "Permission denied";
    }
}

Step 3: User Responds

In your UI/host code:

csharp
await foreach (var evt in agent.RunAsync("Search for flights", ct))
{
    if (evt is PermissionRequestEvent permReq)
    {
        // Show dialog to user
        var approved = await ShowPermissionDialog(permReq.FunctionName);

        // Send response back to middleware
        await agent.EmitEventAsync(new PermissionResponseEvent
        {
            RequestId = permReq.RequestId,
            Approved = approved,
            DenialReason = approved ? null : "User declined"
        });
    }
}

Timeout Handling

Always use CancellationToken with timeouts:

csharp
public async Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
    var requestId = Guid.NewGuid().ToString();

    context.Emit(new PermissionRequestEvent
    {
        FunctionName = context.Function.Name,
        RequestId = requestId
    });

    try
    {
        // Wait up to 30 seconds
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        cts.CancelAfter(TimeSpan.FromSeconds(30));

        var response = await context.WaitForResponseAsync<PermissionResponseEvent>(
            requestId,
            cts.Token
        );

        if (!response.Approved)
        {
            context.BlockExecution = true;
        }
    }
    catch (OperationCanceledException)
    {
        // Timeout - deny by default
        context.BlockExecution = true;
        context.OverrideResult = "Permission request timed out";
    }
}

Human-in-the-Loop Pattern

Complete example with UI integration:

Middleware:

csharp
public class HumanApprovalMiddleware : IAgentMiddleware
{
    public async Task BeforeParallelBatchAsync(
        BeforeParallelBatchContext context,
        CancellationToken ct)
    {
        var requestId = Guid.NewGuid().ToString();

        // Request batch approval
        context.Emit(new BatchApprovalRequestEvent
        {
            Functions = context.ParallelFunctions
                .Select(f => f.Name ?? "_unknown")
                .ToList(),
            RequestId = requestId
        });

        using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        cts.CancelAfter(TimeSpan.FromSeconds(60));

        try
        {
            var response = await context.WaitForResponseAsync<BatchApprovalResponseEvent>(
                requestId,
                cts.Token
            );

            // Store approvals in state for BeforeFunctionAsync to check
            context.UpdateState(s => s with
            {
                MiddlewareState = s.MiddlewareState.WithBatchApprovals(
                    response.ApprovedFunctions.ToHashSet()
                )
            });
        }
        catch (OperationCanceledException)
        {
            // Timeout - deny all
            context.UpdateState(s => s with
            {
                MiddlewareState = s.MiddlewareState.WithBatchApprovals(new HashSet<string>())
            });
        }
    }

    public Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
    {
        var isApproved = context.Analyze(s =>
            s.MiddlewareState.BatchApprovals?.Contains(context.Function.Name) ?? false
        );

        if (!isApproved)
        {
            context.BlockExecution = true;
            context.OverrideResult = "User did not approve this function";
        }

        return Task.CompletedTask;
    }
}

UI Code:

csharp
var agent = new AgentBuilder()
    .WithMiddleware(new HumanApprovalMiddleware())
    .Build();

await foreach (var evt in agent.RunAsync("Book a flight to NYC", ct))
{
    switch (evt)
    {
        case BatchApprovalRequestEvent req:
            // Show dialog
            var approved = await ShowBatchApprovalDialog(req.Functions);

            // Respond
            await agent.EmitEventAsync(new BatchApprovalResponseEvent
            {
                RequestId = req.RequestId,
                ApprovedFunctions = approved
            });
            break;

        case TextDeltaEvent text:
            Console.Write(text.Text);
            break;
    }
}

Progress Updates

Show progress during long operations:

csharp
public async Task<ModelResponse> WrapModelCallAsync(
    ModelRequest request,
    Func<ModelRequest, Task<ModelResponse>> handler,
    CancellationToken ct)
{
    context.Emit(new TextDeltaEvent { Text = "🤔 Thinking..." });

    var response = await handler(request);

    context.Emit(new TextDeltaEvent { Text = "  Response ready\n" });

    return response;
}

Custom Event Types

Define your own events:

csharp
public class TokenUsageEvent : AgentEvent
{
    public required int PromptTokens { get; init; }
    public required int CompletionTokens { get; init; }
    public required decimal Cost { get; init; }
}

public class AuditLogEvent : AgentEvent
{
    public required string Action { get; init; }
    public required string UserId { get; init; }
    public required DateTime Timestamp { get; init; }
}

Usage:

csharp
public async Task<ModelResponse> WrapModelCallAsync(
    ModelRequest request,
    Func<ModelRequest, Task<ModelResponse>> handler,
    CancellationToken ct)
{
    var startTime = DateTime.UtcNow;
    var response = await handler(request);
    var duration = DateTime.UtcNow - startTime;

    // Emit telemetry
    context.Emit(new TokenUsageEvent
    {
        PromptTokens = response.Usage?.PromptTokens ?? 0,
        CompletionTokens = response.Usage?.CompletionTokens ?? 0,
        Cost = CalculateCost(response.Usage)
    });

    // Emit audit log
    var userId = request.State.Analyze(s => s.UserId);
    context.Emit(new AuditLogEvent
    {
        Action = "llm_call",
        UserId = userId,
        Timestamp = DateTime.UtcNow
    });

    return response;
}

Multi-Step Workflows

Chain multiple request/response interactions:

csharp
public async Task BeforeFunctionAsync(BeforeFunctionContext context, CancellationToken ct)
{
    // Step 1: Request permission
    var permRequestId = Guid.NewGuid().ToString();
    context.Emit(new PermissionRequestEvent
    {
        FunctionName = context.Function.Name,
        RequestId = permRequestId
    });

    var permResponse = await context.WaitForResponseAsync<PermissionResponseEvent>(
        permRequestId,
        ct
    );

    if (!permResponse.Approved)
    {
        context.BlockExecution = true;
        return;
    }

    // Step 2: Request additional parameters (if needed)
    if (NeedsMoreInfo(context.Arguments))
    {
        var paramRequestId = Guid.NewGuid().ToString();
        context.Emit(new ParameterRequestEvent
        {
            FunctionName = context.Function.Name,
            MissingParameters = GetMissingParams(context.Arguments),
            RequestId = paramRequestId
        });

        var paramResponse = await context.WaitForResponseAsync<ParameterResponseEvent>(
            paramRequestId,
            ct
        );

        // Merge parameters
        MergeParameters(context.Arguments, paramResponse.Parameters);
    }

    // Function now has permission and all required parameters
}

Event Filtering

Filter events in your UI:

csharp
await foreach (var evt in agent.RunAsync("Search flights", ct))
{
    switch (evt)
    {
        // Only handle specific event types
        case TextDeltaEvent text:
            Console.Write(text.Text);
            break;

        case PermissionRequestEvent req:
            await HandlePermissionRequest(req);
            break;

        case TokenUsageEvent usage:
            UpdateCostDisplay(usage.Cost);
            break;

        // Ignore all other events
        default:
            break;
    }
}

Best Practices

1. Always Use Request IDs

csharp
//   GOOD: Unique request ID
var requestId = Guid.NewGuid().ToString();
context.Emit(new MyRequestEvent { RequestId = requestId });
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, ct);

//    BAD: Hardcoded or predictable ID
context.Emit(new MyRequestEvent { RequestId = "request1" });

2. Set Timeouts

csharp
//   GOOD: Timeout to prevent hanging
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30));

try
{
    var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, cts.Token);
}
catch (OperationCanceledException)
{
    // Handle timeout
}

//    BAD: No timeout
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, ct);

3. Handle Cancellation

csharp
//   GOOD: Graceful cancellation handling
try
{
    var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, ct);
    // Process response
}
catch (OperationCanceledException)
{
    context.BlockExecution = true;
    context.OverrideResult = "Operation cancelled";
}

//    BAD: Let exception propagate
var response = await context.WaitForResponseAsync<MyResponseEvent>(requestId, ct);

4. Validate Responses

csharp
//   GOOD: Validate response
var response = await context.WaitForResponseAsync<PermissionResponseEvent>(requestId, ct);

if (response == null || string.IsNullOrEmpty(response.RequestId))
{
    throw new InvalidOperationException("Invalid response received");
}

if (response.RequestId != requestId)
{
    throw new InvalidOperationException("Response ID mismatch");
}

//    BAD: Trust response blindly
var response = await context.WaitForResponseAsync<PermissionResponseEvent>(requestId, ct);
context.BlockExecution = !response.Approved;

Next Steps

Released under the MIT License.