DEV Community

Cover image for Agent Framework Workflows: Beyond Chat — Orchestrating Complex AI Tasks
Brian Spann
Brian Spann

Posted on

Agent Framework Workflows: Beyond Chat — Orchestrating Complex AI Tasks

Introduction

In Part 1 of this series, we explored how Microsoft Agent Framework unifies Semantic Kernel and AutoGen into a cohesive SDK. We built simple agents, added tools, and managed conversations.

But real-world AI applications often require more than a single agent responding to queries. You need:

  • Multi-step processes with explicit ordering
  • Multiple agents collaborating on different aspects of a task
  • Conditional branching based on intermediate results
  • Human approval at critical decision points
  • Durability so long-running tasks survive failures

This is where Workflows come in.


When to Use Workflows vs. Single Agents

Before diving in, let's clarify when workflows make sense:

Scenario Recommendation
Simple Q&A, chat interfaces Single agent
Content generation with review cycles Workflow
Data processing pipelines Workflow
Tasks requiring human approval Workflow
Complex research with multiple perspectives Workflow
Long-running processes (hours/days) Workflow with checkpointing

The rule of thumb: if your task has explicit steps that should happen in a defined order, or if multiple agents need to collaborate, use a workflow.


Workflow Fundamentals

A workflow in Agent Framework consists of:

  • Steps: Individual units of work
  • Transitions: How steps connect to each other
  • Context: Shared state that flows through the workflow
  • Agents: The AI agents that execute steps

Your First Workflow

Let's build a simple content creation workflow:

using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;

// Create agents
var researcher = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
    Name = "Researcher",
    Instructions = """
        You are a research specialist. Given a topic, you:
        1. Identify key aspects to cover
        2. Find relevant facts and statistics
        3. Note any controversies or debates
        4. Summarize your findings in a structured format
        """
});

var writer = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
    Name = "Writer",
    Instructions = """
        You are a content writer. Given research notes, you:
        1. Create an engaging narrative
        2. Use clear, accessible language
        3. Include relevant examples
        4. Structure with headers and bullet points
        """
});

var editor = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
    Name = "Editor",
    Instructions = """
        You are an editor. Review content for:
        1. Factual accuracy
        2. Grammar and style
        3. Clarity and flow
        4. Engagement
        Provide specific, actionable feedback.
        """
});

// Build the workflow
var workflow = new WorkflowBuilder("content-pipeline")
    .AddStep("research", async ctx =>
    {
        var topic = ctx.GetInput<string>("topic");
        var result = await researcher.InvokeAsync(
            $"Research this topic thoroughly: {topic}");
        ctx.Set("research_notes", result.Content);
    })
    .AddStep("write", async ctx =>
    {
        var notes = ctx.Get<string>("research_notes");
        var result = await writer.InvokeAsync(
            $"Write an article based on these research notes:\n\n{notes}");
        ctx.Set("draft", result.Content);
    })
    .AddStep("edit", async ctx =>
    {
        var draft = ctx.Get<string>("draft");
        var result = await editor.InvokeAsync(
            $"Review and improve this article:\n\n{draft}");
        ctx.Set("final_content", result.Content);
    })
    .Connect("research", "write")
    .Connect("write", "edit")
    .Build();

// Run the workflow
var context = new WorkflowContext();
context.SetInput("topic", "The impact of AI on software development in 2026");

await workflow.RunAsync(context);

Console.WriteLine(context.Get<string>("final_content"));
Enter fullscreen mode Exit fullscreen mode

Understanding Workflow Context

The WorkflowContext is the shared state container that flows through your workflow:

// Setting values
context.Set("key", value);           // Any serializable type
context.SetInput("inputKey", value); // Specifically for inputs

// Getting values
var value = context.Get<T>("key");
var input = context.GetInput<T>("inputKey");

// Check existence
if (context.TryGet<T>("key", out var result)) { ... }

// Metadata
context.Metadata["executionId"] = Guid.NewGuid();
context.Metadata["startedAt"] = DateTime.UtcNow;
Enter fullscreen mode Exit fullscreen mode

Conditional Branching

Real workflows aren't always linear. Let's add quality checks and revision loops:

