Skip to content

Events Overview

Understanding the event lifecycle and when the agent is done

Every agent interaction emits a stream of events that describe exactly what's happening: when the agent starts thinking, calls tools, generates text, and finishes. Understanding this event system is essential for building responsive applications.

The Two-Level Turn Hierarchy

** CRITICAL CONCEPT:** There are TWO levels of turns, and knowing the difference is essential for building correct UIs.

Message Turn vs Agent Turn

MESSAGE TURN (entire user interaction)
├── MessageTurnStartedEvent
├── User: "What's the weather in Paris?"

├── AGENT TURN 1 (first LLM call)
│   ├── AgentTurnStartedEvent
│   ├── LLM responds: "I'll check the weather"
│   ├── ToolCallStartEvent: get_weather
│   └── AgentTurnFinishedEvent

├── Tool executes: get_weather("Paris") → "22°C, sunny"
│   └── ToolCallResultEvent

├── AGENT TURN 2 (second LLM call)
│   ├── AgentTurnStartedEvent
│   ├── TextDeltaEvent: "The"
│   ├── TextDeltaEvent: " weather"
│   ├── TextDeltaEvent: " is 22°C and sunny"
│   └── AgentTurnFinishedEvent

└── MessageTurnFinishedEvent ← THIS is when the agent is DONE!

Key Insights

  • Message Turn: The ENTIRE conversation from user input to final response

    • Starts: MessageTurnStartedEvent
    • Ends: MessageTurnFinishedEventUse this for UI state!
  • Agent Turn: A single LLM API call (there can be many per message turn)

    • Starts: AgentTurnStartedEvent
    • Ends: AgentTurnFinishedEventUsually ignore these

** Common Mistake:** Using AgentTurnFinishedEvent to stop loading spinner causes the UI to show "done" too early while the agent is still working!

** Correct:** Always use MessageTurnFinishedEvent to know when the agent is completely finished.

Event Flow

All events flow through the Agent.RunAsync() async stream:

csharp
await foreach (var evt in agent.RunAsync(messages))
{
    // Events arrive here in real-time
    // Process them with pattern matching
}

The stream delivers events in chronological order as they occur, enabling real-time UI updates.

Event Categories

Events are organized into these categories:

1. Turn Lifecycle Events

Control the conversation flow:

  • MessageTurnStartedEvent - User message processing begins
  • MessageTurnFinishedEvent - Agent is done ← Use this!
  • MessageTurnErrorEvent - Unrecoverable error occurred
  • AgentTurnStartedEvent - Internal LLM call begins (usually ignore)
  • AgentTurnFinishedEvent - Internal LLM call ends (usually ignore)

2. Content Events

The agent's text response:

  • TextDeltaEvent - Streaming text chunks (accumulate these to build the response)
  • TextMessageStartEvent - Message boundary (optional, nice for polish)
  • TextMessageEndEvent - Message boundary (optional, nice for polish)

3. Reasoning Events

