This guide shows how to adapt the AccedeSimple travel expense application to use AG-UI's multi-agent subgraph pattern, inspired by the LangGraph subgraphs demo.
Requirements: .NET 10 SDK, Python 3.12+, Node.js
UserMessage β TravelPlanning β UserSelection β TripRequestCreation β AdminApproval β ApprovalResponse
SupervisorAgent
βββ PolicyCheckAgent (checks expense policies)
βββ ReceiptAnalysisAgent (validates receipts)
βββ ApprovalRoutingAgent (routes to appropriate approver)
Create three new executors for expense approval workflow:
namespace AccedeSimple.Service.Executors;
public class PolicyCheckExecutor(
ILogger<PolicyCheckExecutor> logger,
IChatClient chatClient,
SearchService searchService,
MessageService messageService,
IOptions<UserSettings> userSettings)
: Executor<ExpenseSubmission, PolicyCheckResult>("PolicyCheckExecutor")
{
public override async ValueTask<PolicyCheckResult> HandleAsync(
ExpenseSubmission input,
IWorkflowContext context,
CancellationToken cancellationToken)
{
// Emit AG-UI event: Agent started
await messageService.AddMessageAsync(
new AgentStatusUpdate("Policy Check Agent", "active", "Checking expense against company policies..."),
userSettings.Value.UserId
);
// Search policy documents
var policyQuery = $"expense policy for {input.Category} amount {input.Amount}";
var policyResults = await searchService.SearchAsync(policyQuery);
// Use LLM to analyze against policy
var prompt = $$$"""
Analyze this expense submission against company policies:
Expense Details:
- Category: {{{input.Category}}}
- Amount: ${{{input.Amount}}}
- Date: {{{input.Date}}}
- Description: {{{input.Description}}}
Policy Documents:
{{{string.Join("\n", policyResults)}}}
Determine:
1. Is this expense within policy limits?
2. Are there any policy violations?
3. What's the risk level (low/medium/high)?
""";
var response = await chatClient.GetResponseAsync<PolicyCheckResult>(
prompt,
cancellationToken: cancellationToken
);
response.TryGetResult(out var result);
// Emit AG-UI event: Policy check complete
await messageService.AddMessageAsync(
new PolicyCheckUpdate(result),
userSettings.Value.UserId
);
// Store in workflow state
await context.QueueStateUpdateAsync("policy-check", result, "expense-approval", cancellationToken);
return result;
}
}public class ReceiptAnalysisExecutor(
ILogger<ReceiptAnalysisExecutor> logger,
IChatClient chatClient,
MessageService messageService,
IOptions<UserSettings> userSettings)
: Executor<ExpenseSubmission, ReceiptAnalysisResult>("ReceiptAnalysisExecutor")
{
public override async ValueTask<ReceiptAnalysisResult> HandleAsync(
ExpenseSubmission input,
IWorkflowContext context,
CancellationToken cancellationToken)
{
await messageService.AddMessageAsync(
new AgentStatusUpdate("Receipt Analysis Agent", "active", "Validating receipt data..."),
userSettings.Value.UserId
);
// Analyze receipt attachments
var receiptData = input.Attachments.FirstOrDefault();
if (receiptData == null)
{
return new ReceiptAnalysisResult { IsValid = false, Issues = ["No receipt attached"] };
}
var prompt = $$$"""
Analyze this receipt and verify it matches the expense claim:
Claimed Amount: ${{{input.Amount}}}
Claimed Date: {{{input.Date}}}
Claimed Vendor: {{{input.Vendor}}}
Extract from the receipt:
- Actual amount charged
- Transaction date
- Merchant name
- Line items
Check for:
- Discrepancies between claimed and actual amounts
- Date mismatches
- Signs of alteration or fraud
""";
var messages = new List<ChatMessage>
{
new ChatMessage(ChatRole.User, [
new TextContent(prompt),
new UriContent(receiptData.Uri, receiptData.ContentType)
])
};
var response = await chatClient.GetResponseAsync<ReceiptAnalysisResult>(
messages,
cancellationToken: cancellationToken
);
response.TryGetResult(out var result);
await messageService.AddMessageAsync(
new ReceiptAnalysisUpdate(result),
userSettings.Value.UserId
);
await context.QueueStateUpdateAsync("receipt-analysis", result, "expense-approval", cancellationToken);
return result;
}
}public class ApprovalRoutingExecutor(
ILogger<ApprovalRoutingExecutor> logger,
IChatClient chatClient,
MessageService messageService,
IOptions<UserSettings> userSettings)
: Executor<ExpenseApprovalContext, ApprovalRoute>("ApprovalRoutingExecutor")
{
public override async ValueTask<ApprovalRoute> HandleAsync(
ExpenseApprovalContext input,
IWorkflowContext context,
CancellationToken cancellationToken)
{
await messageService.AddMessageAsync(
new AgentStatusUpdate("Routing Agent", "active", "Determining approval path..."),
userSettings.Value.UserId
);
// Determine routing based on policy check and receipt analysis
var route = new ApprovalRoute
{
RequiresManagerApproval = input.PolicyCheck.RiskLevel != "low" || input.Amount > 500,
RequiresFinanceApproval = input.Amount > 1000,
RequiresCEOApproval = input.Amount > 5000,
AutoApprove = input.PolicyCheck.IsCompliant &&
input.ReceiptAnalysis.IsValid &&
input.Amount < 100
};
await messageService.AddMessageAsync(
new ApprovalRouteUpdate(route),
userSettings.Value.UserId
);
return route;
}
}public class ExpenseApprovalSupervisorExecutor(
ILogger<ExpenseApprovalSupervisorExecutor> logger,
PolicyCheckExecutor policyCheckExecutor,
ReceiptAnalysisExecutor receiptAnalysisExecutor,
ApprovalRoutingExecutor approvalRoutingExecutor,
MessageService messageService,
IOptions<UserSettings> userSettings)
: Executor<ExpenseSubmission, ExpenseApprovalDecision>("SupervisorExecutor")
{
public override async ValueTask<ExpenseApprovalDecision> HandleAsync(
ExpenseSubmission input,
IWorkflowContext context,
CancellationToken cancellationToken)
{
// Emit supervisor status
await messageService.AddMessageAsync(
new AgentStatusUpdate("Supervisor", "active", "Coordinating expense approval workflow..."),
userSettings.Value.UserId
);
// Run policy check and receipt analysis in parallel
var policyTask = policyCheckExecutor.HandleAsync(input, context, cancellationToken);
var receiptTask = receiptAnalysisExecutor.HandleAsync(input, context, cancellationToken);
await Task.WhenAll(policyTask.AsTask(), receiptTask.AsTask());
var policyResult = await policyTask;
var receiptResult = await receiptTask;
// Determine routing
var approvalContext = new ExpenseApprovalContext
{
Expense = input,
PolicyCheck = policyResult,
ReceiptAnalysis = receiptResult,
Amount = input.Amount
};
var route = await approvalRoutingExecutor.HandleAsync(approvalContext, context, cancellationToken);
// Build final decision
var decision = new ExpenseApprovalDecision
{
ExpenseId = input.Id,
AutoApproved = route.AutoApprove,
RequiresHumanApproval = !route.AutoApprove,
PolicyCheckResult = policyResult,
ReceiptAnalysisResult = receiptResult,
ApprovalRoute = route,
Timestamp = DateTime.UtcNow
};
// Emit final supervisor update
await messageService.AddMessageAsync(
new SupervisorDecisionUpdate(decision),
userSettings.Value.UserId
);
return decision;
}
}public static IServiceCollection AddExpenseApprovalWorkflow(
this IServiceCollection services)
{
// Register all executors
services.AddTransient<PolicyCheckExecutor>();
services.AddTransient<ReceiptAnalysisExecutor>();
services.AddTransient<ApprovalRoutingExecutor>();
services.AddTransient<ExpenseApprovalSupervisorExecutor>();
services.AddTransient<HumanApprovalExecutor>(); // For HITL
services.AddTransient<Microsoft.Agents.AI.Workflows.Workflow>(sp =>
{
var supervisor = sp.GetRequiredService<ExpenseApprovalSupervisorExecutor>();
var humanApproval = sp.GetRequiredService<HumanApprovalExecutor>();
// Create HITL port for cases requiring human approval
var humanApprovalPort = RequestPort.Create<ExpenseApprovalDecision, ApprovalResult>("HumanApproval");
// Build workflow with conditional routing
var workflow = new WorkflowBuilder(supervisor)
.AddEdge(supervisor, humanApprovalPort,
decision => decision.RequiresHumanApproval) // Only if needs approval
.AddEdge(humanApprovalPort, humanApproval)
.WithOutputFrom(humanApproval)
.Build();
return workflow;
});
return services;
}// Domain/AgentStatus.cs
public record AgentStatusUpdate(
string AgentName,
string Status, // "active", "complete", "error"
string Message
) : ChatItem
{
public override string Type => "agent-status";
public override string Role => "system";
public override bool IsUserVisible => true;
}
// Domain/PolicyCheckResult.cs
public record PolicyCheckResult
{
public bool IsCompliant { get; init; }
public string RiskLevel { get; init; } = "low"; // low, medium, high
public List<string> Violations { get; init; } = [];
public List<string> Warnings { get; init; } = [];
public string PolicySummary { get; init; } = "";
}
public record PolicyCheckUpdate(PolicyCheckResult Result) : ChatItem
{
public override string Type => "policy-check-result";
public override string Role => "assistant";
public override bool IsUserVisible => true;
public override string Text => $"Policy check complete: {Result.IsCompliant ? "β
Compliant" : "β Issues found"}";
}
// Domain/ReceiptAnalysisResult.cs
public record ReceiptAnalysisResult
{
public bool IsValid { get; init; }
public decimal ExtractedAmount { get; init; }
public DateTime ExtractedDate { get; init; }
public string ExtractedVendor { get; init; } = "";
public List<string> Issues { get; init; } = [];
public double ConfidenceScore { get; init; }
}
public record ReceiptAnalysisUpdate(ReceiptAnalysisResult Result) : ChatItem
{
public override string Type => "receipt-analysis-result";
public override string Role => "assistant";
public override bool IsUserVisible => true;
}
// Domain/ApprovalRoute.cs
public record ApprovalRoute
{
public bool AutoApprove { get; init; }
public bool RequiresManagerApproval { get; init; }
public bool RequiresFinanceApproval { get; init; }
public bool RequiresCEOApproval { get; init; }
public List<string> ApprovalChain { get; init; } = [];
}
public record ApprovalRouteUpdate(ApprovalRoute Route) : ChatItem
{
public override string Type => "approval-route";
public override string Role => "assistant";
public override bool IsUserVisible => true;
}
// Domain/ExpenseSubmission.cs
public record ExpenseSubmission
{
public string Id { get; init; } = Guid.NewGuid().ToString();
public string UserId { get; init; } = "";
public ExpenseCategory Category { get; init; }
public decimal Amount { get; init; }
public DateTime Date { get; init; }
public string Description { get; init; } = "";
public string Vendor { get; init; } = "";
public List<UriAttachment> Attachments { get; init; } = [];
}
public record ExpenseApprovalDecision
{
public string ExpenseId { get; init; } = "";
public bool AutoApproved { get; init; }
public bool RequiresHumanApproval { get; init; }
public PolicyCheckResult PolicyCheckResult { get; init; }
public ReceiptAnalysisResult ReceiptAnalysisResult { get; init; }
public ApprovalRoute ApprovalRoute { get; init; }
public DateTime Timestamp { get; init; }
}
public record SupervisorDecisionUpdate(ExpenseApprovalDecision Decision) : ChatItem
{
public override string Type => "supervisor-decision";
public override string Role => "assistant";
public override bool IsUserVisible => true;
}import React from 'react';
interface Agent {
name: string;
status: 'idle' | 'active' | 'complete' | 'error';
message?: string;
}
interface ExpenseApprovalPanelProps {
currentAgent: string;
policyCheck?: PolicyCheckResult;
receiptAnalysis?: ReceiptAnalysisResult;
approvalRoute?: ApprovalRoute;
}
export const ExpenseApprovalPanel: React.FC<ExpenseApprovalPanelProps> = ({
currentAgent,
policyCheck,
receiptAnalysis,
approvalRoute
}) => {
const agents: Agent[] = [
{
name: 'π¨βπΌ Supervisor',
status: currentAgent === 'Supervisor' ? 'active' : 'complete'
},
{
name: 'π Policy Check',
status: policyCheck
? 'complete'
: (currentAgent === 'Policy Check Agent' ? 'active' : 'idle')
},
{
name: 'π§Ύ Receipt Analysis',
status: receiptAnalysis
? 'complete'
: (currentAgent === 'Receipt Analysis Agent' ? 'active' : 'idle')
},
{
name: 'π― Routing',
status: approvalRoute
? 'complete'
: (currentAgent === 'Routing Agent' ? 'active' : 'idle')
}
];
return (
<div className="expense-approval-panel">
<h3>Expense Approval Process</h3>
<div className="agent-pipeline">
{agents.map(agent => (
<div key={agent.name} className={`agent-card agent-${agent.status}`}>
<div className="agent-name">{agent.name}</div>
<div className="agent-status">{agent.status}</div>
</div>
))}
</div>
{policyCheck && (
<div className="policy-results">
<h4>π Policy Check Results</h4>
<div className={policyCheck.isCompliant ? 'compliant' : 'non-compliant'}>
{policyCheck.isCompliant ? 'β
Compliant' : 'β Policy Issues'}
</div>
{policyCheck.violations.length > 0 && (
<ul>
{policyCheck.violations.map((v, i) => (
<li key={i} className="violation">{v}</li>
))}
</ul>
)}
</div>
)}
{receiptAnalysis && (
<div className="receipt-results">
<h4>π§Ύ Receipt Analysis</h4>
<div className={receiptAnalysis.isValid ? 'valid' : 'invalid'}>
{receiptAnalysis.isValid ? 'β
Valid Receipt' : 'β οΈ Issues Found'}
</div>
<div className="receipt-details">
<p>Extracted Amount: ${receiptAnalysis.extractedAmount}</p>
<p>Vendor: {receiptAnalysis.extractedVendor}</p>
<p>Confidence: {(receiptAnalysis.confidenceScore * 100).toFixed(1)}%</p>
</div>
</div>
)}
{approvalRoute && (
<div className="approval-route">
<h4>π― Approval Path</h4>
{approvalRoute.autoApprove ? (
<div className="auto-approved">β
Auto-Approved</div>
) : (
<div className="approval-chain">
<p>Requires approval from:</p>
<ul>
{approvalRoute.requiresManagerApproval && <li>π€ Manager</li>}
{approvalRoute.requiresFinanceApproval && <li>π° Finance</li>}
{approvalRoute.requiresCEOApproval && <li>π’ CEO</li>}
</ul>
</div>
)}
</div>
)}
</div>
);
};// Add handler for new message types
const renderMessage = (message: Message) => {
switch (message.type) {
case 'agent-status':
return <AgentStatusMessage message={message as AgentStatusUpdate} />;
case 'policy-check-result':
return <PolicyCheckDisplay message={message as PolicyCheckUpdate} />;
case 'receipt-analysis-result':
return <ReceiptAnalysisDisplay message={message as ReceiptAnalysisUpdate} />;
case 'approval-route':
return <ApprovalRouteDisplay message={message as ApprovalRouteUpdate} />;
case 'supervisor-decision':
return <SupervisorDecisionDisplay message={message as SupervisorDecisionUpdate} />;
default:
return <StandardMessage message={message} />;
}
};// In Endpoints.cs HandleMessageAsync method, add cases for new types:
else if (chatItem is AgentStatusUpdate agentStatus)
{
var serializedMessage = JsonSerializer.Serialize(agentStatus, JsonSerializerOptions.Web);
await response.WriteAsync($"data: {serializedMessage}\n\n", cancellationToken);
await response.Body.FlushAsync(cancellationToken);
}
else if (chatItem is PolicyCheckUpdate policyCheck)
{
var serializedMessage = JsonSerializer.Serialize(policyCheck, JsonSerializerOptions.Web);
await response.WriteAsync($"data: {serializedMessage}\n\n", cancellationToken);
await response.Body.FlushAsync(cancellationToken);
}
// ... add other message types- Parallel Processing: Policy checks and receipt analysis run simultaneously
- Real-time Updates: Each agent emits status updates as it works
- Transparent Process: Users see exactly what's being checked
- Flexible Routing: Approval paths determined dynamically based on results
- Auto-approval: Low-risk expenses can be auto-approved instantly
- Better UX: Visual feedback shows which agent is active
// Create a test expense submission
var expense = new ExpenseSubmission
{
UserId = "test-user",
Category = ExpenseCategory.Meals,
Amount = 75.50m,
Date = DateTime.Today,
Description = "Client lunch meeting",
Vendor = "Restaurant ABC",
Attachments = [new UriAttachment("https://...", "image/jpeg")]
};
// Submit through ProcessService
await processService.ActAsync(UserIntent.SubmitExpense, expense);
// Watch the SSE stream for:
// 1. agent-status: Supervisor active
// 2. agent-status: Policy Check Agent active
// 3. agent-status: Receipt Analysis Agent active
// 4. policy-check-result
// 5. receipt-analysis-result
// 6. agent-status: Routing Agent active
// 7. approval-route
// 8. supervisor-decisionFollowing the AG-UI protocol, these events are emitted:
agent-status- Real-time agent activity updatespolicy-check-result- Generative UI showing policy compliancereceipt-analysis-result- Structured receipt validation dataapproval-route- Dynamic routing decisionsupervisor-decision- Final orchestration resulthuman-approval-request- HITL when needed (via RequestPort)
This mirrors the LangGraph subgraphs demo pattern while leveraging your existing .NET workflow infrastructure!