Microsoft Agent Framework Workflows Created: 26 Feb 2026 Updated: 26 Feb 2026

Human-in-the-Loop Workflows

Human-in-the-Loop (HITL) is a pattern where a workflow pauses at a decision point, waits for a human to review and act, then resumes with the human's input. This is essential whenever AI or automated logic is not enough on its own — for example, large expense approvals, content moderation, or medical triage.

In Microsoft Agent Framework, HITL is achieved by splitting the workflow into two separate phases with an explicit human step in between:

  1. Phase 1 — an automated workflow runs and produces a result.
  2. Human step — the application presents the result to a person and collects their decision.
  3. Phase 2 — a second automated workflow runs, using the human's decision as its input.

Key Concepts

1. Two-Phase Workflow Split

You do not need a single continuous workflow that somehow "pauses". Instead, you build two small, focused workflows and connect them through application code that collects the human decision:

// Phase 1: automated audit
Run auditRun = await InProcessExecution.RunAsync(phase1Workflow, claim);

// Human step: read result, ask for input
Console.Write("Manager decision (approve/reject): ");
string decision = Console.ReadLine() ?? "reject";

// Phase 2: automated processing of the decision
Run processRun = await InProcessExecution.RunAsync(phase2Workflow, new ApprovalDecision { ... });

2. Capturing Typed Output from a Run

After a workflow completes, its output events are available in run.OutgoingEvents. Cast WorkflowOutputEvent.Data to your expected type to pass it to the next phase:

ExpenseAuditReport? report = null;
foreach (WorkflowEvent evt in auditRun.OutgoingEvents)
{
if (evt is WorkflowOutputEvent o && o.Data is ExpenseAuditReport r)
report = r;
}

3. Routing Around the Human Step

Not every run needs human input. If the first phase determines the case is straightforward, the application can skip the human step and proceed directly to Phase 2 with a default decision:

if (!report.RequiresManagerApproval)
{
// within policy — auto-approve, skip human step
approved = true;
managerNotes = "Auto-approved — within policy.";
}
else
{
// pause and collect human input
Console.Write("Manager decision (approve/reject): ");
string input = Console.ReadLine() ?? "reject";
approved = input.Equals("approve", StringComparison.OrdinalIgnoreCase);
}

4. Executor Pattern Recap

Each phase uses the same Executor<TIn, TOut> pattern you already know. The HITL pattern adds nothing new to the executor itself — the split happens in application code, not inside the executor.

internal sealed class ExpenseAuditorExecutor()
: Executor<ExpenseClaim, ExpenseAuditReport>("ExpenseAuditor")
{
public override ValueTask<ExpenseAuditReport> HandleAsync(
ExpenseClaim claim, IWorkflowContext context, CancellationToken ct = default)
{
// deterministic policy checks
...
return ValueTask.FromResult(report);
}
}

Scenario: HR Expense Claim Approval

The demo models an HR expense approval pipeline in three cases:

  1. Demo 1 — Small travel expense ($118.50 with receipts) — within policy, auto-approved.
  2. Demo 2 — Large conference expense ($1,350 with receipts) — exceeds threshold, manager approves.
  3. Demo 3 — Entertainment expense ($420 without receipts) — policy violation, manager rejects.

Workflow Architecture

ExpenseClaim
|
v
[ExpenseAuditorExecutor] <-- Phase 1 workflow
|
v
ExpenseAuditReport
|
v
[HUMAN] Manager reviews and types: "approve" or "reject"
|
v
ApprovalDecision
|
v
[ExpenseProcessorExecutor] <-- Phase 2 workflow
|
v
ExpenseOutcome

Sample Output

====== Human-in-the-Loop — HR Expense Approval ======

--- Maria Svensson | Travel | $118.50 | Receipts: Yes ---
[AUDIT] Status : Within Policy
[AUDIT] Notes : All policy checks passed.
[AUTO] Expense is within policy. Automatically approved.
[RESULT] APPROVED — $118.50 (Travel) approved. Reimbursement will be processed in the next payroll cycle. Manager note: "Auto-approved — within policy."

--- James Okafor | Conference | $1,350.00 | Receipts: Yes ---
[AUDIT] Status : Exceeds Limit
[AUDIT] Notes : Amount $1,350.00 exceeds the $500 threshold.
[HITL] Waiting for manager review...
[HITL] Policy status: Exceeds Limit
Manager decision (approve/reject): approve
Manager notes: Conference attendance pre-approved by department head.
[RESULT] APPROVED — $1,350.00 (Conference) approved. Reimbursement will be processed in the next payroll cycle. Manager note: "Conference attendance pre-approved by department head."

--- Lena Fischer | Entertainment | $420.00 | Receipts: No ---
[AUDIT] Status : Missing Receipts
[AUDIT] Notes : No receipts attached — receipts are mandatory for all claims. | Entertainment expenses over $200 require a written business justification.
[HITL] Waiting for manager review...
[HITL] Policy status: Missing Receipts
Manager decision (approve/reject): reject
Manager notes: Cannot process without receipts and business justification. Resubmit.
[RESULT] REJECTED — $420.00 (Entertainment) rejected. Please correct the issues and resubmit. Manager note: "Cannot process without receipts and business justification. Resubmit."

