Your UI, Inside the Chat: Building Custom Copilot Components with SPFx
For years, SPFx meant web parts and extensions living on a SharePoint page. With
@microsoft/sp-copilot-component, the same framework, the same React skills, and the same
brokered-SSO plumbing can now render a fully interactive UI directly inside a Microsoft 365 Copilot
conversation. This post walks through copilot-my-approvals, a real solution that puts an
approvals cockpit — approve, reject, re-assign, email the requester — right in the chat pane.
What just became possible
Classic SPFx components render into a page you control — a web part zone, a field customizer, a command surface. A Copilot Component renders into a surface you don't control at all: an iframe that Microsoft 365 Copilot mounts inline in the middle of a chat turn, or expands to fullscreen on request. Copilot invokes your component the same way it invokes any other tool — by matching a natural-language request to a tool description — except instead of getting text back, the model hands control to your React tree.
That means the things SPFx developers already rely on — brokered Microsoft Graph and SharePoint REST calls with zero token-acquisition code, Fluent UI theming, localized strings, the Heft build pipeline — now apply to surfaces inside the Copilot conversation itself, not just to SharePoint pages.
That's the whole point: no separate app, no tab switch, no "click here to open SharePoint." The user asked Copilot about their pending approvals, and Copilot rendered a live, data-bound UI in response — one they can act on immediately.
Meet the solution: MyApprovals Agent
copilot-my-approvals packages a declarative agent called My Approval Helper
that exposes a single tool, MyApprovalsTool. When a user asks something like "show me my
pending approvals", Copilot calls the tool, and the MyApprovalsCopilotComponent renders:
- The signed-in user's approvals from the Approvals app — requester name and photo, status, type, creation date, and description.
- A status filter (pending / completed / canceled / created), available once the user expands to fullscreen.
- Approve, reject, and re-assign actions on any pending item.
- A "contact requester" flow that sends mail from the signed-in user's own mailbox via Graph.
Fullscreen mode — expanded via the bridge's requestDisplayModeAsync, with the status filter and per-item actions. |
Both screenshots are the same component rendering two different hostContext.displayMode
values. There's no second component, no route, no separate bundle — just one render() that
adapts to whatever the host asks for.
Anatomy of a Copilot Component
A Copilot Component is a contract between three files that all have to agree with each other. Get one out of sync and the tool either won't show up in Copilot, or Copilot won't know how to call it.
1. The component class
src/copilotComponents/myApprovals/MyApprovalsCopilotComponent.tsx extends
BaseCopilotComponent<TProperties> and follows a small, predictable lifecycle:
protected async onInit(): Promise<void> {
// Runs once, before first render. Brokered SSO — no token code.
const graphClient = await this.context.msGraphClientFactory.getClient('3');
const me = await graphClient.api('/me').select('displayName').get();
...
await this._loadApprovals('pending');
}
protected render(): void {
const props: IMyApprovalsProps = { /* data + callbacks */ };
ReactDOM.render(React.createElement(MyApprovals, props), this.context.domElement);
}
protected async onTeardown(): Promise<void> {
ReactDOM.unmountComponentAtNode(this.context.domElement);
}
Two details matter here. First, both the Graph call and the SharePoint REST call
(spHttpClient.get('/_api/web?$select=Title')) are wrapped in try/catch — the
component still has to render cleanly in environments where those brokered services aren't wired up.
Second, there's an onHostContextChanged hook: when Copilot collapses the component back to
inline after a fullscreen session, the component resets its status filter to pending, because
inline mode has a fixed contract with the user regardless of what was last selected in fullscreen.
The component also talks back to the host through this.context.copilotBridge
(ISPCopilotBridge): requestDisplayModeAsync to toggle fullscreen,
openLinkAsync to open the SharePoint site, sendFollowUpMessageAsync to drive the
chat on the user's behalf, and requestSizeChangeAsync to resize the inline card. Those bridge
calls are exactly what powers the Expand, Open Site, Follow
Up, and Resize buttons in the screenshots above.
2. The properties schema — Zod, not hand-written JSON Schema
Copilot needs to know what arguments it's allowed to pass your tool, and it needs that as JSON Schema. Doing that by hand is exactly the kind of thing that drifts out of sync with your TypeScript types. So instead, MyApprovalsCopilotComponentProperties.ts defines the shape once, with Zod:
const propertiesSchema = z.object({
message: z.string().describe('A message to display.')
});
export type IMyApprovalsCopilotComponentProperties = z.infer<typeof propertiesSchema>;
export default zodToJsonSchema(propertiesSchema);
The compiled .js output is referenced directly by the manifest below. Add a field to the Zod
object, and it appears both as a typed prop in your component and as an argument Copilot can
populate — one source of truth for what used to be two.
3. The manifest — declaring a tool, not a web part
MyApprovalsCopilotComponent.manifest.json looks like a familiar SPFx client-side component manifest, with two fields that mark it as something new:
{
"componentType": "CopilotComponent",
"copilotType": "Ux",
"capabilities": {
"availableDisplayModes": ["inline", "fullscreen"]
},
"tools": [
{
"name": "MyApprovalsTool",
"description": { "default": "Shows the signed-in user's Microsoft 365 approval requests..." },
"propertiesSchema": {
"id": "$../../../lib/copilotComponents/myApprovals/MyApprovalsCopilotComponentProperties.js:default;"
}
}
]
}
componentType: "CopilotComponent" and copilotType: "Ux" tell the SPFx runtime this
isn't a web part — it's a UX surface Copilot can invoke. The tools array is what Copilot's
planner actually reads: the tool name and description decide whether your component gets
invoked for a given user message at all, so write the description the way you'd write a docstring for an
LLM, not for a human skimming a settings page.
What's new in the build: the copilot/ folder and copilot-agent.json
This is the part that doesn't exist in a classic SPFx project. Packaging a Copilot Component isn't just building a bundle — it's also assembling a Teams/Copilot app package that registers a declarative agent and points it at your component's tool. Two things drive that:
config/copilot-agent.json — the source of truth
{
"agents": [
{
"name": { "default": "My Approval Helper" },
"description": { "default": "Shows your pending Microsoft 365 approval requests..." },
"components": ["c10faeb8-a5e0-4877-8869-ab74b3622bd9"]
}
]
}
This one file maps an agent's identity to the component IDs it's allowed to use as tools. A Heft task —
copilotAgentPlugin — reads it on every build and regenerates two output directories:
copilot/ and temp/copilot/.
copilot/ — the generated (but hand-tunable) agent package
| File | Purpose |
|---|---|
manifest.json |
The Teams app manifest. Registers the declarative agent under copilotAgents.declarativeAgents, and carries the usual Teams app metadata (icons, developer info, permissions). |
declarativeAgent.json |
The agent definition itself: display name, description, conversation starters, and an instructions field that pulls in instruction.txt via $[file('instruction.txt')]. |
ai-plugin.json |
An OpenAPI-plugin-style descriptor that exposes this solution's tools to the agent — the bridge between the declarative agent and the SPFx component's manifest. |
instruction.txt |
Plain-language system instructions for the agent: when to call the tool, and how to talk about the result. |
In this solution, instruction.txt is worth reading in full because it's the actual behavioral
contract for the agent, in the model's own terms:
The agent prioritizes visual rendering of the approvals experience over textual
descriptions. Call the MyApprovalsTool whenever the user asks about their approvals
or pending requests... Keep any accompanying responses short and helpful, and never
invent approval details, requester names, or statuses — only reflect what the tool
returns.
That last sentence is doing real work: it's an explicit instruction against hallucinating approval data, because the whole value proposition collapses if Copilot narrates approvals that don't exist instead of calling the tool.
copilot/ is checked in as a hand-authored
template, but config/copilot-agent.json is what actually drives regeneration during the build.
Prefer editing there — and in instruction.txt for behavior changes — over hand-editing generated
manifest fields, which the next build can overwrite.
Why this needs special watcher handling
Because copilot/ and temp/copilot/ are regenerated on every build, they'd normally
trip up the TypeScript watcher under heft start — a rebuild triggers a file change, which
triggers a rebuild, forever. tsconfig.json explicitly excludes
**/teams and **/temp/copilot from excludeDirectories to break that
loop. If you add another auto-regenerating output directory later, it needs the same treatment.
The rest of the config layer
Three more files round out how this bundle gets built and packaged, all under config/:
config.jsonmaps the component's manifest and compiled entry point into a webpack bundle, and wires the localized-strings module alias (MyApprovalsCopilotComponentStrings) to the compiled locale file.package-solution.jsoncarries the usual SPFx solution/package metadata — solution ID, features, and the output path for the.sppkg.rig.jsonpoints TypeScript and Heft at the shared@microsoft/spfx-web-build-rigbase config, which is what makes this a Heft build instead of the classic gulp toolchain.
The React UI itself
components/MyApprovals.tsx is a plain function component — nothing
Copilot-specific about its internals. It wraps content in IdPrefixProvider +
FluentProvider, choosing webLightTheme or webDarkTheme from
hostContext.theme, and — this is the detail that trips people up the first time — passes
targetDocument={domElement.ownerDocument} so Griffel (Fluent v9's CSS-in-JS engine) injects
styles into the iframe's document, not the top-level Copilot page.
Everything else is standard Fluent UI v9: Card/CardHeader for the approval list,
Dropdown for the status filter, Avatar for requester photos (fetched as a data URL
from /me/photos/64x64/$value), and a Skeleton loading state while
ApprovalService fetches from Graph. The takeaway: once you're past the three Copilot-specific
contract files, you're just writing React the way you already do.
Try it yourself
Cloning this repo and running npm install is the fastest way to see the finished solution, but
Copilot Component is a project type in its own right, and it's worth knowing how to scaffold one from scratch.
1. Install the Yeoman generator
Copilot Component support landed in the SPFx generator's beta channel, so install the @next
tag rather than the version npm resolves by default:
npm install @microsoft/generator-sharepoint@next --global
That pulls down generator 1.24.0-beta.2 — the same version this solution targets (see
config/rig.json) — which is the first to know about the Copilot Component
client-side component type.
2. Scaffold a solution and pick "Copilot Component"
Run the generator with yo @microsoft/sharepoint and answer the prompts as usual — solution
name, then the client-side component type. That second prompt is where the new option shows up, alongside
the familiar WebPart, Extension, Library, and
Adaptive Card Extension choices:
| The SPFx Yeoman generator (1.24.0-beta.2) with the new Copilot Component option. |
Picking Copilot Component scaffolds the three-file contract described above — the component class, the Zod properties schema, and the manifest — plus a starter copilot/ package and config/copilot-agent.json entry, so you land on a working declarative agent instead of assembling one by hand.
3. Build and run
npm install # requires Node >=22.14.0 <23.0.0
npm start # heft start --clean — build, watch, serve
npm run build # heft test --clean --production && heft package-solution --production
One caveat worth calling out up front: Copilot Components can't be exercised in the classic local SharePoint workbench. You need a hosted Microsoft 365 tenant workbench to actually see the component render inside a live Copilot conversation — that's where both screenshots above came from.
Takeaway
The interesting shift here isn't a new API surface bolted onto SPFx — it's that the same skill set (React,
Fluent UI, brokered Graph/SharePoint calls, Heft) now targets a genuinely new place: inline inside a Copilot
conversation, with the model itself deciding when to bring your UI on screen. The properties schema makes the
tool's contract type-safe instead of hand-maintained JSON, and the copilot/ package plus
config/copilot-agent.json handle the part that's actually new — registering that UI as something
a declarative agent can call. For teams already investing in SPFx, that's a very short bridge to cross.
Resources
My solution: maschroeder-z/Copilot-SPFx-MyApprovals-Agent
This solution builds on SharePoint Copilot Apps (SPFx 1.24 preview) and the declarative agent platform underneath it. For the primary sources:
Official Microsoft 365 Developer Blog posts
- Going beyond text in Microsoft 365 Copilot — Introducing SharePoint Copilot Apps — the announcement post for the feature this whole solution is built on: rendering rich SPFx UX components inline in the Copilot canvas.
- SharePoint Copilot Apps Now in Public Preview: From Intent to Action in Microsoft 365 Copilot — the public preview follow-up, with more detail on the developer experience and supported UX scenarios (dashboards, forms, lists, calculators).
Microsoft Learn — declarative agents & extensibility
-
Declarative agents for Microsoft 365 Copilot — overview
— what a declarative agent is and how it fits into the Copilot extensibility model, the concept behind
copilot/declarativeAgent.json. - Declarative agent architecture — how the manifest, actions, and conversation flow fit together end to end.
-
Declarative agent schema 1.7
— the exact schema this solution's
declarativeAgent.jsontargets ("version": "v1.7"), includingconversation_startersandactions. - Agents for Microsoft 365 Copilot — overview — where declarative agents sit relative to the other agent types Microsoft 365 supports.
-
Microsoft 365 app model for agents
— why an agent is packaged as a Teams app manifest (
copilot/manifest.json) with an agent definition attached, rather than a standalone artifact.
@microsoft/sp-copilot-component are in
public preview (SPFx 1.24.0-beta.2) — expect the Learn documentation for the SPFx-specific APIs
(BaseCopilotComponent, the properties-schema contract, the copilotAgentPlugin build
task) to keep expanding as the feature moves toward general availability. The two developer blog posts above
are currently the most complete narrative source; the Learn pages cover the declarative-agent layer that
sits underneath it.

Kommentare