Skip to content

🚨 Mission 08: Add Agents to a Workflow

🕵️ CodenameOPERATION HANDOFF⭐ Difficulty⏱️ Time 40 min 🧩 ProductsMicrosoft Copilot StudioMicrosoft DataverseMicrosoft OutlookMicrosoft Teams🏷️ TagsAutomationMulti-Agent🏭 IndustriesHR

Preview

This mission is in preview. Content may change and some steps might not work as intended.

🎯 Mission Brief

Welcome back, Agent. The workflow you built files resumes, but it is mostly deterministic - the trigger fires, Classify uses AI to pick a branch, and the loop files each PDF. None of that can answer which of your five open roles a candidate actually fits, because answering it means reading the document and weighing what is in it.

In this mission you'll add agents to that workflow. An inline agent reads each resume PDF and confirms the role, asking a human when the evidence conflicts. Your published Hiring Agent then does the scoring and the writes, reusing the skills you built in Missions 02, 05 and 06. You'll then read the agents' answers back out and post an Adaptive Card to Teams with links to the Hiring Hub lists.

By the end of this mission an email arrives and a recruiter gets a Teams card naming the candidate, the role, the score and links to the Resume and Job Application lists.

🔎 Objectives

In this mission, you'll learn:

  1. When to use an inline agent and when to call a published one
  2. How to give a workflow agent tools and let it ask a human mid-task
  3. How to pass workflow data into an agent and read its answer back out
  4. Why the Agents connection identity decides whether a tool call succeeds
  5. How to post an Adaptive Card to Teams with links to the Hiring Hub lists

🧠 Two kinds of agent node

In Mission 07 the only intelligence in the workflow was a Classify node - a router that sorts text into categories and nothing more. An Agent node hands a real, open-ended task to an agent that can reason, call tools, and use knowledge. Classify decides which way to go, while an agent decides what the answer is.

Filing a resume is deterministic, while reading the PDF, deciding which role it fits, and creating the application need reasoning. We will use two agent nodes with different scopes:

New agent for this workflowPublished Hiring Agent
Use it whenThe task belongs to this workflow onlyThe same agent is used by chats and other workflows
Set it upWrite the instructions and add tools inside the workflow nodeBuild and publish the agent first, then select it in the workflow node
Agent skillsUses built-in capabilities and tools added to the nodeCan use custom skills, including resume-intake and role-matching
Human assistanceCan use Request human assistanceThe connected-agent node has no human-assistance toggle
Task in this missionRead each PDF and confirm the roleScore each candidate and write the hiring records

Both node types use the same sandbox and built-in document skills. We choose between them according to where the instructions, tools, and skills need to be reused.

Covered in Recruit

In Recruit Mission 07: Automate with Workflows the agent calls the workflow, using the When an agent calls the workflow trigger. In this mission the workflow calls the agent.

🔁 Reading an agent's answer in a later step

An agent node returns its answer as a property on its body, and the two node types use different property names. Wrap each one in coalesce(x, '') so an empty answer becomes an empty string rather than a null:

NodeExpression
Inline agentcoalesce(body('Match_to_an_open_role')?['message'], '')
Connected agentcoalesce(body('Hand_off_to_the_Hiring_Agent')?['result'], '')

The expression shape is the one you used in Mission 07: body('…') is the named node's output, ?['message'] is the property holding the answer, and ? means "if this is missing, give me nothing instead of an error". The name inside the quotes is the node's name with spaces replaced by underscores, which is why each node has to be renamed before you write the expression that reads it.

🔑 Which identity a workflow's tool calls run as

When you chat with an agent, its tool calls run as you. When a workflow calls an agent there is no user interacting with the agent directly, so those calls run under the Agents connection identity instead - a connection stored in the environment rather than a signed-in user.

🔄 Coming from the classic Operative course?

In the classic course, a flow reached AI through an AI Builder prompt, which returns one narrowly-shaped completion at a time. The decision-making stayed in the flow itself, written out condition by condition.

