Scenario: from an idea to a delivery backlog
A business user types an idea into a Copilot Studio agent. The product squad researches it, plans it, produces the stakeholder deliverables, and hands back a structured backlog — which the agent then creates as epics, stories, and tasks in Azure DevOps, Jira, or GitHub. This page is the whole path, end to end, so you can reproduce it.
Two things this scenario is not. The server never writes to Azure DevOps, Jira, or GitHub — the agent does, through the certified native connector on the user's own connection. And the squad's output is advisory: finished text and a validated plan, not executed code.
What you will have at the end
- A deployed MCP server in your own Azure tenant, with the product profile and the business tools enabled.
- A Copilot Studio agent with two connectors: this server, and your native work-tracking connector.
- A repeatable conversation: "turn this PRD into a backlog" → confirmation → work items created, correctly parented, with acceptance criteria intact.
- A durable trail under
.copilot-tracking/in your own storage, read back automatically on the next run.
Part 0 — Prerequisites
- An Azure subscription you can deploy to, and permission to create an Entra app registration.
- An Azure OpenAI deployment. This is where the cost is.
- A Copilot Studio licence, and the ability to add a custom connector.
- Azure DevOps, Jira, or GitHub, with a licence that permits the native connector.
- The repository cloned, and
npm ci && npm run buildrun once.
Part 1 — Deploy with the product path switched on
Follow host/RUNBOOK.md steps 1–7
for the base deployment. This scenario needs more than the default advisory posture, so set the following before
Step 6 (they are main.bicepparam parameters as well as environment variables):
| Setting | Value | Why this scenario needs it |
|---|---|---|
SQUAD_MCP_REMOTE_PIPELINE_ENABLED | true | Serves squad_run and squad_status. Off by default. |
SQUAD_MCP_RUN_STATE_BACKEND | table | A product run is long. Table state survives scale-to-zero and makes approval visible across replicas. |
SQUAD_MCP_STORAGE_ACCOUNT | your account | Backs run state, memory, and overflow. |
SQUAD_MCP_WORKER_ENABLED | true | A 10-stage product run exceeds the request path. The worker drives it off-request. |
SQUAD_MCP_ENABLE_BUSINESS_TOOLS | true | Serves squad_backlog — the JSON contract this whole scenario turns on. |
SQUAD_MCP_ENABLE_MEMORY | true | Persists the .copilot-tracking/ tree. |
SQUAD_MCP_MEMORY_BACKEND | table or graph | Where that tree lands. Decide this in Part 2 before you deploy. |
SQUAD_MCP_MEMORY_AUTO_ENABLED | true | Reads prior state back into context automatically, so the backlog call already knows what the run decided. |
SQUAD_MCP_ENABLE_ARTIFACTS | true | Writes the ledger — team.md, routing.md, state.json — so the squad is inspectable. |
SQUAD_MCP_ADVISORY_AUTOPILOT_ENABLED | true | Optional but recommended here. See the note below. |
SQUAD_MCP_RUN_ENCRYPTION_KEY_B64 | a base64 key | Encrypts stage artifacts and caller input at rest. Requirements documents are usually confidential. |
About autopilot. squad_run normally holds at a Human Gate and needs an operator to
release it — which is fine for you, and confusing for a business user who just wants a backlog. The product
profile carries no impactful role (no deployer, no IaC author, no backlog executor), so it resolves as
advisory-only, and SQUAD_MCP_ADVISORY_AUTOPILOT_ENABLED=true lets it run to completion unattended.
This narrows the gate; it does not remove it. Ask for anything that routes an impactful role and the run still holds. If you would rather keep every run gated, leave it off and use the operator release in Part 6.
Part 2 — Decide where the history is saved
This is the decision people skip, and then discover after their first real run that a month of squad output lives somewhere nobody can open. Decide it now — it is one setting, and it is the same setting for everything the squad writes.
What actually gets written
With SQUAD_MCP_ENABLE_ARTIFACTS=true a run writes a browsable .copilot-tracking/ tree —
the same tree the squad produces when it runs locally in VS Code:
squad/team.md the 13 seeded roles
squad/routing.md the rows those roles can serve
squad/state.json the ledger
squad/decisions.md append-only — incl. the Intake Readiness Verdict
squad/notifications.md append-only
squad/history/<agent>.md per agent, with a measured Consumption block
squad/history/autopilot-run-<id>.md per run
squad/consumption.md rebuilt from those blocks, so earlier turns are never dropped
plans/ · docs/ · outputs/ · ppt/<date>/<slug>/ the deliverables themselves
Without SQUAD_MCP_ENABLE_ARTIFACTS, auto-memory keeps three flat keys. That is
enough for continuity between two turns and nothing you can audit — you cannot open the PRD a run produced or see
which agent wrote what. For this scenario, turn it on.
Pick a destination
One setting, SQUAD_MCP_MEMORY_BACKEND, chooses it for memory, the ledger, and the deliverables at once.
| Backend | Where it lands | Choose it when | What you must do |
|---|---|---|---|
tabledefault |
Azure Table Storage in your account, table squadmemory |
You want it durable, cheap, and multi-replica safe, and you read it through the tools rather than by hand. | Set SQUAD_MCP_STORAGE_ACCOUNT. Nothing else — main.bicep assigns
Storage Table Data Contributor to the app and worker identity for you. |
graph |
A SharePoint document library or OneDrive, one readable .md file per entry |
Business users need to open the output themselves. For a product-profile scenario this is usually the right answer. | Set SQUAD_MCP_MEMORY_GRAPH_DRIVE_ID and run a separate, admin-privileged deployment —
see below. |
file |
A local directory in the container | A single-replica trial. Not durable across a scale-to-zero restart. | Set SQUAD_MCP_MEMORY_DIR. Do not combine with the worker. |
Run state and memory are two different stores. SQUAD_MCP_RUN_STATE_BACKEND holds the
in-flight run so it survives a restart; SQUAD_MCP_MEMORY_BACKEND holds the history you keep. They can
differ — table run state with graph memory is a normal combination — and they both read
SQUAD_MCP_STORAGE_ACCOUNT, which is why the two are easy to confuse.
If you chose SharePoint
Each entry becomes one markdown file at:
<rootPath>/<tenantId>/<project>/<path>.md
versioned by SharePoint and subject to your existing retention, search, and DLP policy. Concurrency uses Graph's
native eTag with If-Match, so a stale write loses the race rather than clobbering.
Two steps you must perform yourself:
-
Resolve the drive id of the library that will hold the history:
SITE_ID=$(az rest --method GET \ --url "https://graph.microsoft.com/v1.0/sites/<TENANT>.sharepoint.com:/sites/<SITE_PATH>" \ --query id --output tsv) az rest --method GET \ --url "https://graph.microsoft.com/v1.0/sites/$SITE_ID/drives" \ --query "value[].{name:name,id:id}" --output table -
Grant the app identity access to that one library, as an administrator:
This assignsaz deployment group create \ --resource-group "$RG" \ --template-file host/infra/graph-memory-permissions.bicep \ --parameters host/infra/graph-memory-permissions.bicepparamSites.Selected— which on its own grants access to no site — and then grants write on exactly the site you named. It is a separate deployment on purpose: it needsAppRoleAssignment.ReadWrite.AllandSites.FullControl.All, far above what deploying the Container App needs, so your routine deploys never require Graph admin rights. Re-running it is a no-op.
Leave SQUAD_MCP_MEMORY_GRAPH_ENCRYPT off unless policy forces it. The entire reason
to target SharePoint is that a human can open the file; encrypting the content defeats that. If you do enable it,
configure runEncryptionKeyBase64 as well.
Leaving sharePointSiteId empty assigns Sites.Selected only. That is a deliberate safe
partial state — the identity is eligible but reaches nothing, so a half-finished onboarding never silently
exposes a library.
Large artifacts
A table entity has a size ceiling, and a PRD or a full backlog will exceed it. Enable overflow so the write does not fail:
| Variable | Value |
|---|---|
SQUAD_MCP_MEMORY_OVERFLOW_ENABLED | true |
SQUAD_MCP_MEMORY_OVERFLOW_CONTAINER | a blob container name |
SQUAD_MCP_MEMORY_OVERFLOW_THRESHOLD_BYTES | 32768 default |
Anything past the threshold spills to blob with a pointer left in the entity. Reads are transparent.
Offering a choice to different teams
If one deployment serves several teams that must not share a destination, declare an allow-list instead of a single backend:
SQUAD_MCP_MEMORY_TARGETS=[
{ "name": "azure", "backend": "table", "tableName": "squadmemory" },
{ "name": "sharepoint", "backend": "graph", "driveId": "<DRIVE_ID>", "rootPath": "squad-memory" }
]
SQUAD_MCP_MEMORY_DEFAULT_TARGET=azure
The caller then passes an opaque target name. You own every credential-bearing field;
an undeclared name is rejected before any I/O and never falls back to the default.
Reading it back
There are two paths, and for this scenario the first one is the one that matters:
-
Automatic. With
SQUAD_MCP_MEMORY_AUTO_ENABLED=truethe server reads the project'sstateanddecisionsbefore every dispatch and injects them as delimited data — never authority. Nobody has to remember to ask for it. This is why thesquad_backlogcall in Part 7 does not need the run's artifact pasted back in. -
On demand.
squad_historybrowses the tree —op=indexto summarize,op=listto enumerate,op=readto open one file. It needs theSquad.Memoryscope.
Turn the memory tools off in the agent. With automatic memory on, the generated instructions'
memory section must be deleted. Left in, the agent calls the tools too, invents a different
project name each session, and continuity silently disappears — which looks exactly like the feature
not working.
What is true regardless of destination
- The validated token's
tenantIdis always the first path segment, so tenant isolation holds on Azure Table, SharePoint, and disk alike. - The partition is derived from a pinned sub-squad or
SQUAD_MCP_MEMORY_DEFAULT_PROJECT— it is never taken from caller free text. - With
SQUAD_MCP_RUN_ENCRYPTION_KEY_B64set, stage artifacts, the Council Verdict, and callerrequest/contextare AES-256-GCM encrypted at rest. - Writes are compare-and-swap, so two concurrent runs in one project cannot clobber each other.
Part 3 — Expose the scopes this scenario uses
In the Entra app registration from Step 2 of the runbook, expose and consent to:
| Scope | Granted to | For |
|---|---|---|
Squad.Run | the Copilot Studio connection | Starting the product run and polling it with squad_status. |
Squad.Backlog | the Copilot Studio connection | squad_backlog. Separate from Squad.Business so it is independently revocable. |
Squad.Memory | the Copilot Studio connection | Optional — only if you want the agent to browse history with squad_history. |
Squad.Operate | you, as an app role | Releasing a held run. Never grant this to the agent. |
Every scope is fail-closed: a missing one returns 403 with no work performed and nothing billed.
Part 4 — Import the connector into Copilot Studio
-
Regenerate the connector so it reflects your build, then fill in the placeholders in
generated/copilot-studio-connector/apiDefinition.swagger.jsonandapiProperties.json:npm run generate:connectorPlaceholder Replace with <SQUAD_MCP_HOST>your Container App FQDN, host only, no scheme <ENTRA_TENANT_ID>your tenant id <ENTRA_CLIENT_ID>the app id from Step 2 <SQUAD_MCP_AUDIENCE>api://<ENTRA_CLIENT_ID> - Add a custom connector in Copilot Studio from the OpenAPI file. It advertises the
x-ms-agentic-protocol: mcp-streamable-1.0/mcpoperation. - Complete the Entra OAuth 2.0 connection, consenting to the scopes in Part 3.
- Enable generative orchestration on the agent. Without it the agent cannot call MCP tools at all — this is the single most common reason "nothing happens".
- Add your work-tracking connector to the same agent: the certified Azure DevOps, Jira, or GitHub connector, authenticated as the end user.
- Paste the generated agent instructions from
generated/copilot-studio-connector/agent-instructions.mdinto the agent's Instructions field. It already contains the create-work-items protocol, the confirmation requirement, and the parenting rules. Delete the memory section — you enabled automatic memory in Part 1, so the agent must not call the memory tools.
Part 5 — What the product profile actually runs
profile: "product" seeds 13 roles, and that seeding is what makes this scenario different from a
generic squad_run. Resolved against the bundled cast, a product run sequences:
| # | Stage | Role | Resolved agent |
|---|---|---|---|
| 0 | Intake gate | intake-validator | PRD Quality Reviewer |
| 1 | Research | researcher | Squad Researcher |
| 2 | Plan | lead | Squad Lead |
| — | Council | Interleaved here only when the request engages it. A backlog request usually does not. | |
| 3 | Deliverable fan-out | analyst | PRD Builder |
| 4 | product-owner | Functional Planner | |
| 5 | designer | UX UI Designer | |
| 6 | experimenter | Experiment Designer | |
| 7 | presenter | PowerPoint Subagent | |
| 8 | technical-writer | Squad Technical Writer | |
| 9 | data-scientist | DS Gen Data Spec | |
| 10 | Review | tester | Squad Reviewer |
Two behaviours here are worth understanding before you run it:
-
The intake gate can stop the run. Because the product profile seeds
intake-validator, a run grounded in a requirements document is validated first. The validator recordsReady,Ready-With-Gaps, orNot-Ready. OnNot-Readythe pipeline halts withreason: "intake_not_ready"rather than letting seven specialists build on inputs it just rejected. That is the gate working — fix the document and re-run. -
There is no separate backlog-handoff stage. The product profile carries
product-ownerinside the fan-out, so the handoff is stage 4. A profile without it gets a backlog stage appended instead.
Each specialist writes to its own root, which is where you go looking afterwards:
| Role | Deliverable root |
|---|---|
| analyst, product-owner, designer, experimenter | .copilot-tracking/plans |
| presenter | .copilot-tracking/ppt/<date>/deck |
| technical-writer | docs |
| data-scientist | outputs |
Part 6 — Run it
In the Copilot Studio agent, the user says something like:
Run the full product squad on our customer onboarding PRD and turn it into a delivery backlog.
The agent calls squad_run. Make sure your instructions pass the profile:
{
"name": "squad_run",
"arguments": {
"request": "Turn our customer onboarding PRD into a delivery backlog",
"profile": "product",
"mode": "autopilot",
"squad": "onboarding",
"context": "<the PRD text, or the constraints the user gave>"
}
}
squad is the project partition. Use a stable name per initiative — it scopes the memory, the ledger,
and the history that the next run reads back. default works, but everything then shares one tree.
What comes back depends on your Part 1 choice:
| Autopilot | Behaviour |
|---|---|
| enabled | The run proceeds unattended. The agent gets a run id; the user polls with squad_status until
the compiled artifact returns. |
| disabled | The run holds. The agent reports the run id and says it awaits approval — it must not claim the work is
done. You release it out of band:
Then a squad_status poll drives it to completion. The release is tenant-scoped and audited with
approver and timestamp. |
Part 7 — Turn the result into work items
The compiled artifact is prose — good for a human, wrong for a connector. squad_backlog is the
converter:
{
"name": "squad_backlog",
"arguments": {
"request": "Break the approved onboarding plan into epics, stories, and tasks",
"squad": "onboarding"
}
}
With automatic memory on you do not have to paste the run's output back in — the pre-read merges the project's
prior state into context as data, so passing the same squad is enough. Pass
context explicitly only to add something the run never saw, such as an existing backlog or a
definition of done.
The contract you get back
{
"summary": "Plain-language summary for the business user.",
"epics": [ { "title": "...", "description": "...",
"acceptanceCriteria": ["..."],
"stories": [ { "title": "...", "tasks": [ ... ] } ] } ],
"workItems": [
{ "ref": "E1", "type": "Epic", "title": "...", "acceptanceCriteria": ["..."] },
{ "ref": "E1-S1", "type": "User Story", "parentRef": "E1", "estimate": "M", "...": "..." },
{ "ref": "E1-S1-T1", "type": "Task", "parentRef": "E1-S1", "...": "..." }
]
}
workItems is a depth-first flattening of epics, so parents always precede their
children. Iterating it in order and creating as you go means a parent id always exists by the time a child
needs it. ref and parentRef are assigned by the server, not the model — which is the point:
the agent links by id it recorded, never by matching titles, because titles are not unique and a model will rephrase
them.
The server validates and normalizes the model's JSON and fails cleanly if it cannot, so the agent never receives half-parsed output. Hard caps apply — 30 epics, 30 stories per epic, 30 tasks per story, 20 acceptance criteria, 4000 characters per text field — so one call can never emit an unbounded backlog.
The agent's create loop
This is already in the generated instructions; it is repeated here so you can verify the behaviour you see:
- Show the user the
summaryand the epic/story list. Ask for confirmation. Never bulk-create unconfirmed. - On confirmation, iterate
workItemsin order, one connector call per element: type fromtype, title fromtitle, description fromdescription, and theacceptanceCriterialines joined as a list. - Record the created id against the element's
ref. When an element has aparentRef, link it to the id recorded for that ref. - On a failure, report which
reffailed, continue with the rest, then offer to retry only the failures. - Pace the calls. The connectors are rate limited per connection — create one epic and its stories at a time rather than firing the whole array at once.
Part 8 — Mapping to your tracker
The contract uses the Azure DevOps type names. That is a naming choice, not a lock-in — the mapping happens in your agent's instructions, and the server is unaware of which tracker you use.
Contract type | Azure DevOps | Jira | GitHub |
|---|---|---|---|
Epic | Epic | Epic | Issue labelled epic |
User Story | User Story (Product Backlog Item on Scrum) | Story | Issue labelled story, as a sub-issue of the epic |
Task | Task | Sub-task | Sub-issue, or a task-list item on the story |
| parenting | Add link, parent/child | issue link / parent field | sub-issues |
Check your ADO process template. User Story exists in Agile; Scrum calls it
Product Backlog Item and CMMI calls it Requirement. If creates fail with an unknown work item
type, add the substitution to your agent instructions — the server will keep emitting
User Story.
GitHub has no native epic. The three-level hierarchy maps onto issues plus sub-issues, so decide the convention up front and put it in the instructions. Otherwise the agent will invent one, differently each time.
Part 9 — Verify it worked
| Check | Expected |
|---|---|
| The run's ledger | team.md lists the 13 product roles; routing.md shows only rows those roles can serve. |
| The decision log | An ## Intake Readiness Verdict block, if the run was grounded in a document. |
| Deliverable roots | Artifacts under .copilot-tracking/plans, docs, and outputs. |
| The tracker | Every ref created once, children parented, acceptance criteria present on stories. |
| The next run | Ask a follow-up with the same squad. It should already know what was decided. |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| The agent answers from its own knowledge and never calls a tool | Generative orchestration is off, or the instructions were not pasted | Enable it, and paste the generated instructions block |
squad_run is not in the tool list | The pipeline is disabled | SQUAD_MCP_REMOTE_PIPELINE_ENABLED=true plus a durable run-state backend |
squad_backlog is not in the tool list | Business tools are off | SQUAD_MCP_ENABLE_BUSINESS_TOOLS=true, and a model endpoint must be configured |
| 403 on a tool call | The scope was never consented | Grant the scope in Part 2 and re-authorize the connection |
| 401 on every call | Token audience or issuer not accepted | Check <SQUAD_MCP_AUDIENCE> matches api://<client-id> |
403 origin_not_allowed | Origin allow-list | Add the caller origin to SQUAD_MCP_ALLOWED_ORIGINS. * is rejected at startup |
| The run never finishes | It is held at the gate, or exceeds the request path | Approve it, or enable autopilot; and enable the worker for long runs |
The run stops early with intake_not_ready | The intake gate rejected the inputs | Read the verdict, fix the requirements document, re-run. This is intended behaviour |
| The squad has the wrong roles | The project already had a squad | The seeded roster wins over the profile hint. Use a new squad name |
| Only one giant work item was created | The agent parsed prose instead of calling squad_backlog | Confirm the tool is reachable and the instructions route backlog requests to it |
| Children are attached to the wrong parent | The agent matched on title | It must match on ref. Re-paste the generated instructions |
| Creates fail partway with throttling | The whole array was fired at once | Batch: one epic and its stories per pass |
| The next run remembers nothing | The agent still calls the memory tools and invents a new project name each session | Delete the memory section from the agent instructions, and pass a stable squad |
| A large artifact fails to save | Table entity size ceiling | Enable SQUAD_MCP_MEMORY_OVERFLOW_ENABLED with a blob container |
| Nothing appears in the SharePoint library | Sites.Selected was assigned but no site was granted | Re-run graph-memory-permissions.bicep with sharePointSiteId filled in |
| History vanishes after a restart | The file memory backend on a scale-to-zero app | Move to table or graph |
What this scenario deliberately does not do
- It does not write to Azure DevOps, Jira, or GitHub from the server. Every write is the agent, on the user's own connection, under that connector's auth, DLP, and throttles.
- It does not execute code, open a pull request, or deploy anything.
- It does not let the model or the caller release a Human Gate. Only
Squad.Operatedoes, out of band. - It does not treat the PRD as instructions. Caller content is data; only a persona charter carries authority.