I tried to build a trustworthy Copilot agent using Copilot Studio and quickly ran into hard platform limits: inconsistent citations, non-clickable sources, and no reliable control over how answers are rendered. The experience reinforced a familiar truth: low-code works until it suddenly doesn’t.
Microsoft 365 Agents Toolkit and declarative agents exist precisely to solve this problem — and once I switched, everything clicked.
The Original Goal: A Trustworthy Copilot Agent
I started with what seemed like a straightforward goal: build a useful Copilot agent that calls custom backend logic and returns trustworthy, source-backed answers. Not a creative assistant, not a chatty helper — but an agent suitable for regulatory, policy, and compliance questions, where accuracy and traceability matter more than tone.
Naturally, I began with Copilot Studio. It promises low-code extensibility, API integration, and built-in support for sources — exactly what such an agent should need.
And at first, it worked.
But very quickly, I ran into a familiar pattern that anyone who has spent enough time with low-code platforms will recognize:
answers without sources, then answers with sources, then sources that are not clickable, then clickable sources that disappear behind a generic “Sources” button — and finally, no way to reliably control how any of this is rendered.
At that point, the problem was no longer about configuration. It was architectural.
Phase 1 – Copilot Studio: Where Things Start to Break
Copilot Studio is optimized for:
- conversational flows,
- orchestration,
- and productivity scenarios.
That becomes obvious as soon as you try to build something deterministic.
What We Implemented
- A custom Copilot action
- Calling a custom WebAPI
- Returning structured data:
{ "answer": "...", "sources": [...]}
On paper, this should be enough.
In practice, it isn’t.
Mine #1 – You Don’t Own the Rendering
Depending on how the flow was wired:
- sometimes the answer appeared,
- sometimes sources were dropped,
- sometimes they were shown but not clickable,
- sometimes a “Sources” button appeared — with nothing behind it.
There is no strict contract between:
- the API response,
- the Copilot action output,
- and how the UI is rendered.
For a compliance use case, that alone is a deal-breaker.
Mine #2 – The “Sources” Button Is Not Yours
The built-in Sources panel in Copilot only understands native Copilot citations.
If your backend returns:
{ "title": "EUR-Lex Regulation (EC) No 1333/2008", "url": "https://eur-lex.europa.eu/..."}
Copilot Studio:
- does not treat this as a citation,
- does not wire it into the Sources panel,
- and gives you no override mechanism.
You end up with a polished UI element that looks right — and does nothing.
Mine #3 – Clickability Depends on the Host
Even when you try to render sources manually:
- markdown links may work in one host,
- silently fail in another,
- or behave differently in Teams, Copilot Web, or Power Apps “minimal”.
This is the classic low-code trap:
abstraction leaks exactly when correctness matters.
The Core Problem
The real issue wasn’t Copilot Studio itself.
The issue was this:
Copilot Studio orchestrates — it does not let you define.
And for compliance-grade agents, definition matters more than orchestration.
Phase 2 – Microsoft 365 Agents Toolkit and Declarative Agents
If you watched the recent Ignite sessions, this will sound familiar:
Declarative agents are for scenarios where you need control.
This is not an incremental improvement — it’s a different model.
Step 1 – Declare the Agent Explicitly
declarativeAgent.json
{ "name": "Božo", "description": "Internal Copilot agent for deterministic, source-backed regulatory answers", "actions": [ { "id": "AnswerRich", "file": "AnswerRich.json" } ]}
No guessing.
No “maybe Copilot will pick this up”.
Step 2 – Define a Real API Contract
openapi.yaml
paths: /answer: post: operationId: AnswerRich requestBody: content: application/json: schema: type: object properties: question: type: string responses: "200": description: Answer content: application/json: schema: type: object properties: answer: type: string sectionReference: type: object sources: type: array
The operationId must match the declarative action.
This strictness is a feature, not a limitation.
Step 3 – Backend as the Single Source of Truth
The backend returns a fully structured payload:
{ "answer": "The official Slovene name for E104 is Quinoline Yellow.", "sectionReference": { "celex": "02008R1333-20250731", "article": "Annex II, Part A" }, "sources": [ { "title": "EUR-Lex Regulation (EC) No 1333/2008", "url": "https://eur-lex.europa.eu/..." } ]}
No transformation.
No hidden interpretation.
No hallucination.
Step 4 – Adaptive Card Rendering (The Turning Point)
AnswerRich.json (simplified)
{ "type": "AdaptiveCard", "version": "1.5", "body": [ { "type": "TextBlock", "text": "${answer}", "wrap": true, "weight": "Bolder" }, { "type": "Container", "isVisible": "${sources[0] != null}", "items": [ { "type": "TextBlock", "text": "Sources", "weight": "Bolder" }, { "type": "Container", "selectAction": { "type": "Action.OpenUrl", "url": "${sources[0].url}" }, "items": [ { "type": "TextBlock", "text": "• ${sources[0].title}", "isSubtle": true } ] } ] } ]}
Why This Works
- No markdown links
- No Copilot “Sources” panel
- Clickability via
Action.OpenUrl - Deterministic rendering across hosts
This completely bypasses the low-code rendering minefield.
Mine #4 – Adaptive Card “Errors” That Aren’t Errors
VS Code will complain:
Expected boolean, got string
These are schema warnings, not runtime failures.
The runtime does evaluate expressions like:
"${sources[0] != null}"
This is poorly documented — but critical to know.
Step 5 – Fail-Closed Instructions
instruction.txt
You are Božo, a Microsoft 365 Copilot declarative agent.CRITICAL RULE:For any factual or regulatory question, you MUST call the AnswerRich action.Do not answer from general knowledge.If the action fails:- State that the backend is unavailable.- Do not guess.
This enforces:
- no hallucinations,
- explicit failures,
- backend authority.
Low-code flows cannot guarantee this.
Mine #5 – Tenant-Level Deployment Block
Even with everything correct, deployment initially failed:
Deployment blocked: Advanced declarative agent can't be deployed
Root cause:
- Declarative agents require tenant-level enablement
- Feature flag must be enabled by Microsoft
- UI does not show this unless enabled
This requires a support ticket — and explains many “it works locally but not in tenant” stories.
Publishing Flow
Ctrl + Shift + PTeams: Provision in the cloudTeams: Zip App Package- Upload to Teams Admin Center
- Create App permission policy
- Assign to test group
Only then does the agent appear in Copilot.
When to Use What
| Use case | Copilot Studio | Agents Toolkit |
|---|---|---|
| Conversational helpers | ✅ | ❌ |
| Compliance / legal | ❌ | ✅ |
| Deterministic output | ❌ | ✅ |
| Clickable, auditable sources | ❌ | ✅ |
| Rendering control | ❌ | ✅ |
Final Takeaway
This experience reinforced a simple truth:
Low-code platforms work — until you need guarantees.
Copilot Studio is excellent for orchestration.
Microsoft 365 Agents Toolkit exists for ownership.
Declarative agents are not “more complex Copilot Studio agents”.
They are the point where you stop configuring behavior — and start designing systems.
And for regulated, auditable, trust-critical scenarios, that boundary matters.
That’s all folks!
Cheers!
Gašper Rupnik
{End.}

Leave a comment