The Agent node moves that reasoning somewhere else. You can scope an inline agent to a single node, or call a published agent that brings its own instructions, tools and skills - one that works through a document, calls tools of its own, and can pause to ask a person a question part-way through.

So the practical difference is in what you write. Instead of conditions for a flow, you write instructions for an agent, then read its structured reply back with an expression.

🧪 Lab 08 - Adding AI Agents to the workflow

Prerequisites

Before you start this lab you need:

  • The Autonomous Resume Intake workflow from Mission 07, filing resumes correctly
  • The Hiring Agent published, carrying the resume-intake, role-matching and application skills from Missions 02, 05 and 06
  • A Microsoft Teams connection, and a channel you can post to
  • An Office 365 Outlook mailbox you can read, for the human request
  • The two sample resume PDFs - see the download in Mission 05

Let's add the reasoning. You'll build the two agent nodes in series after the attachment loop, so the loop finishes filing every attachment first and the agents then run once over all of them.

--- config: look: neo flowchart: useMaxWidth: false --- flowchart TB subgraph SCOPE["Process application · Scope"] direction TB LOOP["For each attachment · Loop<br/>built in Mission 07"] LOOP --> A1["Match to an open role<br/>inline agent · Lab 8.1"] A1 --> A2["Hand off to the Hiring Agent<br/>connected agent · Lab 8.2"] A2 --> N["Notify the recruiter in Teams<br/>Adaptive Card · Lab 8.4"] end

8.1 Add the inline agent that reads the PDFs

  1. Open the Autonomous Resume Intake workflow from Mission 07, then select the after the For each attachment loop, still inside the Process application scope, and choose Agent.

    Add panel with Agent at the top

  2. Rename the node to Match to an open role - select its name at the top of the panel, type the new name and press Enter. Later expressions refer to it as Match_to_an_open_role.

    Agent node renamed to Match to an open role

  3. Leave Agent set to New agent for this workflow, then under Tools select Add tool, choose the Model Context Protocol (MCP) category, select Microsoft Dataverse MCP Server, confirm the connection, and select Add.

    Dataverse MCP server attached under the agent Tools

  4. Turn Request human assistance on, confirm the Human review connection in the Set up Human in the loop dialog, and select Add.

    Request human assistance turned on with its tool

    The dialog adds a tool named Request for information to the Tools list. Questions are emailed to the connection owner.

  5. Paste these Instructions. Note the DATA FOR THIS RUN block at the end - that is how the agent receives its input:

    text
    You are the role-confirmation step of an automated resume intake workflow.
    You never talk to the candidate. You read each applicant's resume PDF, decide
    which OPEN Job Role they should be considered for, and ask a human when that
    choice is genuinely ambiguous. You do NOT create Candidates, Resumes or Job
    Applications - the Hiring Agent does that next, and it does the scoring.
    
    STEP 1 - READ THE OPEN ROLES. Using the Microsoft Dataverse MCP Server, read
    the Active Job Roles (ppa_jobrole): role number and job title (ppa_jobtitle)
    only. Do not read evaluation criteria.
    
    STEP 2 - READ EACH RESUME PDF. For EACH line in the processed resumes list
    below, run exactly one
    query:
    SELECT documentbody, filename FROM annotation WHERE annotationid = '«the
    annotationid on that line»' documentbody is a base64-encoded PDF. Decode it
    in Python and extract the text (pdfplumber is available). Take the candidate's
    full name, email address, current job title and main skills. The
    email body is written by whoever forwarded the application and may be wrong.
    The PDF is the authority. Where the two disagree, trust the PDF and say so.
    
    STEP 3 - DECIDE THE ROLE. For each resume choose the single best-fitting OPEN
    role from the PDF.
    
    STEP 4 - ASK A HUMAN WHEN AMBIGUOUS. A clear PDF match overrides the email without human assistance.
    Read all PDFs before requesting help. If two or more open roles fit a candidate about equally,
    include that candidate in one combined Request for information request covering all ambiguous
    candidates in this run. Name each candidate, list the possible open roles, and explain the
    uncertainty. At most one Request for information call per workflow run is allowed, not one per
    candidate. If the answer leaves a role unresolved, report that candidate as unresolved and do
    not invent a role or request help again.
    
    HARD LIMITS - obey these so the workflow cannot stall:
    - At most 8 tool calls in total.
    - Query each annotationid at most once. Never re-read a PDF you have already
      read.
    - Never retry a failed approach more than once.
    - Never invent a role number, candidate name or email address.
    - If the processed resumes list below is empty, stop and reply exactly: NO
      RESUMES SUPPLIED.
    
    OUTPUT - reply with exactly this block for each resume and nothing else:
    RESUME: «ResumeNumber»
    CANDIDATE: «full name from the PDF»
    EMAIL: «email from the PDF, or the sender address if the PDF has none»
    CURRENT TITLE: «current job title from the PDF»
    SKILLS: «comma-separated key skills from the PDF»
    ROLE: «role number and title»
    ASKED A HUMAN: «yes or no»
    WHY: «one line - the PDF evidence that decided it, or the human's answer»
    
    ---------------------------------------- DATA FOR THIS RUN
    
    Processed resumes (one per line: ResumeNumber | note «annotationid» | filename):
    @{variables('ProcessedResumes')}
    
    From: @{triggerOutputs()?['body/from']}
    Subject: @{triggerOutputs()?['body/subject']}
    Cover letter / email body:
    @{triggerOutputs()?['body/body']}

    Agent instructions ending in the DATA FOR THIS RUN block