Important Notes

  1. Each phase is an independent workflow. Create fresh executor instances for each phase — do not reuse executor objects across different workflow runs.
  2. The human step lives in application code, not in an executor. Console.ReadLine(), a web form, a chatbot message, or a mobile notification are all valid ways to collect the human decision.
  3. Typed output requires a cast. WorkflowOutputEvent.Data is object. Use a pattern match (o.Data is ExpenseAuditReport r) to extract the strongly-typed value.
  4. Auto-approve paths skip the human step. Routing logic in application code decides whether a human decision is needed at all, keeping the workflow lean for simple cases.

Full Example

// HumanInTheLoopDemo.cs
// Demonstrates Microsoft Agent Framework Workflows - Human-in-the-Loop (HITL):
// A workflow is split into two automated phases with a human decision gate in between.
// Phase 1 runs an automated audit; the human reviews the result and decides;
// Phase 2 processes the human's decision.
//
// Scenario: HR Expense Claim Approval
// 1. ExpenseAuditorExecutor checks a claim against company policy rules.
// 2. [HUMAN STEP] Finance manager reviews the audit report and approves or rejects.
// 3. ExpenseProcessorExecutor records the decision and generates the final outcome.

using Microsoft.Agents.AI.Workflows;

namespace MicrosoftAgentFrameworkLesson.ConsoleApp.HumanInTheLoop;

// ── Data Models ───────────────────────────────────────────────────────────────

/// <summary>An expense claim submitted by an employee.</summary>
public sealed class ExpenseClaim
{
public string EmployeeName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Description { get; set; } = string.Empty;
public bool HasReceipts { get; set; }
}

/// <summary>Audit report produced by automated policy checks.</summary>
public sealed class ExpenseAuditReport
{
public string EmployeeName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string PolicyStatus { get; set; } = string.Empty;
public string AuditNotes { get; set; } = string.Empty;
public bool RequiresManagerApproval { get; set; }
}

/// <summary>Human manager's decision bundled with the original audit report.</summary>
public sealed class ApprovalDecision
{
public ExpenseAuditReport Report { get; set; } = new();
public bool Approved { get; set; }
public string ManagerNotes { get; set; } = string.Empty;
}