var workflow = new WorkflowBuilder("content-with-review")
    .AddStep("research", async ctx => { /* ... */ })
    .AddStep("write", async ctx => { /* ... */ })
    .AddStep("review", async ctx =>
    {
        var draft = ctx.Get<string>("draft");
        var result = await editor.InvokeAsync(
            $"""Review this article and respond with a JSON object:
            {{
                "quality": "approved" | "needs_revision",
                "feedback": "your detailed feedback",
                "score": 1-10
            }}

            Article:
            {draft}""");

        var review = JsonSerializer.Deserialize<ReviewResult>(result.Content);
        ctx.Set("review", review);
        ctx.Set("quality", review.Quality);
    })
    .AddConditionalStep("quality_gate", ctx =>
    {
        var quality = ctx.Get<string>("quality");
        return quality == "approved" ? "publish" : "revise";
    })
    .AddStep("revise", async ctx =>
    {
        var draft = ctx.Get<string>("draft");
        var review = ctx.Get<ReviewResult>("review");
        var revisionCount = ctx.GetOrDefault("revision_count", 0);

        if (revisionCount >= 3)
        {
            // Force approve after 3 attempts
            ctx.Set("quality", "approved");
            return;
        }

        var result = await writer.InvokeAsync(
            $"""Revise this article based on the feedback:

            Current draft:
            {draft}

            Feedback:
            {review.Feedback}

            Make specific improvements addressing each point.""");

        ctx.Set("draft", result.Content);
        ctx.Set("revision_count", revisionCount + 1);
    })
    .AddStep("publish", async ctx =>
    {
        var content = ctx.Get<string>("draft");
        // Publish logic here
        ctx.Set("published", true);
        ctx.Set("published_at", DateTime.UtcNow);
    })
    // Connections
    .Connect("research", "write")
    .Connect("write", "review")
    .Connect("review", "quality_gate")
    .Connect("quality_gate", "publish", when: "publish")
    .Connect("quality_gate", "revise", when: "revise")
    .Connect("revise", "review")  // Loop back for re-review
    .Build();
Enter fullscreen mode Exit fullscreen mode

This creates a revision loop:

research → write → review → quality_gate
                              ↓         ↓
                           publish    revise
                                        ↓
                                      review (loop)
Enter fullscreen mode Exit fullscreen mode

Parallel Execution

Some steps can run concurrently. Agent Framework makes this explicit:

var workflow = new WorkflowBuilder("parallel-research")
    .AddStep("init", ctx =>
    {
        ctx.Set("topic", ctx.GetInput<string>("topic"));
        return Task.CompletedTask;
    })
    // These three run in parallel
    .AddParallelSteps("gather",
        ("technical", async ctx =>
        {
            var topic = ctx.Get<string>("topic");
            var result = await technicalResearcher.InvokeAsync(
                $"Research technical aspects of: {topic}");
            ctx.Set("technical_notes", result.Content);
        }),
        ("market", async ctx =>
        {
            var topic = ctx.Get<string>("topic");
            var result = await marketResearcher.InvokeAsync(
                $"Research market trends for: {topic}");
            ctx.Set("market_notes", result.Content);
        }),
        ("competition", async ctx =>
        {
            var topic = ctx.Get<string>("topic");
            var result = await competitionAnalyst.InvokeAsync(
                $"Analyze competitors in: {topic}");
            ctx.Set("competition_notes", result.Content);
        })
    )
    // This waits for all parallel steps to complete
    .AddStep("synthesize", async ctx =>
    {
        var technical = ctx.Get<string>("technical_notes");
        var market = ctx.Get<string>("market_notes");
        var competition = ctx.Get<string>("competition_notes");

        var result = await synthesizer.InvokeAsync(
            $"""Create a comprehensive report combining these perspectives:

            Technical Analysis:
            {technical}

            Market Research:
            {market}

            Competitive Analysis:
            {competition}""");

        ctx.Set("report", result.Content);
    })
    .Connect("init", "gather")
    .Connect("gather", "synthesize")
    .Build();
Enter fullscreen mode Exit fullscreen mode

Parallel with Different Completion Strategies

// Wait for all (default)
.AddParallelSteps("all-required", 
    ParallelCompletion.All, 
    steps...);

// First one wins
.AddParallelSteps("race", 
    ParallelCompletion.First, 
    steps...);