8.2 Hand off to the published Hiring Agent

The inline agent has decided which role each candidate fits. Now we'll hand the scoring and the writes to the published Hiring Agent, which already has all the logic to record a new resume.

  1. Add a second Agent node after the Match to an open role node and rename it to Hand off to the Hiring Agent.

    The Agent node in the canvas palette

  2. Fill in the node as follows.

    FieldValue
    AgentHiring Agent (select it from the drop-down list)
    MessageThe block below

    Choosing a published agent removes every inline-agent field and collapses the panel to Message and Output, because a published agent brings its own instructions, tools and skills. That is why the scoring rubric lives here rather than in the inline agent - only a published agent can carry the role-matching skill you built in Mission 05.

    Hiring Agent selected for the handoff node

  3. Select </> Switch to expression mode for Message, then paste:

    The Message field on the Hiring Agent handoff node

    text
    Take in these candidates and create their job applications. The previous step
    has already confirmed WHICH role each candidate should be considered for -
    use that role. You still do the scoring, the intake and all the writes.
    
    Resumes filed by the workflow (one per line: ResumeNumber | note «annotationid» | filename):
    @{variables('ProcessedResumes')}
    
    Sender: @{triggerOutputs()?['body/from']}
    Subject: @{triggerOutputs()?['body/subject']}
    Email body (may be inaccurate - the resume PDF is the authority):
    @{triggerOutputs()?['body/body']}
    
    Role confirmation from the previous step:
    @{body('Match_to_an_open_role')?['message']}
    
    For EACH resume in the list:
    1. Read the resume PDF - it is on the Notes (annotation) table: SELECT
       documentbody, filename FROM annotation WHERE annotationid = '«the
       annotationid on that line»'. documentbody is base64.
    2. Set the Resume's cover letter and summary from the RESUME PDF text, not
       from the email body.
    3. Match the candidate to an existing Candidate record (dedupe on email
       address, then on full name). Only create a new Candidate if there is no
       match. Link the Resume to that Candidate.
    4. Score that candidate against the role confirmed above using the weighted
       rubric from the role-matching skill, reading that role's evaluation
       criteria live from Dataverse.
    5. Create the Job Application for that confirmed role, carrying the score and
       a one-line justification.
    
    Report, for each resume: the ResumeNumber, the Candidate number (and whether
    it was matched or newly created), the Job Role, the score, and the new Job
    Application number. If anything blocks you, say which resume and why rather
    than inventing data.

Confirm the Agents connection before you test

