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.

SPFx 1.24.0-beta.2 sp-copilot-component Fluent UI v9 Declarative Agents Heft

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.

Inline mode — the compact card Copilot renders directly in the chat turn.

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.

Keep it in sync: the manifest's tool description, the component's behavior, and the declarative agent's instructions (below) all describe the same capability from three angles. If one drifts, Copilot either invokes the tool at the wrong time or invokes it and gets confused about what to do with the result.

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

FilePurpose
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.

Edit the source, not the output: 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.json maps 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.json carries the usual SPFx solution/package metadata — solution ID, features, and the output path for the .sppkg.
  • rig.json points TypeScript and Heft at the shared @microsoft/spfx-web-build-rig base 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.

No Copilot license required — for now. During the preview period, SharePoint Copilot Apps don't require a Microsoft 365 Copilot license to build, deploy, or run. Both makers and end users can create and use these experiences without a per-user Copilot license while the feature is in preview, so trying this out doesn't require provisioning licensed test accounts. That's expected to change at general availability, so re-check the licensing guidance before planning a production rollout. See No license required (during preview) in the official docs.

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

Microsoft Learn — declarative agents & extensibility

At the time of writing, SharePoint Copilot Apps and @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

Beliebte Posts aus diesem Blog

Power Automate: Abrufen von SharePoint Listenelementen mit ODATA-Filter

SPFx: Be prepared for the Content Security Policy (CSP) in SharePoint Online

SharePoint Online: Optimale Bildgrößen für Seiten (Teil 1)