// Majority must complete
.AddParallelSteps("majority", 
    ParallelCompletion.Majority, 
    steps...);

// At least N must complete
.AddParallelSteps("quorum", 
    ParallelCompletion.AtLeast(2), 
    steps...);
Enter fullscreen mode Exit fullscreen mode

Human-in-the-Loop

Critical workflows often need human oversight:

var workflow = new WorkflowBuilder("human-approval")
    .AddStep("generate", async ctx => { /* ... */ })
    .AddHumanStep("approval", new HumanStepOptions
    {
        Prompt = ctx => $"Please review this content:\n\n{ctx.Get<string>("draft")}",
        Timeout = TimeSpan.FromHours(24),
        OnTimeout = HumanStepTimeoutBehavior.Reject,
        AllowedResponses = new[] { "approve", "reject", "revise" },
        // Optional: notify via webhook, email, etc.
        NotificationHandler = async (stepId, ctx) =>
        {
            await emailService.SendAsync(
                to: "reviewer@company.com",
                subject: "Content awaiting approval",
                body: ctx.Get<string>("draft"));
        }
    })
    .AddConditionalStep("route", ctx =>
    {
        return ctx.Get<HumanResponse>("approval").Decision;
    })
    .Connect("generate", "approval")
    .Connect("approval", "route")
    .Connect("route", "publish", when: "approve")
    .Connect("route", "archive", when: "reject")
    .Connect("route", "revise", when: "revise")
    .Build();
Enter fullscreen mode Exit fullscreen mode

Responding to Human Steps

When a workflow is waiting for human input:

// Get pending human steps
var pending = await workflowRunner.GetPendingHumanStepsAsync();

foreach (var step in pending)
{
    Console.WriteLine($"Workflow: {step.WorkflowId}");
    Console.WriteLine($"Step: {step.StepId}");
    Console.WriteLine($"Prompt: {step.Prompt}");
    Console.WriteLine($"Waiting since: {step.CreatedAt}");
}

// Submit a response
await workflowRunner.SubmitHumanResponseAsync(
    workflowInstanceId: "abc123",
    stepId: "approval",
    response: new HumanResponse
    {
        Decision = "approve",
        Comment = "Looks good! Minor typo on line 3, but acceptable.",
        RespondedBy = "jane@company.com",
        RespondedAt = DateTime.UtcNow
    });
Enter fullscreen mode Exit fullscreen mode

Checkpointing and Durability

Long-running workflows need to survive failures. Checkpointing saves the workflow state after each step:

// Configure checkpoint storage
var checkpointStore = new AzureBlobCheckpointStore(
    connectionString: config["Storage:ConnectionString"],
    containerName: "workflow-checkpoints");

var runner = new WorkflowRunner(workflow)
{
    CheckpointStore = checkpointStore,
    CheckpointFrequency = CheckpointFrequency.AfterEachStep,
    OnError = WorkflowErrorBehavior.PauseAndCheckpoint
};

// Start a workflow
var instanceId = await runner.StartAsync(context);
Console.WriteLine($"Started workflow: {instanceId}");

// The workflow runs... then your server crashes...
// Later, after restart:

// Resume any incomplete workflows
var incomplete = await runner.GetIncompleteWorkflowsAsync();
foreach (var workflow in incomplete)
{
    Console.WriteLine($"Resuming {workflow.InstanceId} from step {workflow.LastCompletedStep}");
    await runner.ResumeAsync(workflow.InstanceId);
}
Enter fullscreen mode Exit fullscreen mode

Checkpoint Store Options

// Azure Blob Storage
var store = new AzureBlobCheckpointStore(connectionString, container);

// Azure Table Storage (good for many small workflows)
var store = new AzureTableCheckpointStore(connectionString, tableName);

// SQL Server
var store = new SqlCheckpointStore(connectionString);

// File system (development only)
var store = new FileCheckpointStore("./checkpoints");

// In-memory (testing only)
var store = new InMemoryCheckpointStore();
Enter fullscreen mode Exit fullscreen mode

Multi-Agent Orchestration Patterns

Agent Framework provides several built-in patterns for multi-agent collaboration:

Round-Robin Chat

Agents take turns in a fixed order:

var chat = new RoundRobinGroupChat(new[] 
{ 
    analyst, 
    critic, 
    synthesizer 
});

var result = await chat.RunAsync(
    "Analyze the pros and cons of microservices vs monoliths",
    maxRounds: 3);
Enter fullscreen mode Exit fullscreen mode

Selector-Based Routing

An AI selector chooses the next speaker:

var selector = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
    Name = "Selector",
    Instructions = """
        You are a conversation moderator. Based on the conversation so far,
        decide which agent should speak next. Choose from:
        - Researcher: for finding facts
        - Analyst: for interpreting data
        - Writer: for creating content
        - Critic: for reviewing work

        Respond with just the agent name.
        """
});

var chat = new SelectorGroupChat(
    selector: selector,
    agents: new[] { researcher, analyst, writer, critic },
    terminationCondition: conversation => 
        conversation.Messages.Last().Content.Contains("TASK COMPLETE"));

await chat.RunAsync("Write a market analysis report for electric vehicles");
Enter fullscreen mode Exit fullscreen mode

Broadcast Pattern

All agents respond to each message:

var broadcast = new BroadcastGroupChat(new[] 
{ 
    optimist, 
    pessimist, 
    realist 
});

// Each agent will provide their perspective
var responses = await broadcast.CollectResponsesAsync(
    "Should we invest in quantum computing startups?");

foreach (var response in responses)
{
    Console.WriteLine($"{response.Agent.Name}: {response.Content}");
}
Enter fullscreen mode Exit fullscreen mode

Hierarchical Teams

Nested group chats for complex organization:

// Research team
var researchTeam = new RoundRobinGroupChat(new[] 
{ 
    seniorResearcher, 
    juniorResearcher, 
    dataAnalyst 
});

// Writing team
var writingTeam = new RoundRobinGroupChat(new[] 
{ 
    contentWriter, 
    copyEditor, 
    factChecker 
});

// Executive summary
var executiveChat = new SelectorGroupChat(
    selector: projectManager,
    agents: new IAgent[] 
    { 
        researchTeam.AsAgent("ResearchTeam"), 
        writingTeam.AsAgent("WritingTeam"),
        stakeholderLiaison 
    });

await executiveChat.RunAsync("Create quarterly market report");
Enter fullscreen mode Exit fullscreen mode

Magentic One Pattern

Magentic One is a research-proven pattern from Microsoft Research for complex, open-ended tasks. It features:

  • An Orchestrator that decomposes tasks and coordinates
  • Specialized agents for different capabilities
  • Dynamic replanning based on progress
var magneticOne = new MagenticOneTeam(new MagenticOneOptions
{
    Orchestrator = new ChatClientAgent(chatClient, new ChatClientAgentOptions
    {
        Name = "Orchestrator",
        Instructions = """
            You are the orchestrator for a team of AI agents. Your job is to:
            1. Break down complex tasks into subtasks
            2. Assign subtasks to the most appropriate agent
            3. Monitor progress and adjust plans as needed
            4. Synthesize results into a coherent output

            Available agents:
            - WebSurfer: Can browse the web and extract information
            - Coder: Can write and execute code
            - FileSurfer: Can read and analyze files
            - ComputerTerminal: Can execute shell commands
            """
    }),
    Agents = new[]
    {
        CreateWebSurferAgent(chatClient),
        CreateCoderAgent(chatClient),
        CreateFileSurferAgent(chatClient),
        CreateTerminalAgent(chatClient)
    },
    MaxIterations = 10,
    TaskLedger = new AzureBlobTaskLedger(blobClient)
});

var result = await magneticOne.ExecuteAsync(
    "Research the latest developments in quantum error correction, " +
    "find the top 5 research papers from 2025, and create a summary " +
    "comparing their approaches.");
Enter fullscreen mode Exit fullscreen mode

Error Handling and Retry Strategies

var workflow = new WorkflowBuilder("resilient")
    .AddStep("risky_operation", async ctx =>
    {
        // This might fail
        await externalApi.CallAsync();
    })
    .WithRetry("risky_operation", new RetryOptions
    {
        MaxAttempts = 3,
        Delay = TimeSpan.FromSeconds(1),
        BackoffMultiplier = 2.0,
        RetryOn = ex => ex is HttpRequestException or TimeoutException
    })
    .WithFallback("risky_operation", async (ctx, ex) =>
    {
        // If all retries fail, use cached data
        ctx.Set("result", await cache.GetLastKnownGoodAsync());
        ctx.Set("used_fallback", true);
    })
    .Build();