This call runs under the Agents connection identity, not as you, so it needs its own working Dataverse connection. A missing one stops the call on "Permission required". See Which identity a workflow's tool calls run as.

8.3 Verify the agent read the PDF, not the email

Next we will test the agent handling of PDF resumes received by email.

  1. Select Save on the workflow command bar.

  2. Select Publish and wait for publication to finish before sending the email. The email trigger runs the published workflow, so saving alone would test the previous version without the two agent nodes.

    Saved workflow ready for publication before testing

  3. Send one email to the monitored mailbox with both sample resume PDFs attached, using exactly this subject and body:

    text
    Subject: Application - Data Analyst
    
    Hi,
    
    Please find attached the resumes for two candidates applying for the Data
    Analyst role. Both are keen to start as soon as possible.
    
    Thanks, Recruitment Partner

    One email carries two attachments so that a single run exercises the For each attachment loop and the accumulator you built in Mission 07, and produces both outcomes below.

    Email with both sample resume PDFs attached

  4. If the agent requests help, open the actionable card sent by Microsoft Power Automate. Choose J1003 Power Platform Consultant for Avery and J1004 Power Platform Developer for Taylor if those candidates are included, then select Submit. One request can include both candidates. If no request arrives and the node completes, continue to the output check.

    The card is sent to the mailbox of the account that owns the Agents connection. Check Focused, Other and Junk. If the controls do not respond, select Show content to trust the message. The workflow remains Running while it waits for the answer.

    Outlook card asking for Avery's open role

  5. When the run finishes, open the Match to an open role node and read its output. The result includes details taken from the PDFs, including each candidate's email address, current title and certifications:

    text
    RESUME: R#####
    CANDIDATE: Avery Example
    EMAIL: avery.example@example.com
    CURRENT TITLE: Senior Business Applications Consultant
    ROLE: J1003 Power Platform Consultant
    ASKED A HUMAN: yes
    WHY: Human chose J1003 - the PDF's consultant/business-applications profile with PL-200 and
         PL-400 overrides the email's non-existent "Data Analyst" role.

    This is an example. Your record numbers will differ, so check the shape of the result rather than the values.

    The inline agent output block quoting PDF-only details

  6. Check each candidate's ROLE, ASKED A HUMAN, and WHY against the PDF and any human answer. A clear PDF match should override the email without a question. The language model may judge the role evidence differently between runs, so assistance is required only when the PDF leaves a genuine ambiguity. The illustrated output is Avery's result; verify Taylor's complete result in the same node output.

    Avery's confirmed role and human assistance result

  7. Open the Job Applications table in the Hiring Hub app (see Mission 01 if you need the route) and confirm the applications exist, and that both candidates were matched to existing Candidate records rather than duplicated.

    Job Applications created from both resumes

8.4 Notify the recruiter with a list-linked Teams card

