Two elf artisans in a sunlit workshop: a purple Copilot panel of chat bubbles above, a teal SPFx panel below holding three cards with the middle one lit gold under her hand, and a teal thread carrying that choice up into the conversation and on to a small result panel.

Build a SharePoint Copilot App: From Prompt to SPFx and Back

Build Choice Relay: two small SPFx tools that make intent routing, interactive UI, and the component-to-Copilot bridge visible.

In Part 1 , I introduced the mental model behind SharePoint Copilot Apps. To see that interaction for real, I started testing with a small app, and the round trip worked better than I expected. So I polished the test into a community sample: Choice Relay , the smallest app that demonstrates both directions of the exchange:

  • Copilot → SPFx. A prompt becomes typed arguments, and a component renders.
  • SPFx → Copilot. A choice made locally in React returns as a readable user turn, and that turn drives a second tool.
  • And back again. An instruction typed in the normal composer re-invokes the tool, and the component re-renders as a new instance.

One package, two tools, no custom backend, no hardcoded business scenario.

Everything below is a real run. The screenshots and clips come from Choice Relay 1.0.0 in a live tenant on 29 July 2026. What Copilot wrote on the day is one result, not a fixed answer; run it yourself and the words will differ.

SPFx raised to Copilot

One prompt, no tool names

Everything starts with the agent in scope: I open Choice Relay from the agent list, or type @Choice Relay in a normal Copilot chat and select the chip. Then I send this:

I have 30 minutes before my next meeting. Our pilot starts next week. The
demo passed, but the data feed is a week late and the participant briefing
still isn't written. What could I start now? I'd rather pick one myself
before you tell me where to begin.

Copilot now has to pick one of two tools, and the routing rules reduce that to a single question: is the decision still open, or already made?

Intent Tool SPFx component
Present two to four alternatives and let the user choose ChoiceRelayShowChoices a question, selectable choices, and one continue button
Suggest or adjust one next action after the user has chosen ChoiceRelayShowNextAction the selected option, one next action, and a short explanation

This prompt asks what I could start and says I’d rather pick myself. The decision is open, so the first tool fires. Had I written “I’ve decided to write the participant briefing, what is one small step I could finish in half an hour?”, the decision is already made: Copilot goes straight to the second tool, copies my stated decision into selectedOption, and no choice component ever appears.

Notice that the prompt never names a tool or asks for “an interactive component”. That would do the router’s job for it, and the point of the sample is that ordinary intent is enough. The pilot situation itself is just test data; it could be three meeting topics or three lunch options.

Two tiny contracts

Copilot does not route by TypeScript filenames. The public contracts are the tool names in the component manifests. Each tool carries a small Zod schema, exported to JSON Schema at build time:

const propertiesSchema = z.object({
  question: z.string(),
  options: z.array(z.string())
});

Two things can fill that options array: alternatives the user supplied, which the instructions tell Copilot to preserve untouched, or two to four alternatives Copilot generates itself from a bare situation. There is deliberately no minItems/maxItems: the plugin parameter schema supports only a small JSON Schema subset, so the count lives in the tool description and the UI validation instead.

const propertiesSchema = z.object({
  selectedOption: z.string(),
  nextAction: z.string(),
  explanation: z.string()
});

nextAction and explanation are never calculated by the component. Copilot creates them from the selection and the conversation; the component only renders what it receives.

One side note for SPFx veterans: the Copilot template pairs React 17 with Fluent UI v9: cards, radios, and the theme provider all from @fluentui/react-components, with the host context driving light and dark. A Copilot component is a practical place to start using v9 without leaving React 17.

What happens on the first prompt

Copilot selects ChoiceRelayShowChoices and generates the arguments. This is the actual output of the run:

{
  "question": "With 30 minutes available before a pilot starts next week, which area would you like to make progress on first?",
  "options": [
    "Draft the participant briefing",
    "Map risks from the delayed data feed",
    "Prepare a pilot-day contingency plan",
    "Capture open issues and owners"
  ]
}

The situation prompt, and the choice component Copilot rendered from it

Four options, where the instructions prefer three. Nothing enforces a count, and the model filled the range the tool description allows. The component receives the arguments through this.properties and renders; Under the hood stays collapsed but keeps the tool name, the delivered arguments, and the outbound message inspectable.

The schema guarantees the shape of the arguments and the instructions shape the meaning. Neither makes the content true, so read what comes back.

An ordinary situation prompt. Copilot selects ChoiceRelayShowChoices, creates the question and options, and the SPFx component renders inside the conversation.

The choice exists only in React until I send it

Suppose I select Map risks from the delayed data feed. That selection is local component state; Copilot does not know what I clicked. On screen you see a card highlight and then, a moment later, a chat message; the boundary between those two events is the whole point of the sample, and it is invisible:

