Inside a SharePoint Copilot App: How One Chat Message Becomes a Live Recognition Wall

Preview

SharePoint Copilot Apps are in public preview as I write this. The APIs, schemas, and tooling can still change, so treat this as a guide to the model rather than a production blueprint.

What a SharePoint Copilot App actually is

First, the vocabulary, because Microsoft’s naming is precise and worth getting right.

A SharePoint Copilot App extends Microsoft 365 Copilot with custom, interactive UI that you build using the SharePoint Framework (SPFx) tools you already know: heft, TypeScript, SCSS, and the @microsoft/sp-* libraries. The app packages two things together:

  • One or more Copilot components, the client-side UI components that render inside a Copilot conversation.
  • A declarative agent that makes those components discoverable and callable from the conversation.

The comparison that made it click for me is with the SPFx building blocks I have used for years:

Extends Base class Renders in
Web part SharePoint BaseClientSideWebPart A SharePoint page
Copilot component Microsoft 365 Copilot BaseCopilotComponent The Copilot canvas

Same toolchain, same `.sppkg` package, same app catalog. Only the host changes. Because SPFx keeps the hosting concern separate from the UI, the same React component can, in time, power a web part, a Teams tab, and a Copilot component from one codebase. During the preview it renders only in Microsoft 365 Copilot, but that "build once, reach every surface" idea is the whole point.

So when someone gives kudos in Copilot, they are not chatting with a bot that happens to know about kudos. They are running a real component, hosted in your own tenant, that reads and writes real data.

The three moving parts

Before we trace the message, meet the cast. A Copilot App has three pieces that pass work between them:

  1. The declarative agent owns the conversation. It carries the instructions that tell Copilot what the agent is for and when to reach for its tools.
  2. The tool contract is the promise the agent makes to the model: here is a tool, here is what it does, here are the arguments it takes. It lives across ai-plugin.json, the component manifest, and a Zod schema.
  3. The Copilot component is the code that runs. It takes the arguments Copilot filled in, fetches data, and renders the UI.

Here is the whole journey on one page. Keep it in the corner of your eye as we go.

Infographic tracing a chat message through the agent, tool contract, argument validation, component resolution and lifecycle, ending in an inline card or a fullscreen wall
From a sentence to a rendered card. The model decides which tool to call and fills its arguments, the platform hosts the component, and your code renders the result.

Behind the scenes: how the message becomes a card

This is the part most walkthroughs skip, so it is worth slowing down.

The agent reads its instructions

When the user picks the Kudos & Recognition Wall agent and types their sentence, Copilot does not see code. It sees the agent’s instructions, a short piece of plain English that frames everything. The sample’s instruction.txt, trimmed to the key rule, reads:

When someone asks to give, send or record kudos, or names a colleague they want
to thank, call the Kudoswall tool, passing the colleague's name as `recipient`
and any reason they give as `message`. When someone asks to see the wall, the
recent recognition, or who has been recognised, call the Kudoswall tool with no
recipient so the wall opens.

Keep replies short. The tool renders its own card in the conversation, so do not
restate the kudos details back to the user in text.

Notice the two jobs it does. It tells the model when to call the tool, and how to map the sentence onto arguments (name to recipient, reason to message). It even asks the model to stay quiet in text, because the card will speak for itself. Good instructions are the difference between an agent that reliably does the right thing and one that guesses.

It matches the tool contract

The instructions say to call the Kudoswall tool. But what is that tool? The model reads its contract, which two files describe. First, ai-plugin.json gives a plain summary of the action:

{
"namespace": "kudoswall",
"name_for_human": "Kudos and Recognition Wall",
"description_for_model": "Gives kudos to a colleague and shows the recognition wall for your organization."
}

Then the component manifest declares the tool itself and points at the schema that defines its arguments:

"tools": [
{
"name": "KudoswallTool",
"description": {
"default": "Give kudos to a colleague or open your organization's recognition wall. Pass 'recipient' (a colleague's name or email) and 'message' when the user names someone to thank; pass no arguments to show the wall."
},
"propertiesSchema": {
"id": "$../../../lib/copilotComponents/kudoswall/KudoswallCopilotComponentProperties.js:default;"
}
}
]

That description is not documentation for people. It is a piece of prompt the model reads to decide whether the tool fits and how to fill it. Write it as if you are briefing the model, because you are.

It fills and checks the arguments

Now the model turns Give kudos to Alan Partridge for being excellent with client engagement into structured arguments. It knows their shape from the properties schema, which the sample defines with Zod:

const propertiesSchema = z.object({
recipient: z
.string()
.optional()
.describe('Name or email of the colleague to recognise, if the user named one.'),
message: z
.string()
.optional()
.describe('The recognition message text, if the user provided one.'),
});

export default zodToJsonSchema(propertiesSchema);

Two small things are doing a lot of work here.

The .describe() text on each field is exported into the JSON Schema and handed to the model. It is how Copilot knows recipient is a name (Alan Partridge) and message is the recognition text it should write from the reason you gave. Vague descriptions give vague extraction; specific ones give precise extraction.

The zodToJsonSchema(...) default export means the same schema that types your TypeScript also becomes the contract the host uses to validate the arguments before your component ever runs. One definition, two jobs.

So Copilot fills in recipient = "Alan Partridge" and drafts a message from your reason, then checks both against the schema before making the call. That draft is a starting point, not the final word: it lands in the card for you to read, edit, and approve before anything is saved.

SharePoint finds the component by its GUID

This next part surprised me. Nothing in the agent files imports the component. The wiring is all metadata. The manifest carries a stable id (a GUID) and declares its type:

{
"id": "be58ec58-82d8-40a8-a0fe-2d9e6dbc370f",
"alias": "KudoswallCopilotComponent",
"componentType": "CopilotComponent",
"copilotType": "Ux",
"capabilities": {
"availableDisplayModes": ["inline", "fullscreen"]
}
}

At build time a config/copilot-agent.json file groups the agent with the component GUIDs it should host, and the toolchain merges the two sides into the package. Because the link is a GUID and not a code reference, the same agent can be assembled with different components, and the whole app moves between tenants by deploying the .sppkg. When Copilot decides to call the tool, the platform looks the component up by that GUID and loads its assets, which are hosted in your own tenant.

The component runs its lifecycle

Only now does your code run. A Copilot component extends BaseCopilotComponent and follows a small, predictable lifecycle. Here is the sample’s component, lightly condensed:

export default class KudoswallCopilotComponent
extends BaseCopilotComponent<IKudoswallCopilotComponentProperties> {

protected async onInit(): Promise<void> {
// The Copilot host has no site page context, so target the data site explicitly.
const webAbsoluteUrl = KUDOS_SITE_URL || this.context.pageContext.web.absoluteUrl;

// Brokered SSO, no token handling. Graph may be unavailable in the workbench,
// so the services fall back to a mock directory when it is.
let graph: MSGraphClientV3 | undefined;
try { graph = await this.context.msGraphClientFactory.getClient("3"); }
catch { graph = undefined; }

this._services = createKudosServices({ /* ...spHttpClient, graph, currentUser... */ });

// Turn the name Copilot passed into a real person for the compose card.
const named = this.properties.recipient?.trim();
if (named) {
this._recipient = await this._services.people.resolvePerson(named);
}
}

protected render(): void {
const element = React.createElement(KudosApp, {
surface: resolveSurface(this.hostContext.displayMode),
inlineMode: resolveInlineMode(this.properties),
recipient: this._recipient,
prefilledMessage: this.properties.message,
isDarkTheme: this.hostContext.theme === "dark",
onRequestFullscreen: () => this.requestDisplayModeAsync("fullscreen"),
// ...services
});
ReactDOM.render(element, this.context.domElement);
}
}

Read onInit from the top and the design choices explain themselves.

this.properties.recipient is the argument Copilot filled in back at the previous step. The component reads it straight off this.properties, with no plumbing.

KUDOS_SITE_URL is there because a Copilot component has no page context the way a web part does. There is no current site, so the data service has to be told which site to talk to.

Graph is brokered, with a fallback. SSO is handled for you, and if Graph is not available (as in the workbench) the services quietly switch to mock data so nothing hard fails.

resolvePerson turns the string “Alan Partridge” into a real directory person, so the compose card opens with the right colleague selected.

Then render() mounts the React tree, and the message finally becomes pixels.

How it picks the right view

The component we just met can show four different things, and it decides between them from only two inputs. That is what lets one component feel like several tailored experiences.

The two inputs are the display mode (inline or fullscreen), which the host owns and delivers on this.hostContext.displayMode, and whether recipient was supplied. The sample reads them with two small helpers:

// inline in the conversation, fullscreen when the host expands the canvas.
export function resolveSurface(displayMode: string | undefined): KudosSurface {
return displayMode === 'fullscreen' ? 'fullscreen' : 'inline';
}

// compose when the prompt named a recipient, launcher when it did not.
export function resolveInlineMode(properties: IKudoswallProperties): InlineCardMode {
return properties.recipient?.trim() ? 'compose' : 'launcher';
}

Cross the two and you get the full behaviour:

Display mode recipient present? What the user sees
inline Yes Compose card, pre-filled to thank that colleague
inline No Launcher digest, recent kudos plus a Give kudos action
fullscreen Either Full recognition wall, feed, filters, and leaderboards

The inline launcher digest showing recent recognitions with Give kudos and Open the wall actions
With no colleague named, the same tool opens the launcher instead: a compact digest of recent recognitions, with Give kudos and Open the wall actions.

How the display mode works

Your component reads the current mode on every render and can request a switch with requestDisplayModeAsync('fullscreen'), the only mode it may ask for. Collapsing back to inline is the host's job: the user clicks the host's own control and your component simply re-renders. There is no built-in expand button either, so you render your own and wire it to that call, which is what onRequestFullscreen does above.

The Kudos wall expanded to fullscreen, showing the recognition feed, filters, and leaderboards
Ask to see the wall and the host expands the canvas: the same component, now rendering the full feed with team and value filters plus the leaderboards.

Getting started, without provisioning anything

The part I like most about these apps is that you can run the whole thing against mock data before touching SharePoint. The Kudos sample ships a mock backend for exactly that.

1. Clone and run. From the sample folder:

npm install
npm run start

Copilot components cannot run in the classic local workbench, so npm run start serves your component to the Copilot Workbench instead. It lives at the /_layouts/15/copilotworkbench.aspx path of any site in your tenant, for example:

https://yourtenant.sharepoint.com/_layouts/15/copilotworkbench.aspx

Load it, accept the debug manifest when asked, activate the component, and fire a turn to see it render with the properties you pass. It is a quick loop of rebuild, refresh, iterate, against a real Copilot surface. Under the bonnet this is the SPFx heft toolchain, so you can also run heft start --nobrowser directly.

2. Force the mock, or go live. The sample uses mock data automatically when there is no provisioned list, or whenever you add ?kudosMock=1 to the workbench URL, which is handy for demos. To run against real data you provision two SharePoint lists (a Departments vocabulary and a Kudos list) and point the component at the site with KUDOS_SITE_URL. The README has the exact provisioning scripts.

3. Package and deploy. When the workbench looks right, build the package:

npm run build

Upload the .sppkg to your app catalog, enable it, and choose Add to Teams. That one action deploys the declarative agent to your tenant’s agent catalog; there is no separate publishing step in Copilot. Grant the optional User.ReadBasic.All Graph permission and the recipient picker searches your real directory and reads each colleague’s department. Without it, the component falls back to its mock roster.

Watch the agent version

Every time you change the agent side (its instructions, conversation starters, or actions), bump the declarative agent version. If you leave it the same, Copilot may keep serving the previously synced definition even after you redeploy.

What I took away

A few things stuck with me while building this. The tool description and the Zod .describe() text are not comments; they are the words the model actually reads, so they deserve the same care as any prompt. The same component becomes a compose card, a launcher, or a full wall depending on its arguments and the display mode, which means you ship one component and serve many scenarios. And the link between agent and component is a GUID rather than a code reference, which is what lets the app move cleanly between tenants as a single package.

The bigger shift is this. Copilot stops being a place that only returns text about your systems and becomes a place that renders live interactions with them. A sentence goes in, a working card comes out, and everything in between is code you own.

Source code

Source code

The complete Kudos & Recognition Wall sample is on GitHub, ready to clone and run against mock data.

References

  1. Microsoft Learn, Overview of SharePoint Copilot Apps (preview), https://learn.microsoft.com/en-us/sharepoint/dev/spfx/copilot/overview-copilot-apps
  2. Microsoft Learn, Display modes in SharePoint Copilot components, https://learn.microsoft.com/en-us/sharepoint/dev/spfx/copilot/displaymode
  3. Microsoft Learn, Build your first SharePoint Copilot App, https://learn.microsoft.com/en-us/sharepoint/dev/spfx/copilot/get-started/build-your-first-copilot-app
  4. Microsoft 365 Copilot extensibility documentation, https://learn.microsoft.com/microsoft-365-copilot/extensibility/
  5. Zod, TypeScript-first schema validation, https://zod.dev
Author: Ejaz Hussain
Link: https://office365clinic.com/2026/08/12/inside-sharepoint-copilot-app-kudos-wall/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.