/// <summary>Final processing outcome after the decision is recorded.</summary>
public sealed class ExpenseOutcome
{
public string EmployeeName { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}

// ── Executor 1: ExpenseClaim → ExpenseAuditReport ────────────────────────────

/// <summary>
/// Audits an expense claim against company policy rules.
/// Flags claims that exceed the $500 manager-approval threshold or lack receipts.
/// </summary>
internal sealed class ExpenseAuditorExecutor()
: Executor<ExpenseClaim, ExpenseAuditReport>("ExpenseAuditor")
{
private const decimal ManagerApprovalThreshold = 500m;

public override ValueTask<ExpenseAuditReport> HandleAsync(
ExpenseClaim claim, IWorkflowContext context, CancellationToken ct = default)
{
var notes = new List<string>();
string policyStatus = "Within Policy";

if (claim.Amount > ManagerApprovalThreshold)
{
policyStatus = "Exceeds Limit";
notes.Add($"Amount ${claim.Amount:N2} exceeds the ${ManagerApprovalThreshold:N0} threshold.");
}

if (!claim.HasReceipts)
{
policyStatus = "Missing Receipts";
notes.Add("No receipts attached — receipts are mandatory for all claims.");
}

if (claim.Category == "Entertainment" && claim.Amount > 200m)
notes.Add("Entertainment expenses over $200 require a written business justification.");

bool requiresApproval = claim.Amount > ManagerApprovalThreshold || !claim.HasReceipts;

return ValueTask.FromResult(new ExpenseAuditReport
{
EmployeeName = claim.EmployeeName,
Category = claim.Category,
Amount = claim.Amount,
PolicyStatus = policyStatus,
AuditNotes = notes.Count > 0 ? string.Join(" | ", notes) : "All policy checks passed.",
RequiresManagerApproval = requiresApproval
});
}
}

// ── Executor 2: ApprovalDecision → ExpenseOutcome ────────────────────────────

/// <summary>
/// Records the manager's approval or rejection and generates the final outcome
/// message that will be sent to payroll or returned to the employee.
/// </summary>
internal sealed class ExpenseProcessorExecutor()
: Executor<ApprovalDecision, ExpenseOutcome>("ExpenseProcessor")
{
public override ValueTask<ExpenseOutcome> HandleAsync(
ApprovalDecision decision, IWorkflowContext context, CancellationToken ct = default)
{
var outcome = new ExpenseOutcome { EmployeeName = decision.Report.EmployeeName };

if (decision.Approved)
{
outcome.Status = "APPROVED";
outcome.Message = $"${decision.Report.Amount:N2} ({decision.Report.Category}) approved. " +
$"Reimbursement will be processed in the next payroll cycle. " +
$"Manager note: \"{decision.ManagerNotes}\"";
}
else
{
outcome.Status = "REJECTED";
outcome.Message = $"${decision.Report.Amount:N2} ({decision.Report.Category}) rejected. " +
$"Please correct the issues and resubmit. " +
$"Manager note: \"{decision.ManagerNotes}\"";
}

return ValueTask.FromResult(outcome);
}
}

// ── Demo Runner ───────────────────────────────────────────────────────────────

public static class HumanInTheLoopDemo
{
public static async Task RunAsync()
{
Console.WriteLine("====== Human-in-the-Loop — HR Expense Approval ======\n");

// Demo 1: Small travel expense — within policy, auto-approved
await ProcessExpenseAsync(
new ExpenseClaim
{
EmployeeName = "Maria Svensson",
Category = "Travel",
Amount = 118.50m,
Description = "Train tickets to regional office",
HasReceipts = true
},
simulatedDecision: "approve",
simulatedNotes: "Auto-approved — within policy.");

// Demo 2: Large conference expense — requires manager approval, approved
await ProcessExpenseAsync(
new ExpenseClaim
{
EmployeeName = "James Okafor",
Category = "Conference",
Amount = 1_350.00m,
Description = "Annual tech summit registration + hotel",
HasReceipts = true
},
simulatedDecision: "approve",
simulatedNotes: "Conference attendance pre-approved by department head.");

// Demo 3: Entertainment without receipts — rejected by manager
await ProcessExpenseAsync(
new ExpenseClaim
{
EmployeeName = "Lena Fischer",
Category = "Entertainment",
Amount = 420.00m,
Description = "Client dinner",
HasReceipts = false
},
simulatedDecision: "reject",
simulatedNotes: "Cannot process without receipts and business justification. Resubmit.");
}

// ── Core HITL flow ────────────────────────────────────────────────────────

private static async Task ProcessExpenseAsync(
ExpenseClaim claim,
string simulatedDecision,
string simulatedNotes)
{
Console.WriteLine($"--- {claim.EmployeeName} | {claim.Category} | ${claim.Amount:N2} | Receipts: {(claim.HasReceipts ? "Yes" : "No")} ---");

// ── PHASE 1: Automated audit ──────────────────────────────────────────
var auditor = new ExpenseAuditorExecutor().BindExecutor();
var phase1 = new WorkflowBuilder(auditor).WithOutputFrom(auditor).Build();
Run auditRun = await InProcessExecution.RunAsync(phase1, claim);

ExpenseAuditReport? report = null;
foreach (WorkflowEvent evt in auditRun.OutgoingEvents)
{
if (evt is WorkflowOutputEvent o && o.Data is ExpenseAuditReport r)
report = r;
}

if (report is null)
{
Console.WriteLine(" ERROR: Audit produced no report.\n");
return;
}

Console.WriteLine($" [AUDIT] Status : {report.PolicyStatus}");
Console.WriteLine($" [AUDIT] Notes : {report.AuditNotes}");

// ── HUMAN-IN-THE-LOOP: Collect manager decision ───────────────────────
bool approved;
string managerNotes;

if (!report.RequiresManagerApproval)
{
// Expense is within policy — no human step needed.
Console.WriteLine(" [AUTO] Expense is within policy. Automatically approved.");
approved = true;
managerNotes = "Auto-approved — within policy.";
}
else
{
// Pause and wait for human reviewer.
// In a real application replace the two simulated lines with:
// Console.Write(" Manager decision (approve/reject): ");
// simulatedDecision = Console.ReadLine() ?? "reject";
// Console.Write(" Manager notes: ");
// simulatedNotes = Console.ReadLine() ?? "";
Console.WriteLine(" [HITL] Waiting for manager review...");
Console.WriteLine($" [HITL] Policy status: {report.PolicyStatus}");
Console.Write(" Manager decision (approve/reject): ");
Console.WriteLine(simulatedDecision); // simulated human input
Console.Write(" Manager notes: ");
Console.WriteLine(simulatedNotes); // simulated human input

approved = simulatedDecision.Equals("approve", StringComparison.OrdinalIgnoreCase);
managerNotes = simulatedNotes;
}

// ── PHASE 2: Process the decision ─────────────────────────────────────
var processor = new ExpenseProcessorExecutor().BindExecutor();
var phase2 = new WorkflowBuilder(processor).WithOutputFrom(processor).Build();
Run processRun = await InProcessExecution.RunAsync(
phase2,
new ApprovalDecision { Report = report, Approved = approved, ManagerNotes = managerNotes });

foreach (WorkflowEvent evt in processRun.OutgoingEvents)
{
if (evt is WorkflowOutputEvent o && o.Data is ExpenseOutcome outcome)
Console.WriteLine($" [RESULT] {outcome.Status} — {outcome.Message}");
}

Console.WriteLine();
}
}
Share this lesson: