When Low-Code Copilot Agents Hit the Wall — and Why Microsoft 365 Agents Toolkit Matters

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

  1. Ctrl + Shift + P
  2. Teams: Provision in the cloud
  3. Teams: Zip App Package
  4. Upload to Teams Admin Center
  5. Create App permission policy
  6. Assign to test group

Only then does the agent appear in Copilot.

When to Use What

Use caseCopilot StudioAgents 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

Website Powered by WordPress.com.

Up ↑