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.
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.
- Triage. Starts the workflow with one handoff destination: the receptionist. It has no domain tools.
- 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.
Connect the workflow to the supplied host
Section titled “Connect the workflow to the supplied host”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
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."); 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.
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
private static Workflow CreateHandOffWorkflow(IServiceProvider sp, string key) => throw new NotSupportedException("Complete Make your first handoff before enabling the workflow."); 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
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(); 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
// --- 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. """); // --- 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
// --- 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]); // --- 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.
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
"AgentMode": "Single""AgentMode": "HandOff"Leave the other project-based AppHost settings unchanged.
Watch intake change hands
Section titled “Watch intake change hands”From the working project root:
aspire stop --apphost ./apphost.csdotnet build InterviewCoach.slnxaspire start --apphost ./apphost.csaspire stop --apphost ./apphost.csdotnet build InterviewCoach.slnxaspire start --apphost ./apphost.csWe’ll send one message, then check that the receptionist created an interview record and saved both inputs.
Send the intake message
Section titled “Send the intake message”- In the Aspire dashboard, select Resources. Find the
webuirow and open its link in the URLs column. Keep the dashboard tab open. - 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.
Find this chat’s session ID
Section titled “Find this chat’s session ID”- Return to the Aspire dashboard. Select Structured in the left navigation. This opens Structured logs.
- Set Resource to
webuiand Level to (All). - In the search box at the top, labeled Message filter, enter
Started new chat session with SessionIdand press Enter. - 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.
Check the receptionist’s database calls
Section titled “Check the receptionist’s database calls”- Close Log entry details. Change Resource to
mcp-interview-data. - 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.
Read the saved inputs
Section titled “Read the saved inputs”- In Aspire, return to Resources. On the
cosmosrow, select Data Explorer in the URLs column. - In the emulator page, select Explorer. Expand
interviewdb, theninterviewsessions, and select Items. - In the filter box beside
SELECT * FROM c, enter the expression below. ReplaceYOUR_SESSION_IDwith 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.