Extended thinking (Claude's internal reasoning):

  • ReasoningDeltaEvent - Streaming reasoning content
  • ReasoningMessageStartEvent - Reasoning begins
  • ReasoningMessageEndEvent - Reasoning ends

4. Tool Events

When the agent calls functions:

  • ToolCallStartEvent - Tool invocation begins (show "Calling calculator..." in UI)
  • ToolCallResultEvent - Tool execution complete (show result or error)
  • ToolCallArgsEvent - Streaming tool arguments (advanced)
  • ToolCallEndEvent - Arguments complete (advanced)

5. Bidirectional Events

Events that require a response from the user:

  • PermissionRequestEventPermissionResponseEvent - Ask user to approve tool execution
  • ClarificationRequestEventClarificationResponseEvent - Ask user for more info
  • ClientToolInvokeRequestEventClientToolInvokeResponseEvent - Execute tool on client

** CRITICAL:** These events require calling agent.SendMiddlewareResponse() or the agent will hang until timeout!

6. Observability Events

Internal diagnostics (filter these out!):

  • 25+ internal events like MiddlewareProgressEvent, CircuitBreakerTriggeredEvent, etc.
  • All implement IObservabilityEvent marker interface
  • Always filter these out in user-facing code to prevent console spam

Basic Event Handling Pattern

Here's the fundamental pattern every application needs:

csharp
await foreach (var evt in agent.RunAsync(messages))
{
    switch (evt)
    {
        // Content streaming
        case TextDeltaEvent delta:
            Console.Write(delta.Text);
            break;

        // Reasoning
        case ReasoningDeltaEvent reasoning:
            Console.Write($"[Thinking: {reasoning.Text}]");
            break;

        // Tool execution
        case ToolCallStartEvent toolStart:
            Console.WriteLine($"\n[Calling: {toolStart.ToolName}]");
            break;

        case ToolCallResultEvent toolResult:
            Console.WriteLine($"[Result: {toolResult.Result}]");
            break;

        //   THIS is when to stop your loading spinner!
        case MessageTurnFinishedEvent:
            Console.WriteLine("\n✓ Agent finished");
            // In a web UI: setIsLoading(false), enableInput()
            break;

        // Error handling
        case MessageTurnErrorEvent error:
            Console.WriteLine($"\n✗ Error: {error.ErrorMessage}");
            break;

        // Permissions (requires SendMiddlewareResponse!)
        case PermissionRequestEvent permission:
            var approved = PromptUser($"Allow {permission.FunctionName}?");

            // THIS LINE IS MANDATORY - don't forget it!
            agent.SendMiddlewareResponse(permission.PermissionId,
                new PermissionResponseEvent
                {
                    PermissionId = permission.PermissionId,
                    Approved = approved
                });
            break;
    }
}

Event Properties

All events inherit from AgentEvent and share these core properties:

csharp
public abstract record AgentEvent
{
    // Event routing
    public EventPriority Priority { get; init; }         // Immediate/Control/Normal/Background
    public EventDirection Direction { get; init; }       // Downstream/Upstream
    public string? StreamId { get; init; }               // For stream interruption

    // Nested agent tracking
    public AgentExecutionContext? ExecutionContext { get; init; }

    // OpenTelemetry correlation IDs (see below)
    public string? TraceId { get; init; }
    public string? SpanId { get; init; }
    public string? ParentSpanId { get; init; }

    // Stream control
    public bool CanInterrupt { get; init; }              // Can be dropped on cancellation
    public long SequenceNumber { get; internal set; }    // Order of emission
}

Most applications only need to care about the event type itself. Advanced scenarios may use:

  • ExecutionContext - Filter events from nested agents (see SubAgent Events)
  • Priority - Event routing in advanced streaming setups (see Streaming & Cancellation)
  • TraceId / SpanId / ParentSpanId - Correlate events with distributed traces (see below)

OpenTelemetry Correlation IDs on AgentEvent

Every AgentEvent carries three OpenTelemetry-compatible IDs. These let you correlate events with distributed traces without running a full OTel pipeline:

PropertyFormatScopeDescription
TraceId32 hex chars (128-bit)Message turnShared across all events in a single RunAsync call. Use this to group everything that happened in one user interaction.
SpanId16 hex chars (64-bit)Per eventUnique ID for this event within the trace. Use as the span ID when exporting.
ParentSpanId16 hex chars (64-bit)Per eventThe SpanId of the parent event. null on root events (MessageTurnStartedEvent). Use this to reconstruct the parent-child span tree.

What they mean together:

MessageTurnStartedEvent    TraceId=abc  SpanId=001  ParentSpanId=null   ← root
├── AgentTurnStartedEvent  TraceId=abc  SpanId=002  ParentSpanId=001
│   ├── ToolCallStartEvent TraceId=abc  SpanId=003  ParentSpanId=002
│   └── AgentTurnFinished  TraceId=abc  SpanId=004  ParentSpanId=001
└── MessageTurnFinished    TraceId=abc  SpanId=005  ParentSpanId=001

All events in a turn share the same TraceId. ParentSpanId lets you link child events back to their parent without any external context propagation.

Practical use: If you're forwarding events to a logging system or custom trace exporter, use TraceId as the correlation key and SpanId/ParentSpanId to build the call tree.

If you want fully automatic OTel export to Jaeger, Zipkin, or Azure Monitor, use WithTracing() instead — the TracingObserver reads these same IDs and builds the spans for you (see Built-in Observers below).

Common Beginner Mistakes

  1. Using AgentTurnFinishedEvent instead of MessageTurnFinishedEvent
  • Result: UI shows "done" while agent is still working
  • Fix: Always use MessageTurnFinishedEvent for UI state
  1. Handling PermissionRequestEvent but not calling SendMiddlewareResponse()
  • Result: Agent hangs for ~30 seconds until timeout
  • Fix: Always call SendMiddlewareResponse() for bidirectional events
  1. Forgetting to handle MessageTurnFinishedEvent
  • Result: Loading spinner never stops, input stays disabled
  • Fix: Always handle MessageTurnFinishedEvent to update UI state

Observers

Observers (IAgentEventObserver) receive every event after it's emitted, running in the background without blocking the event stream. They're the right place for side-effect logic like tracing, logging, and metrics.

ObserverDispatcher

Internally, each observer is wrapped in an ObserverDispatcher — a dedicated per-observer FIFO channel that processes events sequentially. This guarantees:

  • Ordered delivery — events are processed in emission order, no race conditions
  • Non-blocking — the agent never waits for observers; events are enqueued and processed asynchronously
  • Isolation — a slow or failing observer doesn't affect others or the agent

You don't interact with ObserverDispatcher directly. Register observers via AgentBuilder, then call FlushObserversAsync() to wait for all pending events to finish processing:

csharp
await foreach (var evt in agent.RunAsync("Hello", branch)) { }
await agent.FlushObserversAsync();  // Wait for all observers to drain

Built-in Observers

OpenTelemetry Tracing

TracingObserver exports structured traces to any OTel-compatible backend (Jaeger, Zipkin, Tempo, Azure Monitor, etc.). Register it with:

csharp
var agent = await new AgentBuilder()
    .WithTracing()  // sourceName = "HPD.Agent", redaction enabled by default
    .BuildAsync();

Configure the OTLP exporter in your host:

csharp
builder.Services.AddOpenTelemetry()
    .WithTracing(t => t
        .AddSource("HPD.Agent")
        .AddOtlpExporter());

Span hierarchy — each RunAsync call produces a three-level tree:

agent.turn           ← one per RunAsync call
└── agent.iteration  ← one per LLM API call
    └── agent.tool_call  ← one per tool invocation

Non-structural events are attached as ActivityEvents on the iteration span: agent.decision, permission.request/approved/denied, circuit_breaker.triggered, tool.retry, model.retry.

Configuration:

csharp
var agent = await new AgentBuilder()
    .WithTracing(
        sourceName: "MyApp.Agent",       // ActivitySource name (default: "HPD.Agent")
        sanitizerOptions: new SpanSanitizerOptions
        {
            MaxStringLength = 2048,      // Truncate payloads (default: 4096)
            EnableRedaction = true       // Redact sensitive fields (default: true)
        })
    .BuildAsync();

Sensitive JSON fields (password, token, secret, apikey, authorization, jwt, credential, privatekey, etc.) are automatically redacted before export.


What's Next

This overview covers the core concepts. For detailed guides:

Event Documentation

Getting Started

Released under the MIT License.