Skip to content

Make your first handoff

Our single coach now handles the whole interview. We’ll keep it for comparison and split document intake into two roles.

Triage selects the role that should handle a request. The receptionist collects and saves the materials. This gives intake its own instructions and tools, which we can change without rewriting the other roles.

The tradeoff is another decision: who takes the next turn? We’ll allow two routes and observe one transfer before adding the interviewers.

Continue in the same interview-coach-lab project from the previous chapter. We’ll be working in src/InterviewCoach.Agent/AgentDelegateFactory.cs and root apphost.settings.json.

Our first two-agent workflow

Triage sends document intake to the receptionist. The receptionist saves the inputs and ends its turn. It can return to triage for an unexpected request.

  1. Triage. Starts the workflow with one handoff destination: the receptionist. It has no domain tools.
  2. Receptionist. Uses MarkItDown and InterviewData to gather the inputs. It reports completed intake without starting another phase.

The solid arrow starts intake. The dashed arrow permits a return for an unexpected request. An available route does not require a handoff.

A handoff transfers control to another agent. It is different from calling a record tool and returning its result to the same agent. The agents share the configured model, but each has its own role instructions and available tools.

Handoffs can add model calls and cost. Use the short fictional example below with your existing approved Foundry setup.

Find the existing AddHandOffWorkflow method near the top of the file, just below AddAIAgent. It currently throws NotSupportedException. Replace that whole method, including its signature, with the code below. Stop before the following CreateProviderAgent method. Leave that method in place. Do not add a second AddHandOffWorkflow.

Replace the existing AddHandOffWorkflow stub

File to edit: src/InterviewCoach.Agent/AgentDelegateFactory.cs

Function to edit: AddHandOffWorkflow

Supplied setup to apply

Replace the matching block with the code below. Open "Current code" to locate the block in your file.

Current code
Current code
private static IHostedAgentBuilder AddHandOffWorkflow(this IHostApplicationBuilder builder, string key, Func<IServiceProvider, string, Workflow> createWorkflowDelegate)
=> throw new NotSupportedException("Complete Make your first handoff before enabling the workflow.");

Updated code
private static IHostedAgentBuilder AddHandOffWorkflow(this IHostApplicationBuilder builder, string key, Func<IServiceProvider, string, Workflow> createWorkflowDelegate)
{
builder.AddWorkflow(key, createWorkflowDelegate);
return builder.AddAIAgent(key, (sp, name) =>
{
var workflow = sp.GetRequiredKeyedService<Workflow>(key);
return workflow.AsAIAgent(name: key)
.CreateFixedAgent();
});
}

CreateProviderAgent is the helper we implemented in Chapter 2. It creates a ChatClientAgent with the configured model client. The single coach, triage, and receptionist all call it.

What the hosting adapter does

AddWorkflow registers the workflow, and AsAIAgent adapts it to the hosted-agent interface. CreateFixedAgent uses the supplied compatibility helper to serialize string tool results for this pinned AG-UI integration. Keep that helper and WorkshopHosting.cs in place.

If CreateProviderAgent is missing

If the compiler reports that CreateProviderAgent does not exist, check for its definition in AgentDelegateFactory.cs.

If the definition is missing, restore this method inside the AgentDelegateFactory class, after AddHandOffWorkflow and before CreateSingleAgent. If it is already there, leave it unchanged.

src/InterviewCoach.Agent/AgentDelegateFactory.cs
private static AIAgent CreateProviderAgent(
IServiceProvider services,
string name,
string description,
string instructions,
IList<AITool>? tools = null)
{
return new ChatClientAgent(
chatClient: services.GetRequiredService<IChatClient>(),
name: name,
description: description,
instructions: instructions,
tools: tools);
}

Define the two agent roles and their routes

Section titled “Define the two agent roles and their routes”

Find the existing CreateHandOffWorkflow(IServiceProvider sp, string key) method at the bottom of the file, under MODE 2: Handoff workflow. It returns Workflow and currently throws NotSupportedException. This is a different method from AddHandOffWorkflow, which registers the workflow with the host.

Replace the CreateHandOffWorkflow stub with the next block. Then apply the three edits that follow inside that same method. Wait until all four edits are in place before building the solution. The last edit adds the return statement.

Replace the existing CreateHandOffWorkflow stub

File to edit: src/InterviewCoach.Agent/AgentDelegateFactory.cs

