Overview
dart pub add ag_uiag_ui.core
The Agent User Interaction Protocol Dart SDK uses a streaming event-based architecture with strongly typed data structures. This package provides the foundation for connecting to agent systems with full null safety and compile-time type checking.
Types
Core data structures that represent the building blocks of the system:
- RunAgentInput - Input parameters for running agents
- Message - User-assistant communication and tool usage
- Context - Contextual information provided to agents
- Tool - Defines functions that agents can call
- State - Agent state management
Complete documentation of all types in the ag_ui package
Events
Events that power communication between agents and frontends:
- Lifecycle Events - Run and step tracking
- Text Message Events - Assistant message streaming
- Tool Call Events - Function call lifecycle
- State Management Events - Agent state updates
- Special Events - Raw and custom events
Complete documentation of all events in the ag_ui package
Type System
The Dart SDK leverages Dart's strong type system for compile-time safety:
Pattern Matching
Use Dart's pattern matching for elegant event handling:
await for (final event in client.runAgent('agent', input)) {
switch (event) {
case RunStartedEvent(:final runId):
print('Run started: $runId');
case TextMessageDeltaEvent(:final delta, :final messageId):
print('Message $messageId: $delta');
case ToolCallStartedEvent(:final name, :final arguments):
print('Calling $name with $arguments');
case StateSnapshotEvent(:final state):
print('State updated: $state');
case RunFinishedEvent(:final error):
if (error != null) {
print('Run failed: $error');
}
}
}Sealed Classes
Events use sealed classes for exhaustive pattern matching:
sealed class BaseEvent {
final String type;
final DateTime timestamp;
const BaseEvent({
required this.type,
required this.timestamp,
});
}
// Compiler ensures all cases are handled
String describeEvent(BaseEvent event) {
return switch (event) {
RunStartedEvent() => 'Starting run',
RunFinishedEvent() => 'Finishing run',
TextMessageEvent() => 'Processing message',
ToolCallEvent() => 'Calling tool',
StateEvent() => 'Updating state',
// No default needed - compiler knows all cases
};
}Null Safety
Full null safety support with clear nullable types:
class RunAgentInput {
final List<Message> messages;
final Map<String, dynamic>? context; // Optional
final List<Tool>? tools; // Optional
final String? threadId; // Optional
const RunAgentInput({
required this.messages,
this.context,
this.tools,
this.threadId,
});
}Reactive Programming
Built on Dart's Stream API for reactive programming:
Stream Transformations
// Filter and transform events
final textStream = client
.runAgent('agent', input)
.whereType<TextMessageDeltaEvent>()
.map((event) => event.delta);
// Aggregate messages
final fullMessage = await textStream.join();Error Handling
final stream = client.runAgent('agent', input);
await for (final event in stream) {
try {
await processEvent(event);
} catch (e) {
// Handle individual event errors
print('Error processing ${event.type}: $e');
}
}Cancellation
// Create cancellable subscription
final subscription = stream.listen(
(event) => processEvent(event),
onError: (error) => handleError(error),
onDone: () => cleanup(),
cancelOnError: false,
);
// Cancel when needed
await subscription.cancel();Serialization
All types support JSON serialization:
// To JSON
final json = message.toJson();
// From JSON
final message = Message.fromJson(json);
// Custom serialization
class CustomContext {
final String id;
final Map<String, dynamic> data;
Map<String, dynamic> toJson() => {
'id': id,
'data': data,
};
factory CustomContext.fromJson(Map<String, dynamic> json) {
return CustomContext(
id: json['id'] as String,
data: Map<String, dynamic>.from(json['data']),
);
}
}Validation
Built-in validation for all inputs:
// Validates automatically
final input = RunAgentInput(
messages: messages,
tools: tools,
);
// Manual validation
try {
InputValidator.validate(input);
} on ValidationError catch (e) {
print('Invalid input: ${e.message}');
print('Failed fields: ${e.fields}');
}Best Practices
1. Use Pattern Matching
Prefer pattern matching over if-else chains:
// Good
switch (event) {
case TextMessageDeltaEvent(:final delta):
updateUI(delta);
}
// Less preferred
if (event is TextMessageDeltaEvent) {
updateUI(event.delta);
}2. Handle All Event Types
Always handle unexpected events:
await for (final event in stream) {
switch (event) {
// Handle known events...
case _:
// Log unexpected events
logger.debug('Unhandled event: ${event.type}');
}
}3. Use Type Guards
Create type-safe helper functions:
extension EventExtensions on Stream<BaseEvent> {
Stream<String> get textMessages =>
whereType<TextMessageDeltaEvent>()
.map((e) => e.delta);
Stream<ToolCall> get toolCalls =>
whereType<ToolCallStartedEvent>()
.map((e) => ToolCall(name: e.name, args: e.arguments));
}4. Immutable Data
Keep data structures immutable:
@immutable
class AppState {
final List<Message> messages;
final Map<String, dynamic> context;
const AppState({
required this.messages,
required this.context,
});
AppState copyWith({
List<Message>? messages,
Map<String, dynamic>? context,
}) {
return AppState(
messages: messages ?? this.messages,
context: context ?? this.context,
);
}
}