Next we will send the recruitment team the match result and links to the Resumes and Job Applications lists in Hiring Hub. The recruiter can locate the records by the numbers reported in the card.

  1. On the canvas, select Add a step after Hand off to the Hiring Agent inside the Process application scope.

    Add a step after the Hiring Agent handoff

  2. In the Add panel's search box, type post card.

    Add panel search box holding post card

  3. The results are grouped by connector, and Microsoft Teams is the first group. Under it, select Post card in a chat or channel.

    Post card action under the Microsoft Teams group

  4. Rename the node to Notify the recruiter in Teams - select its name at the top of the panel, type the new name and press Enter.

    Teams node renamed to Notify the recruiter in Teams

  5. In the Connection box select Not connected, then Create new connection, then Create, and select your account tile. Teams needs its own connection - the Agents connection from Mission 07 does not cover it. Once connected, the box shows your account with a green tick.

    Teams action connected under your own account

  6. Fill in the node as follows. Leave Card Type ID and IsAlert empty.

    FieldValue
    Post asFlow bot (already selected)
    Post inChat with Flow bot
    RecipientType your own email address, then select yourself from the list that appears so your name becomes a person chip
    Adaptive CardThe JSON below, assembled in a text editor first

    Setting Post in makes a Post card request group appear containing Recipient, Adaptive Card, Card Type ID and IsAlert. Before you connect, those fields read Could not load options and Fill in dependent fields first, which is expected.

    If you only type an address into Recipient and move on without picking the person from the list, the node keeps its Needs setup badge and saving fails with body/recipient: Recipient is required.

    Post in set to Chat with Flow bot with recipient

  7. Select </> Switch to expression mode for Adaptive Card, then copy the finished card below and paste it into the expanded editor in one go.

    Before you paste, replace both «AppUrl» placeholders with your literal app URL: the part of the Hiring Hub address up to and including the appid. Copy it straight from your browser's address bar while the Hiring Hub app is open, keeping everything from https:// up to the end of the appid= GUID - for example https://«your-org».crm.dynamics.com/main.aspx?appid=«HiringHubAppId».

    text
    {
      "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
      "type": "AdaptiveCard", "version": "1.4", "body": [
        { "type": "TextBlock", "text": "New application received", "weight": "Bolder", "size": "Medium" },
        { "type": "TextBlock", "text": "Filed automatically by the Autonomous Resume Intake workflow.", "wrap": true, "isSubtle": true },
        { "type": "FactSet", "facts": [
          { "title": "From", "value": "@{replace(replace(replace(replace(coalesce(triggerOutputs()?['body/from'],''),decodeUriComponent('%5C'),'/'),decodeUriComponent('%22'),''),decodeUriComponent('%0D'),''),decodeUriComponent('%0A'),decodeUriComponent('%5Cn'))}" },
          { "title": "Subject", "value": "@{replace(replace(replace(replace(coalesce(triggerOutputs()?['body/subject'],''),decodeUriComponent('%5C'),'/'),decodeUriComponent('%22'),''),decodeUriComponent('%0D'),''),decodeUriComponent('%0A'),decodeUriComponent('%5Cn'))}" }
        ]},
        { "type": "TextBlock", "text": "Resumes processed", "weight": "Bolder", "spacing": "Medium" },
        { "type": "TextBlock", "text": "@{replace(replace(replace(replace(coalesce(variables('ProcessedResumes'),''),decodeUriComponent('%5C'),'/'),decodeUriComponent('%22'),''),decodeUriComponent('%0D'),''),decodeUriComponent('%0A'),decodeUriComponent('%5Cn'))}", "wrap": true },
        { "type": "TextBlock", "text": "Role match", "weight": "Bolder", "spacing": "Medium" },
        { "type": "TextBlock", "text": "@{replace(replace(replace(replace(coalesce(body('Match_to_an_open_role')?['message'],''),decodeUriComponent('%5C'),'/'),decodeUriComponent('%22'),''),decodeUriComponent('%0D'),''),decodeUriComponent('%0A'),decodeUriComponent('%5Cn'))}", "wrap": true },
        { "type": "TextBlock", "text": "Intake and applications", "weight": "Bolder", "spacing": "Medium" },
        { "type": "TextBlock", "text": "@{replace(replace(replace(replace(coalesce(body('Hand_off_to_the_Hiring_Agent')?['result'],''),decodeUriComponent('%5C'),'/'),decodeUriComponent('%22'),''),decodeUriComponent('%0D'),''),decodeUriComponent('%0A'),decodeUriComponent('%5Cn'))}", "wrap": true }
      ], "actions": [
        { "type": "Action.OpenUrl", "title": "Open resumes in Hiring Hub",
          "url": "«AppUrl»&pagetype=entitylist&etn=ppa_resume" },
        { "type": "Action.OpenUrl", "title": "Open job applications",
          "url": "«AppUrl»&pagetype=entitylist&etn=ppa_jobapplication" }
      ]
    }

    Expression mode keeps the complete JSON in one multiline editor. Editing the long values in the one-line token picker turns them into chips and makes it easy to leave a stray quote that fails only when the card runs.

    Adaptive Card JSON in expression mode

    Why the four replace calls

    Everything inside "text" has to survive as a JSON string. An agent writes for humans, so it will eventually write a sentence like the email claimed "Data Analyst" - and that bare " closes the string early, making the whole card invalid. Teams rejects it with The specified Teams flowbot message's message body is invalid JSON.

    Each nested replace neutralizes one character class: %5C backslash becomes /, %22 double quote is removed, %0D carriage return is removed, and %0A newline becomes the two characters \n that JSON wants. They are written as decodeUriComponent(...) because the expression editor cannot express a bare double quote inside a single-quoted literal - an escaped one produces a replace that matches nothing, reports no error, and leaves the quotes in place.

    The coalesce(…, '') at the centre handles the empty case: an agent that returned nothing yields an empty string rather than a null that would break the card.

    The app URL is hard-coded here on purpose - get the card working first with a value you can see. Mission 09 explains how an environment variable makes those links work after a move to another environment.

    Both buttons open a list, not a single record

    A per-record link would need the row's GUID, and nothing in the workflow ever learns it because the agent created that row - the only identifier anyone sees is the short Application Number, which pagetype=entityrecord will not accept. Opening the list avoids the problem, and the card already carries the agent's report so the reviewer can spot the right row immediately. If you want a direct link later, look the row up by its number with a List rows step and use the GUID that returns.

  8. Replace «your-org» with your own org name and «HiringHubAppId» with your Hiring Hub app id - you can read both from the browser address bar while the Hiring Hub app is open. No « characters should remain anywhere in the card.

    Configured Teams card action inputs

  9. On the command bar select Save. The Needs setup badge disappears from the node.

    Numbered execution order: Match, Hand off, Notify

