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:
MessageTurnFinishedEvent← Use this for UI state!
- Starts:
Agent Turn: A single LLM API call (there can be many per message turn)
- Starts:
AgentTurnStartedEvent - Ends:
AgentTurnFinishedEvent← Usually ignore these
- Starts:
** 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:
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 beginsMessageTurnFinishedEvent- Agent is done ← Use this!MessageTurnErrorEvent- Unrecoverable error occurredAgentTurnStartedEvent- 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 contentReasoningMessageStartEvent- Reasoning beginsReasoningMessageEndEvent- 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:
PermissionRequestEvent→PermissionResponseEvent- Ask user to approve tool executionClarificationRequestEvent→ClarificationResponseEvent- Ask user for more infoClientToolInvokeRequestEvent→ClientToolInvokeResponseEvent- 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
IObservabilityEventmarker 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:
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:
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:
| Property | Format | Scope | Description |
|---|---|---|---|
TraceId | 32 hex chars (128-bit) | Message turn | Shared across all events in a single RunAsync call. Use this to group everything that happened in one user interaction. |
SpanId | 16 hex chars (64-bit) | Per event | Unique ID for this event within the trace. Use as the span ID when exporting. |
ParentSpanId | 16 hex chars (64-bit) | Per event | The 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=001All 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
- Using
AgentTurnFinishedEventinstead ofMessageTurnFinishedEvent
- Result: UI shows "done" while agent is still working
- Fix: Always use
MessageTurnFinishedEventfor UI state
- Handling
PermissionRequestEventbut not callingSendMiddlewareResponse()
- Result: Agent hangs for ~30 seconds until timeout
- Fix: Always call
SendMiddlewareResponse()for bidirectional events
- Forgetting to handle
MessageTurnFinishedEvent
- Result: Loading spinner never stops, input stays disabled
- Fix: Always handle
MessageTurnFinishedEventto 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:
await foreach (var evt in agent.RunAsync("Hello", branch)) { }
await agent.FlushObserversAsync(); // Wait for all observers to drainBuilt-in Observers
OpenTelemetry Tracing
TracingObserver exports structured traces to any OTel-compatible backend (Jaeger, Zipkin, Tempo, Azure Monitor, etc.). Register it with:
var agent = await new AgentBuilder()
.WithTracing() // sourceName = "HPD.Agent", redaction enabled by default
.BuildAsync();Configure the OTLP exporter in your host:
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 invocationNon-structural events are attached as ActivityEvents on the iteration span: agent.decision, permission.request/approved/denied, circuit_breaker.triggered, tool.retry, model.retry.
Configuration:
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
- Event Types Reference - Complete listing of all 50+ event types
- Consuming Events - Advanced patterns, filtering, error handling
- SubAgent Events - Nested agents, filtering by depth
- Streaming & Cancellation - Interruption, graceful shutdown, priority channels
- Bidirectional Events - Request/response patterns in depth
- Custom Events - Creating your own event types
Getting Started
- Event Handling - Quick start guide
- Building Console Apps - Console patterns
- Building Web Apps - SSE streaming setup