Function to edit: CreateHandOffWorkflow

Replace the matching block with the code below. Open "Current code" to locate the block in your file.

Current code
Current code
private static Workflow CreateHandOffWorkflow(IServiceProvider sp, string key)
=> throw new NotSupportedException("Complete Make your first handoff before enabling the workflow.");

Updated code
private static Workflow CreateHandOffWorkflow(IServiceProvider sp, string key)
{
var markitdown = sp.GetRequiredKeyedService<McpClient>("mcp-markitdown");
var interviewData = sp.GetRequiredKeyedService<McpClient>("mcp-interview-data");
var markitdownTools = markitdown.ListToolsAsync().GetAwaiter().GetResult();
var interviewDataTools = interviewData.ListToolsAsync().GetAwaiter().GetResult();
}

The workflow discovers the same two MCP tool sets as the single coach.

Add triage after the tool discovery

File to edit: src/InterviewCoach.Agent/AgentDelegateFactory.cs

Function to edit: CreateHandOffWorkflow

Replace the matching block with the code below. Open "Current code" to locate the block in your file.

Current code
Current code
private static Workflow CreateHandOffWorkflow(IServiceProvider sp, string key)
{
var markitdown = sp.GetRequiredKeyedService<McpClient>("mcp-markitdown");
var interviewData = sp.GetRequiredKeyedService<McpClient>("mcp-interview-data");
var markitdownTools = markitdown.ListToolsAsync().GetAwaiter().GetResult();
var interviewDataTools = interviewData.ListToolsAsync().GetAwaiter().GetResult();

Updated code
private static Workflow CreateHandOffWorkflow(IServiceProvider sp, string key)
{
var markitdown = sp.GetRequiredKeyedService<McpClient>("mcp-markitdown");
var interviewData = sp.GetRequiredKeyedService<McpClient>("mcp-interview-data");
var markitdownTools = markitdown.ListToolsAsync().GetAwaiter().GetResult();
var interviewDataTools = interviewData.ListToolsAsync().GetAwaiter().GetResult();
// --- Triage Agent ---
var triageAgent = CreateProviderAgent(
services: sp,
name: "triage",
description: "Routes the conversation to the correct interview specialist.",
instructions: """
You are the Triage agent for an interview intake workflow.
The only available specialist is "receptionist".
Hand off session setup and document intake to the receptionist.
If intake is already complete, explain that the interview specialists are not connected yet.
Do not restart completed intake or route to unavailable specialists.
""");

The triage instructions name the available specialist and when to choose it. Triage has no application tools, such as record updates. The workflow supplies its handoff tools.

Add the receptionist after triage

File to edit: src/InterviewCoach.Agent/AgentDelegateFactory.cs

Function to edit: CreateHandOffWorkflow

Replace the matching block with the code below. Open "Current code" to locate the block in your file.

Current code
Current code
// --- Triage Agent ---
var triageAgent = CreateProviderAgent(
services: sp,
name: "triage",
description: "Routes the conversation to the correct interview specialist.",
instructions: """
You are the Triage agent for an interview intake workflow.
The only available specialist is "receptionist".
Hand off session setup and document intake to the receptionist.
If intake is already complete, explain that the interview specialists are not connected yet.
Do not restart completed intake or route to unavailable specialists.
""");

Updated code
// --- Triage Agent ---
var triageAgent = CreateProviderAgent(
services: sp,
name: "triage",
description: "Routes the conversation to the correct interview specialist.",
instructions: """
You are the Triage agent for an interview intake workflow.
The only available specialist is "receptionist".
Hand off session setup and document intake to the receptionist.
If intake is already complete, explain that the interview specialists are not connected yet.
Do not restart completed intake or route to unavailable specialists.
""");
// --- Receptionist Agent ---
var receptionistAgent = CreateProviderAgent(
services: sp,
name: "receptionist",
description: "Sets up interview sessions and collects resumes and job descriptions.",
instructions: """
You are the Receptionist for an AI Interview Coach system.
Your job is to set up the interview session and collect documents.
Process:
1. Call get_interview_session with the application-provided SessionId. If no record exists,
call add_interview_session with that exact ID before any update. An update cannot create a record.
Let the user know the session ID after lookup or creation succeeds.
2. Ask the user to provide their resume (link or text). Use MarkItDown to parse document links into markdown.
3. Ask the user to provide the job description (link or text). Use MarkItDown to parse document links into markdown.
4. Save parsed or pasted text in ResumeText and JobDescriptionText; saving a URL alone is insufficient.
A failed fetch does not complete intake. Ask for corrected input or explicit permission to skip it.
Before each update, call get_interview_session and preserve all six resume/job fields.
Set Transcript to ONLY new text to append, and verify the returned document fields before handoff.
5. Once document intake is complete, let the user know. Interview specialists are not connected yet.
Only hand off to "triage" if the user wants to do something unexpected.
The user may choose to proceed without a resume or job description — that's fine.
Always maintain a supportive and encouraging tone.
""",
tools: [.. markitdownTools, .. interviewDataTools]);

The receptionist gets MarkItDown and InterviewData tools. It collects the inputs, saves them, and reports that intake is complete. Its instructions describe the two-agent stage you’re building.

Select the complete receptionist definition: from // --- Receptionist Agent --- through tools: [.. markitdownTools, .. interviewDataTools]);. Replace that block with the code below.

Add the graph after the receptionist

File to edit: src/InterviewCoach.Agent/AgentDelegateFactory.cs

Function to edit: CreateHandOffWorkflow

Replace the matching block with the code below. Open "Current code" to locate the block in your file.

Current code
Current code
// --- Receptionist Agent ---
var receptionistAgent = CreateProviderAgent(
services: sp,
name: "receptionist",
description: "Sets up interview sessions and collects resumes and job descriptions.",
instructions: """
You are the Receptionist for an AI Interview Coach system.
Your job is to set up the interview session and collect documents.
Process:
1. Call get_interview_session with the application-provided SessionId. If no record exists,
call add_interview_session with that exact ID before any update. An update cannot create a record.
Let the user know the session ID after lookup or creation succeeds.
2. Ask the user to provide their resume (link or text). Use MarkItDown to parse document links into markdown.
3. Ask the user to provide the job description (link or text). Use MarkItDown to parse document links into markdown.
4. Save parsed or pasted text in ResumeText and JobDescriptionText; saving a URL alone is insufficient.
A failed fetch does not complete intake. Ask for corrected input or explicit permission to skip it.
Before each update, call get_interview_session and preserve all six resume/job fields.
Set Transcript to ONLY new text to append, and verify the returned document fields before handoff.
5. Once document intake is complete, let the user know. Interview specialists are not connected yet.
Only hand off to "triage" if the user wants to do something unexpected.
The user may choose to proceed without a resume or job description — that's fine.
Always maintain a supportive and encouraging tone.
""",
tools: [.. markitdownTools, .. interviewDataTools]);

Updated code
// --- Receptionist Agent ---
var receptionistAgent = CreateProviderAgent(
services: sp,
name: "receptionist",
description: "Sets up interview sessions and collects resumes and job descriptions.",
instructions: """
You are the Receptionist for an AI Interview Coach system.
Your job is to set up the interview session and collect documents.
Process:
1. Call get_interview_session with the application-provided SessionId. If no record exists,
call add_interview_session with that exact ID before any update. An update cannot create a record.
Let the user know the session ID after lookup or creation succeeds.
2. Ask the user to provide their resume (link or text). Use MarkItDown to parse document links into markdown.
3. Ask the user to provide the job description (link or text). Use MarkItDown to parse document links into markdown.
4. Save parsed or pasted text in ResumeText and JobDescriptionText; saving a URL alone is insufficient.
A failed fetch does not complete intake. Ask for corrected input or explicit permission to skip it.
Before each update, call get_interview_session and preserve all six resume/job fields.
Set Transcript to ONLY new text to append, and verify the returned document fields before handoff.
5. Once document intake is complete, let the user know. Interview specialists are not connected yet.
Only hand off to "triage" if the user wants to do something unexpected.
The user may choose to proceed without a resume or job description — that's fine.
Always maintain a supportive and encouraging tone.
""",
tools: [.. markitdownTools, .. interviewDataTools]);
// Connect only the two agents that exist at this checkpoint.
#pragma warning disable MAAIW001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var workflow = AgentWorkflowBuilder
.CreateHandoffBuilderWith(triageAgent)
.WithHandoff(triageAgent, receptionistAgent)
.WithHandoff(receptionistAgent, triageAgent)
.Build();
#pragma warning restore MAAIW001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
return workflow.SetName(key);

A graph defines the agents and allowed handoff routes. AgentWorkflowBuilder builds this graph and sets triage as the starting agent.

Here, triage can hand off to the receptionist. The receptionist can return to triage for an unexpected request. When normal intake finishes, its instructions say to report that the interview specialists are not connected yet.

An edge makes a destination available. It does not require the active agent to transfer there. Instructions and conversation context guide that choice.

The MAAIW001 pragma acknowledges an experimental handoff API in the pinned package. Keep the suppression narrow and keep the package pins. Review this API before upgrading a separate application.

Check your understandingAt this two-agent stage, why can the receptionist save a record while triage cannot?

Select HandOff in root apphost.settings.json:

Select the handoff workflow

File to edit: apphost.settings.json

Scope to edit: File-level contents (no enclosing function)

Replace the matching block with the code below. Open "Current code" to locate the block in your file.

Current code
Current code
"AgentMode": "Single"

Updated code
"AgentMode": "HandOff"

Leave the other project-based AppHost settings unchanged.

From the working project root:

Terminal window
aspire stop --apphost ./apphost.cs
dotnet build InterviewCoach.slnx
aspire start --apphost ./apphost.cs

We’ll send one message, then check that the receptionist created an interview record and saved both inputs.

  1. In the Aspire dashboard, select Resources. Find the webui row and open its link in the URLs column. Keep the dashboard tab open.
  2. On the chat page, select New chat and send the message below. WebUI supplies the session ID.
Set up an interview with this fictional resume text: "I build C# web APIs."
The job description is "Backend developer." Save these inputs.

Wait for the reply. The receptionist should confirm that it saved the inputs. It may say that the interview specialists are not connected yet. That is expected in this chapter.

  1. Return to the Aspire dashboard. Select Structured in the left navigation. This opens Structured logs.
  2. Set Resource to webui and Level to (All).
  3. In the search box at the top, labeled Message filter, enter Started new chat session with SessionId and press Enter.
  4. Find the row whose Timestamp matches when you started this chat. Click the row to open Log entry details. In the Log entry section, copy the value beside SessionId.

This ID connects the chat to its saved interview record. The Started new chat entry means WebUI created the chat. It does not mean anything has been saved to the database yet.

  1. Close Log entry details. Change Resource to mcp-interview-data.
  2. Replace the text in Message filter with the session ID you copied. Press Enter. Keep Level at (All) so the first lookup is also visible.

The mcp-interview-data logs now show entries for this interview. Read them by Timestamp:

Message contains Meaning
Interview session with ID followed by not found The first lookup found no record for the new chat. This warning is expected when an Added entry follows it.
Added interview session The service created the interview record.
Retrieved interview session The service read the existing record before changing it.
Updated interview session The service saved changes to that record.

Triage has no InterviewData tools. The receptionist does. These successful database calls show that intake reached the receptionist. Next, check what it saved.

If the expected entries are missing

Check Resource, Level, and Message filter first. Replace the old search text with the ID. Do not add the ID to the old text.

If there is an Added entry but no Updated entry, creation succeeded, but the logs have not confirmed a later save. Inspect the record below. If its input fields are empty, return to the same chat and check the reply for a question or tool error.

  1. In Aspire, return to Resources. On the cosmos row, select Data Explorer in the URLs column.
  2. In the emulator page, select Explorer. Expand interviewdb, then interviewsessions, and select Items.
  3. In the filter box beside SELECT * FROM c, enter the expression below. Replace YOUR_SESSION_ID with your copied ID and keep the single quotes.
WHERE c.id = 'YOUR_SESSION_ID'

Select Apply Filter, then select the matching ID in the results. Read the JSON document without editing it:

Field Expected value
id The session ID you copied from WebUI’s log.
ResumeText Contains I build C# web APIs.
JobDescriptionText Contains Backend developer.
IsCompleted false: intake is saved, but the interview is not finished.

Both text fields must contain the supplied inputs. An empty record or a chat reply saying “saved” is not enough. Select New chat if you want to repeat the exercise.

Where DevUI fits

The agent resource opens DevUI. In handoff mode, coach shows a workflow graph with the permitted routes between roles. It does not replay this WebUI conversation. Use WebUI for chat and the checks above to confirm the saved result.

Chapter 10 · Specialist workflows

Next: 11. Add the interviewers