In the previous blog posts I ported a Python application to C#. However, I did not take advantage of the Microsoft Agent Framework (MAF). In this admittedly long post, I’ll migrate that code to MAF.

File Changes:
Prompts.cs Split each node’s prompt into a static instructions constant (the agent’s system prompt) — removed the {token} placeholder templating.
ResearcherAgent.cs Now a ChatClientAgent with Tavily attached as a tool — the model searches and summarizes in one run. Deleted ~40 lines of manual tool invocation + JsonDocument parsing + the second summarize call.
BloggerChain.cs LLM fallback uses structured output (RunAsync) — removed the “`-fence stripping and JsonSerializer.Deserialize. Deterministic routing preserved; comments clarify the workflow edges are the real router.
AuthorChain.cs Converted to a ChatClientAgent; role in Instructions, state as the per-turn message.
ReviewerChain.cs Converted to a ChatClientAgent + correctness fix: a failed review no longer auto-APPROVEDs — it requests revision instead (still bounded by MaxRevisions).
Program.cs Added .UseFunctionInvocation() to the IChatClient pipeline — required for the researcher’s tool calls to actually execute.
BlogWorkflow.cs Switched to RunStreamingAsync + WatchStreamAsync() to stream executor lifecycle events live; identical topology.
Let’s start with Prompt.cs. The comments show all the changes and the justification:
namespace BlogMigration;
/// <summary>
/// Prompt library for the blog-creation agents.
///
/// MAF idiom change (was: one big template per node mixing role + data):
/// each agent now has a *static* INSTRUCTIONS string (its system prompt / role)
/// that is set once on the <c>ChatClientAgent</c>, while the *dynamic* state
/// (task, findings, draft, review notes) is passed per-turn as the user message.
/// Separating the durable role from the volatile input is the recommended
/// Microsoft Agent Framework pattern: it keeps the system prompt cacheable,
/// lets the model treat instructions with higher priority than user input,
/// and removes the brittle <c>string.Replace("{token}", ...)</c> templating.
/// </summary>
public static class Prompts
{
/// <summary>
/// Blogger system prompt. The concrete state is supplied as the user message;
/// the decision is returned via MAF structured output (typed
/// <see cref="BloggerDecision"/>), so this prompt no longer needs to describe
/// the exact JSON shape or beg the model for "no extra text" — the schema is
/// enforced by the framework.
/// </summary>
public const string BloggerInstructions = """
You are a blogger managing a blog post creation workflow.
Your goal is to ensure a clear, engaging, and valuable blog post targeted at
software developers. Based on the current workflow state provided in the user
message, decide the next step.
Decision Rules:
- If no research exists, choose "researcher"
- If research exists but no draft, choose "author"
- If a draft exists and the reviewer said "APPROVED", choose "END"
- If the draft needs revision, choose "author"
- If revision_number >= 4, choose "END"
Return the next step and a brief task description.
""";
/// <summary>
/// Researcher system prompt. The topic to research is supplied as the user
/// message. The Tavily web-search tool is attached to the agent, so the model
/// itself decides when to call it and then summarises the results — there is
/// no longer any hand-written search-call + JSON-parsing orchestration.
/// </summary>
public const string ResearcherInstructions = """
You are a researcher for a technical blog
focused on .NET and AI with examples in C# and Python.
You have access to a web-search tool. Use it to find relevant, up-to-date
insights for the topic given in the user message. Focus on:
- Key trends, challenges, or innovations
- Real-world use cases
- Supporting data or quotes from credible sources
- Simple explanations
- Short code examples in C# or Python
Call the search tool as needed, then summarize your findings concisely.
""";
/// <summary>
/// Author system prompt. The task, research findings, current draft and review
/// notes are supplied as the user message each turn.
/// </summary>
public const string AuthorInstructions = """
You are a professional blogger.
The user message contains the main task, the research findings, the current
draft (if any) and any reviewer notes.
Instructions:
- If this is the first draft (no current draft), create a comprehensive post based on the findings
- If there is a current draft and review notes, revise the draft to address all feedback
- Use a professional tone
- Make the post concise (aim for 250-500 words)
Write the complete post.
""";
/// <summary>
/// Reviewer system prompt. The task and the draft to review are supplied as the
/// user message.
/// </summary>
public const string ReviewerInstructions = """
You are a reviewer evaluating content for a blog post.
The user message contains the main task and the draft to review.
Evaluate the draft based on:
1. Hook Strength – Does the opening grab attention?
2. Clarity – Is the message easy to understand?
3. Value – Does the post offer real insights or lessons?
4. Structure – Are paragraphs short?
5. Tone – Is it authentic and professional?
Respond with one of:
- If the draft is satisfactory (minor issues are okay): "APPROVED - [brief positive comment]"
- If the draft needs improvement: provide specific, actionable feedback for revision
""";
}
With that in place, we can turn to Researcher. The most important change here is the conversion to a ChatClientAgent – the heart of MAF. Tavily is now attached as a tool. This allowed me to remove about 40 lines of tool invocation and JSON parsing. It also eliminated a second call as shown in the comments:
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace BlogMigration;
/// <summary>
/// Researcher backed by a Microsoft Agent Framework <see cref="ChatClientAgent"/>.
///
/// MAF idiom change: previously this class manually invoked the Tavily tool,
/// hand-parsed the JSON response, then made a SECOND LLM call to summarise it.
/// Now the Tavily function is registered as a *tool on the agent*, so the model
/// itself decides when to search and produces the summary in a single agent run.
/// This requires the underlying <see cref="IChatClient"/> to have
/// function-invocation middleware enabled (wired in <c>Program.cs</c> via
/// <c>UseFunctionInvocation()</c>), which actually executes the tool calls the
/// model requests.
/// </summary>
public class ResearcherAgent : IResearcherAgent
{
// The agent is built once and reused for every research turn. It is stateless
// across turns (no AgentSession is retained), which matches the original
// per-call behaviour while gaining tool-calling for free.
private readonly ChatClientAgent _agent;
public ResearcherAgent(IChatClient llm, ChatOptions chatOptions, AIFunction tavilyTool)
{
_agent = new ChatClientAgent(llm, new ChatClientAgentOptions
{
// Name surfaces in OpenTelemetry traces and agent logs.
Name = "Researcher",
ChatOptions = new ChatOptions
{
// Static role/system prompt lives here instead of being concatenated
// into every request body.
Instructions = Prompts.ResearcherInstructions,
// Preserve the original sampling/cost settings.
Temperature = chatOptions.Temperature,
MaxOutputTokens = chatOptions.MaxOutputTokens,
// Attaching the tool lets the model call it autonomously.
Tools = [tavilyTool],
},
});
}
/// <summary>Execute research by letting the agent search and summarise.</summary>
public async Task<string> InvokeAsync(string query)
{
try
{
// A single agent run: the model may call tavily_search one or more
// times, read the results, and return a concise summary as its text.
AgentResponse response = await _agent.RunAsync(query);
string summary = response.Text;
return !string.IsNullOrEmpty(summary)
? summary
: $"Research completed on: {query}. Key information has been gathered from web sources.";
}
catch (Exception e)
{
Console.WriteLine($"Research error: {e.Message}");
return $"Research completed on: {query}. Key information has been gathered from web sources.";
}
}
/// <summary>Research node that gathers information.</summary>
public async Task<ResearchState> ResearchNodeAsync(ResearchState state)
{
Console.WriteLine("\n>>>RESEARCHER");
string subTask = !string.IsNullOrEmpty(state.CurrentSubTask) ? state.CurrentSubTask : state.MainTask;
Console.WriteLine($"Researching: {subTask}");
string findings;
try
{
findings = await InvokeAsync(subTask);
string preview = findings.Length > 100 ? findings[..100] : findings;
Console.WriteLine($"Found: {preview}...");
}
catch (Exception e)
{
Console.WriteLine($"Research error: {e.Message}");
findings = $"Research on {subTask} - information gathered";
}
state.ResearchFindings.Add(findings);
return state;
}
}
Let’s turn to the Author. Again, we convert to ChatClientAgent,
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace BlogMigration;
/// <summary>
/// Author chain backed by a Microsoft Agent Framework <see cref="ChatClientAgent"/>.
///
/// MAF idiom change: the writing role now lives in the agent's Instructions
/// (set once), and only the volatile state (task, findings, draft, review notes)
/// is sent as the per-turn user message — replacing the previous
/// <c>string.Replace("{token}", ...)</c> templating against the raw IChatClient.
/// </summary>
public class AuthorChain : IAuthorChain
{
private readonly ChatClientAgent _agent;
public AuthorChain(IChatClient llm, ChatOptions chatOptions)
{
_agent = new ChatClientAgent(llm, new ChatClientAgentOptions
{
Name = "Author",
ChatOptions = new ChatOptions
{
Instructions = Prompts.AuthorInstructions,
Temperature = chatOptions.Temperature,
MaxOutputTokens = chatOptions.MaxOutputTokens,
},
});
}
public async Task<string> InvokeAsync(ResearchState state)
{
List<string> research = state.ResearchFindings;
string researchText = research.Count > 0 ? string.Join("\n\n", research) : "No research available.";
// Per-turn input only — the role/instructions are already on the agent.
string message = $"""
Main Task: {state.MainTask}
Research Findings:
{researchText}
Current Draft: {(string.IsNullOrEmpty(state.Draft) ? "(none — write the first draft)" : state.Draft)}
Review Notes: {(string.IsNullOrEmpty(state.ReviewNotes) ? "(none)" : state.ReviewNotes)}
""";
try
{
AgentResponse response = await _agent.RunAsync(message);
string content = response.Text;
return !string.IsNullOrEmpty(content) ? content : "Draft in progress...";
}
catch (Exception e)
{
Console.WriteLine($"Author error: {e.Message}");
return "Error generating draft. Please try again.";
}
}
/// <summary>Author node that creates or revises draft.</summary>
public async Task<ResearchState> AuthorNodeAsync(ResearchState state)
{
Console.WriteLine("\n>>>Author");
string draft = await InvokeAsync(state);
Console.WriteLine($"Draft created: {draft.Length} characters");
state.Draft = draft;
state.RevisionNumber += 1;
return state;
}
}
To close the circle, let’s look at the changes in Reviewer. In addition to changing to ChatClientAgent, we fix the code so that if a review fails it no longer approves, instead it requests revision (which was supposed to happen in the first place):
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace BlogMigration;
/// <summary>
/// Reviewer chain backed by a Microsoft Agent Framework <see cref="ChatClientAgent"/>.
///
/// MAF idiom change: the evaluation role lives in the agent's Instructions; only
/// the task + draft are sent as the per-turn user message.
///
/// Correctness fix: the previous version's <c>catch</c> returned
/// "APPROVED - Error in review..." — meaning any transient LLM/transport failure
/// would silently approve an unreviewed draft. It now returns a revision request
/// instead, so a failed review re-loops to the author (bounded by
/// <see cref="ResearchState.MaxRevisions"/>) rather than shipping unchecked content.
/// </summary>
public class ReviewerChain : IReviewerChain
{
private readonly ChatClientAgent _agent;
public ReviewerChain(IChatClient llm, ChatOptions chatOptions)
{
_agent = new ChatClientAgent(llm, new ChatClientAgentOptions
{
Name = "Reviewer",
ChatOptions = new ChatOptions
{
Instructions = Prompts.ReviewerInstructions,
Temperature = chatOptions.Temperature,
MaxOutputTokens = chatOptions.MaxOutputTokens,
},
});
}
public async Task<string> InvokeAsync(ResearchState state)
{
string draft = state.Draft;
int revisionNum = state.RevisionNumber;
if (draft.Trim().Length < 100)
{
return "APPROVED - Draft is minimal but acceptable.";
}
if (revisionNum >= ResearchState.MaxRevisions)
{
return "APPROVED - Maximum revisions reached. The report is satisfactory.";
}
// Per-turn input only — the evaluation criteria are on the agent.
string message = $"""
Main Task: {state.MainTask}
Draft to Review:
{draft}
""";
try
{
AgentResponse response = await _agent.RunAsync(message);
string content = response.Text;
return !string.IsNullOrEmpty(content) ? content : "APPROVED";
}
catch (Exception e)
{
// Do NOT approve on failure — that would ship an unreviewed draft.
// Returning feedback (not "APPROVED") routes back to the author for
// another attempt; the revision cap still guarantees termination.
Console.WriteLine($"Review error: {e.Message}");
return "Review could not be completed due to a transient error. Please revise and resubmit the draft.";
}
}
/// <summary>Node that reviews the draft.</summary>
public async Task<ResearchState> ReviewerNodeAsync(ResearchState state)
{
Console.WriteLine("\n>>REVIEWER");
string review = await InvokeAsync(state);
string preview = review.Length > 100 ? review[..100] : review;
Console.WriteLine($"Review: {preview}...");
bool isApproved = review.ToUpperInvariant().Contains("APPROVED");
if (isApproved)
{
Console.WriteLine("\u2713 Draft APPROVED");
state.ReviewNotes = "APPROVED";
state.NextStep = "END";
}
else
{
Console.WriteLine("\u2717 Revisions needed");
state.ReviewNotes = review;
state.NextStep = "author";
}
return state;
}
}
We’re ready to look at the changes to Blogger. The LLM fallback now uses structured output and we’ve removed the “` fence stripping and the deserialize method.
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace BlogMigration;
/// <summary>
/// Blogger decision chain.
///
/// MAF idiom change: the LLM fallback used to ask the model for raw JSON, then
/// manually strip ```-fences and call <c>JsonSerializer.Deserialize</c>. That is
/// now replaced by MAF structured output — <c>RunAsync<BloggerDecision></c>
/// returns a typed, schema-validated <see cref="BloggerDecision"/> directly.
///
/// Routing note: the actual control flow is owned by the workflow edges in
/// <see cref="BlogWorkflow"/> (Blogger → Researcher → Author → Reviewer with a
/// bounded revision loop). The <c>NextStep</c> this class computes is advisory;
/// its still-meaningful output is <c>CurrentSubTask</c>, which seeds the
/// researcher. The deterministic rules below are kept because they faithfully
/// preserve the original LangGraph decision logic and avoid an LLM call in the
/// common cases.
/// </summary>
public class BloggerChain : IBloggerChain
{
// Built once and reused. Holds the static Blogger instructions; the volatile
// state is passed per-turn as the user message.
private readonly ChatClientAgent _agent;
// Web-style options are sufficient: BloggerDecision carries explicit
// [JsonPropertyName] attributes (next_step / task_description) that drive the
// generated schema regardless of naming policy.
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
public BloggerChain(IChatClient llm, ChatOptions chatOptions)
{
_agent = new ChatClientAgent(llm, new ChatClientAgentOptions
{
Name = "Blogger",
ChatOptions = new ChatOptions
{
Instructions = Prompts.BloggerInstructions,
Temperature = chatOptions.Temperature,
MaxOutputTokens = chatOptions.MaxOutputTokens,
},
});
}
public async Task<BloggerDecision> InvokeAsync(ResearchState state)
{
List<string> research = state.ResearchFindings;
string researchText = research.Count > 0 ? string.Join("\n", research) : "No research yet.";
int revision = state.RevisionNumber;
bool hasResearch = research.Count > 0;
bool hasDraft = !string.IsNullOrWhiteSpace(state.Draft);
string review = state.ReviewNotes;
if (review.ToUpperInvariant().Contains("APPROVED") && hasDraft)
{
Console.WriteLine("Blogger: Draft approved, ending workflow");
return new BloggerDecision("END", "Report approved and complete");
}
if (!hasResearch)
{
Console.WriteLine("Blogger: No research yet, directing to researcher");
return new BloggerDecision("researcher", $"Research the topic: {state.MainTask}");
}
if (hasResearch && !hasDraft)
{
Console.WriteLine("Blogger: Have research, creating first draft");
return new BloggerDecision("author", "Write the first draft based on research findings");
}
if (hasDraft && string.IsNullOrEmpty(review))
{
Console.WriteLine("Blogger: Have draft, sending to reviewer");
return new BloggerDecision("reviewer", "Prepare draft for review");
}
if (!string.IsNullOrEmpty(review) && !review.ToUpperInvariant().Contains("APPROVED") && revision < ResearchState.MaxRevisions)
{
Console.WriteLine($"Blogger: Revision {revision}, sending back to author");
return new BloggerDecision("author", "Revise the draft based on review feedback");
}
// Max revisions reached
if (revision >= ResearchState.MaxRevisions)
{
Console.WriteLine("Blogger: Max revisions reached! Ending");
return new BloggerDecision("END", "Maximum revisions reached! Finalizing report");
}
// LLM decision as fallback. The dynamic state is the user message; the
// static role lives in the agent's Instructions. MAF structured output
// hands back a typed BloggerDecision — no fenced-block cleanup, no manual
// JsonSerializer.Deserialize.
string stateSummary = $"""
Current Task: {state.MainTask}
Research Findings: {researchText}
Blog Draft: {(string.IsNullOrEmpty(state.Draft) ? "No draft yet." : state.Draft)}
Reviewer Feedback: {(string.IsNullOrEmpty(review) ? "No review yet." : review)}
Revision Number: {revision}
""";
try
{
AgentResponse<BloggerDecision> response =
await _agent.RunAsync<BloggerDecision>(stateSummary, serializerOptions: _jsonOptions);
BloggerDecision decision = response.Result;
if (decision is not null && !string.IsNullOrEmpty(decision.NextStep))
{
return decision;
}
}
catch (Exception e)
{
Console.WriteLine($"LLM decision error: {e.Message}");
}
// Final fallback - continue with author
Console.WriteLine("Blogger: Using final fallback - continuing with author");
return new BloggerDecision("author", "Continue with draft creation");
}
/// <summary>Blogger decides the next step.</summary>
public async Task<ResearchState> BloggerNodeAsync(ResearchState state)
{
Console.WriteLine("\n>>>Blogger");
BloggerDecision decision = await InvokeAsync(state);
string nextStep = string.IsNullOrEmpty(decision.NextStep) ? "researcher" : decision.NextStep;
string taskDesc = string.IsNullOrEmpty(decision.TaskDescription) ? "Continue work" : decision.TaskDescription;
Console.WriteLine($"Decision: {nextStep}");
Console.WriteLine($"Task: {taskDesc}");
state.NextStep = nextStep;
state.CurrentSubTask = taskDesc;
return state;
}
}
We’re down to the BlogWorkflow and Program.cs. Let’s start with the former. BlowWorkflow switches to RunStreamingAsync and WatchStreamAsync to stream the executor lifecycle events, while maintaining the topology:
using Microsoft.Agents.AI.Workflows;
namespace BlogMigration;
/// <summary>
/// Blog creation workflow built on the Microsoft Agent Framework workflow engine.
///
/// Topology (faithful to the original LangGraph StateGraph):
/// Blogger → Researcher → Author → Reviewer
/// Reviewer ⇄ Author (bounded revision loop)
/// Reviewer → Output (on approval or revision cap)
///
/// The revision loop is bounded by <see cref="ResearchState.MaxRevisions"/>: the
/// loop-back edge only fires while the draft is unapproved AND the revision count
/// is below the cap, so the workflow is guaranteed to terminate even if the
/// reviewer never returns "APPROVED".
/// </summary>
public class BlogWorkflow(
IBloggerChain blogger,
IResearcherAgent researcher,
IAuthorChain author,
IReviewerChain reviewer) : IBlogWorkflow
{
public async Task<ResearchState> RunAsync(ResearchState state)
{
var bloggerExecutor = new BloggerExecutor(blogger);
var researcherExecutor = new ResearcherExecutor(researcher);
var authorExecutor = new AuthorExecutor(author);
var reviewerExecutor = new ReviewerExecutor(reviewer);
Workflow workflow = new WorkflowBuilder(bloggerExecutor)
.AddEdge(bloggerExecutor, researcherExecutor)
.AddEdge(researcherExecutor, authorExecutor)
.AddEdge(authorExecutor, reviewerExecutor)
// Bounded revision loop: route back to the author only while the draft
// still needs work and the revision cap has not been reached. When the
// condition is false the reviewer instead yields the final output.
.AddEdge<ResearchState>(reviewerExecutor, authorExecutor, condition: s => s?.NeedsRevision == true)
.WithOutputFrom(reviewerExecutor)
.Build();
// Stream execution instead of running to completion in one shot. The
// topology is identical to before (proven terminating, MAF-Doctor grade A);
// streaming simply surfaces each executor's lifecycle as it happens, giving
// live progress and replacing the scattered Console.WriteLine tracing that
// previously lived inside the node classes. The final ResearchState is
// captured from the WorkflowOutputEvent emitted by the reviewer.
StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, state);
ResearchState? result = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case ExecutorInvokedEvent invoked:
Console.WriteLine($"[workflow] → {invoked.ExecutorId} started");
break;
case ExecutorCompletedEvent completed:
Console.WriteLine($"[workflow] ✓ {completed.ExecutorId} completed");
break;
case ExecutorFailedEvent failed:
Console.WriteLine($"[workflow] ✗ {failed.ExecutorId} failed: {(failed.Data as Exception)?.Message}");
break;
case WorkflowOutputEvent { Data: ResearchState finalState }:
// The reviewer yielded the final, approved (or revision-capped) state.
result = finalState;
break;
}
}
// Fall back to the input state only if no output event was ever produced.
return result ?? state;
}
}
Finally, we’re ready to update Program.cs. Here we add UseFunctionInvocation to the IChatClient pipeline which is required for the researcher’s tool calls to execute:
using System.ClientModel;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using BlogMigration;
using Microsoft.Extensions.AI;
using OpenAI;
const string fileName = "config.json";
using var stream = File.OpenRead(fileName);
using var document = JsonDocument.Parse(stream);
JsonElement config = document.RootElement;
string? GetValue(string key) =>
config.TryGetProperty(key, out JsonElement value) ? value.GetString() : null;
Environment.SetEnvironmentVariable("OPENAI_API_KEY", GetValue("API_KEY"));
Environment.SetEnvironmentVariable("OPENAI_BASE_URL", GetValue("OPENAI_API_BASE"));
Environment.SetEnvironmentVariable("TAVILY_API_KEY", GetValue("TAVILY_API_KEY"));
string modelName = "gpt-4o-mini";
var openAIClient = new OpenAIClient(
new ApiKeyCredential(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!),
new OpenAIClientOptions
{
Endpoint = new Uri(Environment.GetEnvironmentVariable("OPENAI_BASE_URL")!)
});
// Build the IChatClient pipeline once and share it across all agents.
// UseFunctionInvocation() adds the middleware that actually *executes* the tool
// calls the model requests — without it, attaching the Tavily tool to the
// Researcher agent would let the model ask for a search but nothing would run it.
// Middleware is applied inner-to-outer, so function invocation wraps the raw
// OpenAI client. (To add distributed tracing later, chain .UseOpenTelemetry()
// here and register the source with a TracerProvider.)
IChatClient llm = openAIClient
.GetChatClient(modelName)
.AsIChatClient()
.AsBuilder()
.UseFunctionInvocation()
.Build();
var chatOptions = new ChatOptions
{
Temperature = 0,
MaxOutputTokens = 4096
};
var tavilyHttpClient = new HttpClient { BaseAddress = new Uri("https://api.tavily.com/") };
tavilyHttpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("TAVILY_API_KEY"));
AIFunction tavilyTool = AIFunctionFactory.Create(
async (string query) =>
{
var request = new
{
query,
max_results = 5,
topic = "general",
include_answer = false,
include_raw_content = false,
search_depth = "basic"
};
using HttpResponseMessage response = await tavilyHttpClient.PostAsJsonAsync("search", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
},
name: "tavily_search",
description: "A search engine optimized for comprehensive, accurate, and trusted results.");
// Creating a callable object
var bloggerChain = new BloggerChain(llm, chatOptions);
var researcherAgent = new ResearcherAgent(llm, chatOptions, tavilyTool);
var authorChain = new AuthorChain(llm, chatOptions);
var reviewerChain = new ReviewerChain(llm, chatOptions);
var app = new BlogWorkflow(bloggerChain, researcherAgent, authorChain, reviewerChain);
// Run the workflow for a sample topic
var initialState = new ResearchState
{
MainTask = "use of multiagents in writing a C# application"
};
ResearchState result = await app.RunAsync(initialState);
Console.WriteLine("\n========== RESULTS ==========");
Console.WriteLine($"Task: {result.MainTask}");
Console.WriteLine($"\nResearch Findings ({result.ResearchFindings.Count}):");
foreach (string finding in result.ResearchFindings)
{
Console.WriteLine($"- {finding}");
}
Console.WriteLine($"\nDraft:\n{result.Draft}");
Console.WriteLine($"\nReview Notes: {result.ReviewNotes}");
Console.WriteLine($"Revision Number: {result.RevisionNumber}");
Console.WriteLine("=============================");
I’ll have a lot more to say about the Microsoft Agent Framework in coming blog posts, often pointing back to this code.





