How a choice made in SPFx returns to Copilot A situation prompt reaches Microsoft 365 Copilot, which selects ChoiceRelayShowChoices and creates a question and options. The SPFx component renders them as selectable cards. Selecting one changes only local React state, which Copilot cannot see. Choosing Continue with Copilot sends that selection as a readable user turn. Copilot then selects ChoiceRelayShowNextAction and a second SPFx component renders one next action and a short explanation. ChoiceRelayShowChoices readable user turn ChoiceRelayShowNextAction Situationprompt Microsoft 365Copilot SPFx choice component one selected, locally Microsoft 365Copilot SPFx nextaction Copilot cannot see inside the dashed box. The selection is React state until Continue with Copilot is chosen.
  1. A situation prompt starts an ordinary user turn.
  2. Copilot selects ChoiceRelayShowChoices and creates the question and options.
  3. The SPFx component renders them as selectable cards.
  4. Selecting one changes only local React state. Copilot cannot see it, and nothing has been sent.
  5. Continue with Copilot sends that selection as a readable user turn through the bridge.
  6. Copilot selects ChoiceRelayShowNextAction, and a second component renders one next action with a short explanation.
The pause in the middle is the point. A selection made in SPFx is invisible to Copilot until the component sends it as a new user turn, and the host acknowledging that message confirms delivery only, not that Copilot has processed it.

When I select Continue with Copilot, the component composes a readable message and sends it through the bridge:

## Choice Relay selection

**I selected**
> Map risks from the delayed data feed

**Please**
> Suggest one small, reversible next action grounded only in this conversation. Use one sentence of no more than 25 words. Do not invent missing details or perform an external action.

Show the response in the Choice Relay result component.
const result = await this.context.copilotBridge
  .sendFollowUpMessageAsync([
    createCopilotTextContent(message)
  ]);

return result.isError !== true;

The acknowledgement only means the host accepted the message. It does not prove Copilot processed it. The evidence for that is what happens next: Copilot selects ChoiceRelayShowNextAction and the second component renders the selection, one next action, and a short explanation. Worth noting: the message never repeats the original 30-minute limit, yet the constraint survived into the next action. It lives in the conversation, not in anything the component asserts.

The result component has its own adjustment field:

Make this a five-minute first step toward the option I selected. Keep both
one risk and one mitigation in scope.

Sending it crosses the same boundary again: another readable turn, another invocation, revised arguments.

Everything here happens inside SPFx: selecting an option, sending it back as a readable turn, and adjusting the result from the component’s own field.

Type in the chat, and the component reacts

The next instruction does not need to originate in SPFx. With the result component on screen, I typed this in the normal Copilot composer:

One more fact: sample data is available for rehearsal, but it cannot be used
to validate customer results. Keep the option I already selected. Revise the
next action using this new fact and do not add anything else.

Copilot already has the conversation and the previous tool input, so this is just another turn: it selected ChoiceRelayShowNextAction again and rendered the revised action as a new component instance. One caution: keep the wording aligned with the option you actually selected; a follow-up naming a different option makes the model switch selections, reasonably enough.

That is the distinction the sample exists to show: Copilot can reuse anything that has entered the conversation, and it can see nothing that exists only inside React.

Driven from the normal Copilot composer. A new fact typed in chat re-invokes the second tool, and the result arrives as a new component instance.

Workbench tests the component, not the agent

The Copilot Workbench gives a fast UI loop:

npm ci
npx heft trust-dev-cert
export SPFX_SERVE_TENANT_DOMAIN="YOURTENANT.sharepoint.com"
npm start -- --nobrowser

Then open https://YOURTENANT.sharepoint.com/_layouts/15/copilotworkbench.aspx, select a component, and Fire turn with a manual fixture:

{
  "question": "Which task should you start in the next 30 minutes?",
  "options": [
    "Draft the participant briefing",
    "Map risks from the delayed data feed",
    "Prepare a pilot-day contingency plan"
  ]
}

Workbench renders the contract, themes, display modes, and the outbound message, but it does not interpret prompts, select tools, or run the next Copilot turn. It stops at the acknowledgement. Only a deployed agent proves the routing.

Deploying it

npm run build

Upload sharepoint/solution/choice-relay.sppkg to the tenant app catalog, enable it, and select Sync to Teams to publish the embedded agent.

Then install it. Synchronizing makes the agent available to the tenant; installing it is a separate step, from the Agents page in the Microsoft 365 admin center or the agent store. It is easy to miss, because the app shows as published before anyone can use it. Allow a few minutes for propagation, and check the version in developer mode rather than in the component UI.

What the run proved

Natural language selected the intended typed tool with no tool named in the prompt. A choice held in React crossed into the conversation only when sent, and drove a second tool. Both revision paths, the component’s field and the normal composer, returned through the same tool, and the opening 30-minute constraint survived the whole trip without the component ever restating it.

The route works. The next sample can put a real use case on top of it without hiding the plumbing. That is where Prompt Fortress takes the same interaction under more pressure.

Resources