Enter fullscreen mode Exit fullscreen mode

Global Error Handling

var runner = new WorkflowRunner(workflow)
{
    OnStepError = async (stepId, context, exception) =>
    {
        logger.LogError(exception, "Step {StepId} failed", stepId);

        await alertService.SendAsync(
            $"Workflow step failed: {stepId}",
            exception.Message);
    },

    OnWorkflowError = async (context, exception) =>
    {
        // Save partial results before failing
        await savePartialResults(context);
        throw; // Re-throw to mark workflow as failed
    }
};
Enter fullscreen mode Exit fullscreen mode

Observability and Tracing

Workflows integrate with OpenTelemetry:

var runner = new WorkflowRunner(workflow)
{
    ActivitySource = new ActivitySource("Workflows.ContentPipeline")
};

// Each step creates a span
// Trace hierarchy:
// workflow:content-pipeline
//   ├── step:research
//   │     └── agent:Researcher.invoke
//   ├── step:write
//   │     └── agent:Writer.invoke
//   └── step:edit
//         └── agent:Editor.invoke
Enter fullscreen mode Exit fullscreen mode

Custom Metrics

workflow.OnStepCompleted += (sender, args) =>
{
    stepDurationHistogram.Record(
        args.Duration.TotalMilliseconds,
        new KeyValuePair<string, object?>("step", args.StepId),
        new KeyValuePair<string, object?>("workflow", args.WorkflowId));

    if (args.Context.TryGet<int>("tokens_used", out var tokens))
    {
        tokenCounter.Add(tokens,
            new KeyValuePair<string, object?>("step", args.StepId));
    }
};
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Keep Steps Focused

// ❌ Too much in one step
.AddStep("do_everything", async ctx =>
{
    // Research, write, edit, publish... 500 lines
})

// ✅ Single responsibility
.AddStep("research", async ctx => { /* just research */ })
.AddStep("write", async ctx => { /* just writing */ })
.AddStep("edit", async ctx => { /* just editing */ })
Enter fullscreen mode Exit fullscreen mode

2. Use Typed Context Objects

public record ContentWorkflowState
{
    public string Topic { get; init; } = "";
    public string? ResearchNotes { get; set; }
    public string? Draft { get; set; }
    public ReviewResult? Review { get; set; }
    public int RevisionCount { get; set; }
}

// Extension for type safety
public static class ContextExtensions
{
    public static ContentWorkflowState GetState(this WorkflowContext ctx)
        => ctx.Get<ContentWorkflowState>("state");

    public static void SetState(this WorkflowContext ctx, ContentWorkflowState state)
        => ctx.Set("state", state);
}
Enter fullscreen mode Exit fullscreen mode

3. Make Workflows Idempotent

.AddStep("publish", async ctx =>
{
    var articleId = ctx.Get<string>("article_id");

    // Check if already published (in case of retry)
    if (await cms.ExistsAsync(articleId))
    {
        ctx.Set("publish_result", "already_exists");
        return;
    }

    await cms.PublishAsync(articleId, ctx.Get<string>("content"));
    ctx.Set("publish_result", "published");
})
Enter fullscreen mode Exit fullscreen mode

4. Plan for Long-Running Workflows

// Always use checkpointing for production
var runner = new WorkflowRunner(workflow)
{
    CheckpointStore = new AzureTableCheckpointStore(...),
    CheckpointFrequency = CheckpointFrequency.AfterEachStep,

    // Set reasonable timeouts
    StepTimeout = TimeSpan.FromMinutes(5),
    WorkflowTimeout = TimeSpan.FromHours(24),

    // Handle orphaned workflows
    OrphanedWorkflowTimeout = TimeSpan.FromHours(48)
};
Enter fullscreen mode Exit fullscreen mode

What's Next

In Part 3, we'll explore the Model Context Protocol (MCP) — the universal tool standard that lets your agents use tools built in any language, and exposes your C# tools to agents everywhere.


Resources

Top comments (0)