Now test it by posting a real card. Posting sends a real message, so use a recipient or channel you own.

  1. Publish the workflow, then email the monitored mailbox another application with only Taylor's PDF attached. This notification test processes one candidate; the two-candidate test is in Lab 8.3. Now that an Agent node sits in the branch this run takes 4-6 minutes, almost all of it inside the agent, and it stays at Running the whole time. That is normal - do not assume it has hung and start re-sending email.

    The published workflow carrying the Teams notification

  2. Open the run in Activity and select the Notify the recruiter in Teams node. It returns a Teams Message ID and a Message link to the posted card.

    Successful workflow run with Teams message details

  3. Open the recipient's Teams Workflows chat. The card renders with the match facts and both buttons.

    The recruiter card posted to the Workflows chat

  4. Review the Role match section and confirm it carries Taylor's PDF-grounded decision, including whether the inline agent asked a person.

    The card's Role match section

  5. Scroll to the bottom and confirm the Hiring Agent matched Taylor's existing Candidate record and the two list-link buttons are present.

    Hiring Hub list buttons on the recruiter card

  6. Select Open resumes in Hiring Hub. Find the Resume Number reported in the card in the Hiring Hub Resumes list, and check its candidate and title.

    Newly filed resume in Hiring Hub

The successful run confirms that the workflow passed the PDF-grounded role match to the Hiring Agent and posted the resulting application details to Teams. The card gives the recruiter the match result and links to the Resume and Job Application lists in Hiring Hub.

✅ Mission Complete

Your workflow now processes resume PDFs, records the hiring results, and sends a Teams notification.

You can now:

Inline agents: You added an agent scoped to one workflow node, gave it the Dataverse MCP server, and let it ask a human when the evidence conflicted.

Connected agents in a workflow: You handed the scoring and the writes to your published Hiring Agent, reusing every skill you'd already built.

Reading an agent's answer: You pulled a structured reply out of an agent node with coalesce and fed it into the next step.

Evidence over assertion: You proved the agent read the resume PDF rather than the email body that contradicted it.

List-linked notifications: You posted an Adaptive Card to Teams with links to the Resume and Job Application lists in Hiring Hub.

⏭️ Move to Human Oversight and Handling Alternative Flows mission

📚 Tactical Resources

🔗 Add an agent to a workflow

🔗 Adaptive Cards

🔗 Adaptive Card designer

🔗 Microsoft Teams connector reference

Analytics