{"id":851,"date":"2026-08-21T16:09:14","date_gmt":"2026-08-21T16:09:14","guid":{"rendered":"https:\/\/codingwithramin.com\/?p=851"},"modified":"2026-08-21T16:15:43","modified_gmt":"2026-08-21T16:15:43","slug":"work-iq-answers-building-a-sharepoint-copilot-app-on-work-iq","status":"publish","type":"post","link":"https:\/\/codingwithramin.com\/?p=851","title":{"rendered":"Work IQ Answers: building a SharePoint Copilot App on Work IQ"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Ask Copilot to show your team&#8217;s open approvals and it hands you a paragraph. No buttons, nothing to click. You still have to leave the chat and open SharePoint to act on any of it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/devblogs.microsoft.com\/microsoft365dev\/going-beyond-text-in-microsoft-365-copilot-introducing-sharepoint-copilot-apps\/\">SharePoint Copilot Apps<\/a> exist to close that gap, and once I understood what that actually meant, I wanted to build one rather than just read the announcement. <a href=\"https:\/\/devblogs.microsoft.com\/microsoft365dev\/sharepoint-copilot-apps-now-in-public-preview-from-intent-to-action-in-microsoft-365-copilot\/\">Public preview shipped July 9, 2026<\/a>. I picked a scenario nobody in the official sample gallery had touched yet: Work IQ.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What a Copilot App actually is, and why it&#8217;s a different kind of SPFx surface<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every SPFx component I&#8217;d built before this had the same shape: I decided where it lived, and the user had to go there. A web part sits on a page, so someone navigates to that page. An extension changes how a page behaves, a command in a menu, a custom column renderer, but it&#8217;s still bound to that page. An ACE lives on the Viva Connections dashboard, which is a real improvement since it&#8217;s the first thing most people see each morning, but it&#8217;s still a destination. You open Viva Connections, and the ACE is there waiting for you.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A Copilot App breaks that pattern. It lives inside the conversation itself, and it only shows up when the conversation actually calls for it. There&#8217;s no button to find and no page to remember exists.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Mechanically, it&#8217;s still SPFx underneath. You scaffold it the same way, write it in React or whatever framework you&#8217;re comfortable with, and package it the same way. What&#8217;s genuinely different is how it gets invoked. Every Copilot Component ships a tool description: a plain-English paragraph explaining what the component answers and when it should be used. Microsoft 365 Copilot&#8217;s own orchestration reads that description against whatever the person just typed and decides, per turn, whether this specific component is the right response. Nobody places it on a page. Nothing points a user toward it. If the question fits, it renders.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the actual tool description shipping in the manifest for this sample, word for word:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:flex;align-items:center;padding:10px 0px 10px 16px;margin-bottom:-2px;width:100%;text-align:left;background-color:#2b2b2b;color:#c7c7c7\">JSON<\/span><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>\"description\": {\n  \"default\": \"Answers questions about the signed-in user's own work in Microsoft 365 \u2014 meetings and what was decided in them, documents and their contents, projects and who is working on them, and what has happened recently across their mail, chats, meetings, and files \u2014 by asking Work IQ and rendering the answer as a structured card with the people, meetings, and files it mentions shown as clickable chips and every source listed in a citation panel. Use this tool whenever the user asks a question about their own work, their meetings, their documents, their colleagues, or their projects, and whenever they ask what was decided, who is involved, or what the latest is on something. Also use it for follow-up questions, which continue the same Work IQ conversation. Do not use it to send mail, create meetings, or change anything \u2014 this tool only answers.\"\n}<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #CE9178\">&quot;description&quot;<\/span><span style=\"color: #D4D4D4\">: {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #9CDCFE\">&quot;default&quot;<\/span><span style=\"color: #D4D4D4\">: <\/span><span style=\"color: #CE9178\">&quot;Answers questions about the signed-in user&#39;s own work in Microsoft 365 \u2014 meetings and what was decided in them, documents and their contents, projects and who is working on them, and what has happened recently across their mail, chats, meetings, and files \u2014 by asking Work IQ and rendering the answer as a structured card with the people, meetings, and files it mentions shown as clickable chips and every source listed in a citation panel. Use this tool whenever the user asks a question about their own work, their meetings, their documents, their colleagues, or their projects, and whenever they ask what was decided, who is involved, or what the latest is on something. Also use it for follow-up questions, which continue the same Work IQ conversation. Do not use it to send mail, create meetings, or change anything \u2014 this tool only answers.&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">}<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">That paragraph is doing the job a menu item or a bookmarked URL used to do. It&#8217;s the entire routing mechanism for the component. Write it too broad and the component fires on questions it has no business answering. Write it too narrow and Copilot just falls back to plain text, which is the exact failure this whole category of feature is meant to fix. Getting that description right turned out to matter as much as anything in the actual component code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Under the hood, it&#8217;s built on SPFx and implements the <a href=\"https:\/\/devblogs.microsoft.com\/microsoft365dev\/going-beyond-text-in-microsoft-365-copilot-introducing-sharepoint-copilot-apps\/\">MCP Apps model<\/a>. A typical MCP Apps setup has you standing up your own hosting and handling routing yourself. Here, Microsoft hosts and routes the component for you, so the part you actually build is small: the UI, and the judgment call in that description above.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;ve built ACEs for Viva Connections, the underlying instinct still applies: bring a real business surface into a Microsoft 365 host app, instead of sending the user out to a separate tool to get their answer. What&#8217;s new is where that surface shows up, and who decides when to show it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What it costs to try<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">No Microsoft 365 Copilot license is required to start building. Microsoft says that&#8217;s deliberate, meant to lower the barrier during preview. Whether that holds once the feature reaches general availability isn&#8217;t clear yet.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The gap in the sample gallery<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/github.com\/pnp\/spfx-copilot-apps\">Microsoft&#8217;s PnP sample repository<\/a> had 10 Copilot App samples when I went looking in July 2026: approvals, people search, permissions review, PTO requests, an executive dashboard, a few more. The gallery has grown since. It&#8217;s up to 15 samples as of this writing. I read through every one before deciding what to build. All 10 either used mocked data or called Microsoft Graph or SharePoint directly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">None of them called Work IQ, even though Microsoft&#8217;s own &#8220;My Day&#8221; demo video for this whole feature was explicitly powered by it. That gap became the project: <strong>Work IQ Answers<\/strong>, a Copilot Component that sends a user&#8217;s question straight to the real Work IQ Chat API and renders the response as structured UI instead of plain text.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Work IQ&#8217;s own <a href=\"https:\/\/learn.microsoft.com\/en-us\/microsoft-365\/copilot\/extensibility\/work-iq\/rest\/overview\">REST documentation<\/a> lists &#8220;only responds with textual responses&#8221; as a stated limitation of the API. Reading that line is what decided the scenario. Wrapping that same API in a Copilot Component turns a wall of prose into entity mentions rendered as clickable chips, citations grouped into a real source panel, and a sensitivity badge when the answer touches labeled content. Everything the API itself can&#8217;t do on its own.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The two things that had to work before any UI was worth writing<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two open questions sat ahead of the actual component: what shape the Work IQ request and response really take, and whether a browser is even allowed to call the API directly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The contract turned out to be documented, once I found the right pages: create a conversation, then <strong>POST <\/strong>messages into it, one turn at a time. Auth is delegated only, no application-only path exists, and it&#8217;s scoped to a single permission, <strong>WorkIQAgent.Ask<\/strong>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <strong>useMock <\/strong>flag in the service constructor makes that decision explicit rather than implicit:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:flex;align-items:center;padding:10px 0px 10px 16px;margin-bottom:-2px;width:100%;text-align:left;background-color:#2b2b2b;color:#c7c7c7\">TypeScript<\/span><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>public constructor(options: IWorkIQServiceOptions = {}) {\n  this._useMock = options.useMock !== false;\n  this._aadHttpClientFactory = options.aadHttpClientFactory;\n  this._apiVersion = options.apiVersion || 'v1';\n  ...\n\n  if (!this._useMock &amp;&amp; !this._aadHttpClientFactory) {\n    throw new Error(\n      'WorkIQService: aadHttpClientFactory is required when useMock is false.'\n    );\n  }\n}<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #9CDCFE\">public<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #DCDCAA\">constructor<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #9CDCFE\">options<\/span><span style=\"color: #D4D4D4\">: <\/span><span style=\"color: #9CDCFE\">IWorkIQServiceOptions<\/span><span style=\"color: #D4D4D4\"> = {}) {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #569CD6\">this<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">_useMock<\/span><span style=\"color: #D4D4D4\"> = <\/span><span style=\"color: #9CDCFE\">options<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">useMock<\/span><span style=\"color: #D4D4D4\"> !== <\/span><span style=\"color: #569CD6\">false<\/span><span style=\"color: #D4D4D4\">;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #569CD6\">this<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">_aadHttpClientFactory<\/span><span style=\"color: #D4D4D4\"> = <\/span><span style=\"color: #9CDCFE\">options<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">aadHttpClientFactory<\/span><span style=\"color: #D4D4D4\">;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #569CD6\">this<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">_apiVersion<\/span><span style=\"color: #D4D4D4\"> = <\/span><span style=\"color: #9CDCFE\">options<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">apiVersion<\/span><span style=\"color: #D4D4D4\"> || <\/span><span style=\"color: #CE9178\">&#39;v1&#39;<\/span><span style=\"color: #D4D4D4\">;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  ...<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #C586C0\">if<\/span><span style=\"color: #D4D4D4\"> (!<\/span><span style=\"color: #569CD6\">this<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">_useMock<\/span><span style=\"color: #D4D4D4\"> &amp;&amp; !<\/span><span style=\"color: #569CD6\">this<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">_aadHttpClientFactory<\/span><span style=\"color: #D4D4D4\">) {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #C586C0\">throw<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">new<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #DCDCAA\">Error<\/span><span style=\"color: #D4D4D4\">(<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">      <\/span><span style=\"color: #CE9178\">&#39;WorkIQService: aadHttpClientFactory is required when useMock is false.&#39;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    );<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  }<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">}<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">And both paths, mock and live, funnel through the same extraction before the UI ever sees the data:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:flex;align-items:center;padding:10px 0px 10px 16px;margin-bottom:-2px;width:100%;text-align:left;background-color:#2b2b2b;color:#c7c7c7\">TypeScript<\/span><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>public toAnswer(conversation: IWorkIQConversation): IWorkIQAnswer {\n  const messages: IWorkIQMessage[] = conversation.messages || [];\n  const answer = messages&#91;messages.length - 1&#93;;\n\n  if (!answer) {\n    throw new Error('Work IQ returned a conversation with no messages.');\n  }\n\n  return {\n    text: answer.text || '',\n    attributions: answer.attributions || [],\n    references: answer.references,\n    sensitivityLabel: answer.sensitivityLabel,\n    turnCount: conversation.turnCount,\n    conversationId: conversation.id\n  };\n}<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #9CDCFE\">public<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #DCDCAA\">toAnswer<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #9CDCFE\">conversation<\/span><span style=\"color: #D4D4D4\">: <\/span><span style=\"color: #9CDCFE\">IWorkIQConversation<\/span><span style=\"color: #D4D4D4\">): <\/span><span style=\"color: #9CDCFE\">IWorkIQAnswer<\/span><span style=\"color: #D4D4D4\"> {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #569CD6\">const<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #4FC1FF\">messages<\/span><span style=\"color: #D4D4D4\">: <\/span><span style=\"color: #4EC9B0\">IWorkIQMessage<\/span><span style=\"color: #D4D4D4\">[] = <\/span><span style=\"color: #9CDCFE\">conversation<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">messages<\/span><span style=\"color: #D4D4D4\"> || [];<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #569CD6\">const<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #4FC1FF\">answer<\/span><span style=\"color: #D4D4D4\"> = <\/span><span style=\"color: #9CDCFE\">messages<\/span><span style=\"color: #D4D4D4\">&#91;<\/span><span style=\"color: #9CDCFE\">messages<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">length<\/span><span style=\"color: #D4D4D4\"> - <\/span><span style=\"color: #B5CEA8\">1<\/span><span style=\"color: #D4D4D4\">&#93;;<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #C586C0\">if<\/span><span style=\"color: #D4D4D4\"> (!<\/span><span style=\"color: #9CDCFE\">answer<\/span><span style=\"color: #D4D4D4\">) {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #C586C0\">throw<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">new<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #DCDCAA\">Error<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #CE9178\">&#39;Work IQ returned a conversation with no messages.&#39;<\/span><span style=\"color: #D4D4D4\">);<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  }<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #C586C0\">return<\/span><span style=\"color: #D4D4D4\"> {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #9CDCFE\">text:<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #9CDCFE\">answer<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">text<\/span><span style=\"color: #D4D4D4\"> || <\/span><span style=\"color: #CE9178\">&#39;&#39;<\/span><span style=\"color: #D4D4D4\">,<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #9CDCFE\">attributions:<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #9CDCFE\">answer<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">attributions<\/span><span style=\"color: #D4D4D4\"> || [],<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #9CDCFE\">references:<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #9CDCFE\">answer<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">references<\/span><span style=\"color: #D4D4D4\">,<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #9CDCFE\">sensitivityLabel:<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #9CDCFE\">answer<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">sensitivityLabel<\/span><span style=\"color: #D4D4D4\">,<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #9CDCFE\">turnCount:<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #9CDCFE\">conversation<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">turnCount<\/span><span style=\"color: #D4D4D4\">,<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #9CDCFE\">conversationId:<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #9CDCFE\">conversation<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">id<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  };<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">}<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">That single shared function is why flipping <strong>useMock <\/strong>from <strong>true <\/strong>to <strong>false <\/strong>doesn&#8217;t touch a line of UI code. There&#8217;s only one place the React layer ever reads an answer from, so the switch is structural rather than something anyone has to remember to honor.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second open question, whether the browser could call Work IQ directly at all, had no answer in the docs either way. SPFx has a standard pattern for calling a non-Graph, Entra-secured API without a middle tier, <strong>AadHttpClient<\/strong>, backed by a <strong>webApiPermissionRequests <\/strong>entry a tenant admin approves once:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:flex;align-items:center;padding:10px 0px 10px 16px;margin-bottom:-2px;width:100%;text-align:left;background-color:#2b2b2b;color:#c7c7c7\">JSON<\/span><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>\"webApiPermissionRequests\": &#91;\n  {\n    \"resource\": \"Work IQ\",\n    \"scope\": \"WorkIQAgent.Ask\"\n  }\n&#93;<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #CE9178\">&quot;webApiPermissionRequests&quot;<\/span><span style=\"color: #D4D4D4\">: &#91;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #9CDCFE\">&quot;resource&quot;<\/span><span style=\"color: #D4D4D4\">: <\/span><span style=\"color: #CE9178\">&quot;Work IQ&quot;<\/span><span style=\"color: #D4D4D4\">,<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #9CDCFE\">&quot;scope&quot;<\/span><span style=\"color: #D4D4D4\">: <\/span><span style=\"color: #CE9178\">&quot;WorkIQAgent.Ask&quot;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  }<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">&#93;<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">Whether that call would actually clear CORS from inside a browser was the real unknown going in.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Keeping a conversation, and not racing yourself<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A Copilot Component doesn&#8217;t get its own server. Everything about &#8220;this is turn two of the same conversation&#8221; has to live in the client instance SPFx hands you, for as long as the component stays mounted. <strong>WorkIQAnswersCopilotComponent <\/strong>holds the conversation id as instance state, set once on the first question and reused for every follow-up after that:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:flex;align-items:center;padding:10px 0px 10px 16px;margin-bottom:-2px;width:100%;text-align:left;background-color:#2b2b2b;color:#c7c7c7\">TypeScript<\/span><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>private async _ask(questionText: string): Promise&lt;void> {\n  const requestId = ++this._askRequestId;\n  ...\n  try {\n    if (!this._conversationId) {\n      this._conversationId = await this._service.startConversation();\n    }\n\n    const answer = await this._service.sendMessage(this._conversationId, questionText);\n\n    if (requestId !== this._askRequestId) {\n      return;\n    }\n...<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #9CDCFE\">private<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #9CDCFE\">async<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #DCDCAA\">_ask<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #9CDCFE\">questionText<\/span><span style=\"color: #D4D4D4\">: <\/span><span style=\"color: #9CDCFE\">string<\/span><span style=\"color: #D4D4D4\">): <\/span><span style=\"color: #4EC9B0\">Promise<\/span><span style=\"color: #D4D4D4\">&lt;<\/span><span style=\"color: #569CD6\">void<\/span><span style=\"color: #D4D4D4\">&gt; {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  const <\/span><span style=\"color: #9CDCFE\">requestId<\/span><span style=\"color: #D4D4D4\"> = ++<\/span><span style=\"color: #569CD6\">this<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">_askRequestId<\/span><span style=\"color: #D4D4D4\">;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  ...<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #9CDCFE\">try<\/span><span style=\"color: #D4D4D4\"> {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #DCDCAA\">if<\/span><span style=\"color: #D4D4D4\"> (!this._conversationId) {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">      <\/span><span style=\"color: #569CD6\">this<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">_conversationId<\/span><span style=\"color: #D4D4D4\"> = <\/span><span style=\"color: #C586C0\">await<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">this<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">_service<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #DCDCAA\">startConversation<\/span><span style=\"color: #D4D4D4\">();<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    }<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    const <\/span><span style=\"color: #9CDCFE\">answer<\/span><span style=\"color: #D4D4D4\"> = <\/span><span style=\"color: #C586C0\">await<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">this<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">_service<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #DCDCAA\">sendMessage<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #569CD6\">this<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">_conversationId<\/span><span style=\"color: #D4D4D4\">, <\/span><span style=\"color: #9CDCFE\">questionText<\/span><span style=\"color: #D4D4D4\">);<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #DCDCAA\">if<\/span><span style=\"color: #D4D4D4\"> (<\/span><span style=\"color: #9CDCFE\">requestId<\/span><span style=\"color: #D4D4D4\"> !== this._askRequestId) {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">      <\/span><span style=\"color: #C586C0\">return<\/span><span style=\"color: #D4D4D4\">;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    }<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">...<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">That requestId check is easy to skip, and worth not skipping. The follow-up box lets someone ask a second question while the first answer is still loading, or hit enter twice without meaning to, and nothing in the UI stops them from doing it. Without that guard, a slow first response landing after a faster second one would silently overwrite the newer, correct answer with a stale one. _askRequestId increments on every call, and a response only gets applied if it&#8217;s still the most recent request in flight. Anything superseded gets discarded without ceremony.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That bug is arguably nastier than the citation one, because nothing about it shows up on screen unless you&#8217;re specifically trying to trigger a race. It just quietly shows the wrong answer sometimes.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Mock first, on purpose<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most samples in the gallery ship with useMock: true by default, and Work IQ needs that convention more than any of them: it&#8217;s a preview feature, provisioned and licensed through Copilot Credits, gated behind a tenant admin&#8217;s approval before a single live call can succeed. Nobody cloning this sample should have to clear all of that just to see what it does.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Building against mock data shaped like the documented response let the genuinely hard parts, entity parsing and citation grouping, get proven out in the <a href=\"https:\/\/learn.microsoft.com\/en-us\/sharepoint\/dev\/spfx\/copilot\/overview-copilot-apps\">Copilot Workbench<\/a> before any tenant setup existed at all.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"526\" src=\"https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-123422-1024x526.png\" alt=\"\" class=\"wp-image-852\" srcset=\"https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-123422-1024x526.png 1024w, https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-123422-300x154.png 300w, https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-123422-768x394.png 768w, https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-123422-1536x789.png 1536w, https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-123422-769x395.png 769w, https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-123422.png 1704w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Testing live without deploying anything<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Getting to a real call didn&#8217;t need a full deployment. AadHttpClient runs through a tenant-wide Entra app every SharePoint tenant already has: the SharePoint Online Client Extensibility service principal. Grant that app the WorkIQAgent.Ask permission directly in Entra admin center, no .sppkg, no app catalog, and the same hosted Workbench that&#8217;s already loading the local build starts making real calls.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It worked. First real query, first real Work IQ response, rendered straight through the component. CORS wasn&#8217;t a problem either, a live browser POST succeeded outright.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What broke on the first real query<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The question was &#8220;How many meeting do I have next week?&#8221; Work IQ found 9 meetings, real ones, off a real calendar.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It rendered as 123456789. Nine citation numbers, stripped of their brackets and links, jammed together right after &#8220;I found 9 meetings scheduled in the next week.&#8221; Under the bullet listing the recurring daily standup, a different jumble: 234679.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The parser had been built against Microsoft&#8217;s documented response example, where citations are [^n^] footnote markers resolved against a flat attributions array. That format never showed up once in a real response. What Work IQ actually sent was ordinary markdown links, <a href=\"https:\/\/teams.microsoft.com\/\u2026\">1<\/a>, <a href=\"https:\/\/\u2026\">2<\/a>, nine of them stacked back to back right after the opening sentence. A parser built to expect footnotes just let those links fall through as raw text, and stripping markdown syntax from unrecognized brackets is exactly how you end up with a string of bare digits.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"948\" height=\"372\" src=\"https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-140225.png\" alt=\"\" class=\"wp-image-853\" srcset=\"https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-140225.png 948w, https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-140225-300x118.png 300w, https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-140225-768x301.png 768w, https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-140225-769x302.png 769w\" sizes=\"auto, (max-width: 948px) 100vw, 948px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">entityParser.ts now handles both shapes in one combined pattern, matched in a single pass so the alternatives can&#8217;t fight over the same characters:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:flex;align-items:center;padding:10px 0px 10px 16px;margin-bottom:-2px;width:100%;text-align:left;background-color:#2b2b2b;color:#c7c7c7\">TypeScript<\/span><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>const INLINE_PATTERN =\n  \/&lt;(Person|Event|File)>(&#91;\\s\\S&#93;*?)&lt;\\\/\\1>|\\&#91;\\^(\\d+)\\^\\&#93;|\\[(&#91;^\\&#93;\\n]*)\\]\\((&#91;^)\\s&#93;*)\\)|\\*\\*(&#91;\\s\\S&#93;+?)\\*\\*\/g;<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #569CD6\">const<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #4FC1FF\">INLINE_PATTERN<\/span><span style=\"color: #D4D4D4\"> =<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #D16969\">\/&lt;<\/span><span style=\"color: #CE9178\">(<\/span><span style=\"color: #D16969\">Person<\/span><span style=\"color: #DCDCAA\">|<\/span><span style=\"color: #D16969\">Event<\/span><span style=\"color: #DCDCAA\">|<\/span><span style=\"color: #D16969\">File<\/span><span style=\"color: #CE9178\">)<\/span><span style=\"color: #D16969\">&gt;<\/span><span style=\"color: #CE9178\">(&#91;<\/span><span style=\"color: #D16969\">\\s\\S<\/span><span style=\"color: #CE9178\">&#93;<\/span><span style=\"color: #D7BA7D\">*?<\/span><span style=\"color: #CE9178\">)<\/span><span style=\"color: #D16969\">&lt;<\/span><span style=\"color: #D7BA7D\">\\\/<\/span><span style=\"color: #569CD6\">\\1<\/span><span style=\"color: #D16969\">&gt;<\/span><span style=\"color: #DCDCAA\">|<\/span><span style=\"color: #D7BA7D\">\\&#91;\\^<\/span><span style=\"color: #CE9178\">(<\/span><span style=\"color: #D16969\">\\d<\/span><span style=\"color: #D7BA7D\">+<\/span><span style=\"color: #CE9178\">)<\/span><span style=\"color: #D7BA7D\">\\^\\&#93;<\/span><span style=\"color: #DCDCAA\">|<\/span><span style=\"color: #D7BA7D\">\\[<\/span><span style=\"color: #CE9178\">(&#91;^<\/span><span style=\"color: #D7BA7D\">\\&#93;<\/span><span style=\"color: #D16969\">\\n<\/span><span style=\"color: #CE9178\">]<\/span><span style=\"color: #D7BA7D\">*<\/span><span style=\"color: #CE9178\">)<\/span><span style=\"color: #D7BA7D\">\\]\\(<\/span><span style=\"color: #CE9178\">(&#91;^<\/span><span style=\"color: #D16969\">)\\s<\/span><span style=\"color: #CE9178\">&#93;<\/span><span style=\"color: #D7BA7D\">*<\/span><span style=\"color: #CE9178\">)<\/span><span style=\"color: #D7BA7D\">\\)<\/span><span style=\"color: #DCDCAA\">|<\/span><span style=\"color: #D7BA7D\">\\*\\*<\/span><span style=\"color: #CE9178\">(&#91;<\/span><span style=\"color: #D16969\">\\s\\S<\/span><span style=\"color: #CE9178\">&#93;<\/span><span style=\"color: #D7BA7D\">+?<\/span><span style=\"color: #CE9178\">)<\/span><span style=\"color: #D7BA7D\">\\*\\*<\/span><span style=\"color: #D16969\">\/<\/span><span style=\"color: #569CD6\">g<\/span><span style=\"color: #D4D4D4\">;<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">Four alternatives: entity tags, footnote markers, markdown links, bold text. Order matters here in a way that isn&#8217;t obvious until it bites you: a footnote marker like [^1^] has to be tried before the plain link pattern, or the link alternative partially matches it first and mangles it the same way the original bug did. That&#8217;s a one-line comment in the real file now, and it&#8217;s there because of exactly this.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second problem was worse, and it wasn&#8217;t visible on screen at all. attributions came back empty, [], on every real message. The actual citation data lived in a field the documented examples never mentioned: references, a dictionary keyed by URL fragment, each entry carrying the real Teams deep link. The source panel, the entire point of building this component instead of just letting Copilot answer in text, had been reading from a field real Work IQ never populates.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The fix<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">citations.ts now tries the documented shape first and falls back to the real one:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:flex;align-items:center;padding:10px 0px 10px 16px;margin-bottom:-2px;width:100%;text-align:left;background-color:#2b2b2b;color:#c7c7c7\">TypeScript<\/span><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>export function toCitations(\n  attributions: IWorkIQAttribution[] | undefined,\n  references?: { &#91;key: string&#93;: IWorkIQConversationReference }\n): ICitation[] {\n  const fromAttributions = toCitationsFromAttributions(attributions || []);\n  if (fromAttributions.length > 0) {\n    return fromAttributions;\n  }\n\n  return toCitationsFromReferences(references);\n}\n<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #C586C0\">export<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #569CD6\">function<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #DCDCAA\">toCitations<\/span><span style=\"color: #D4D4D4\">(<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #9CDCFE\">attributions<\/span><span style=\"color: #D4D4D4\">: <\/span><span style=\"color: #4EC9B0\">IWorkIQAttribution<\/span><span style=\"color: #D4D4D4\">[] | <\/span><span style=\"color: #4EC9B0\">undefined<\/span><span style=\"color: #D4D4D4\">,<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #9CDCFE\">references<\/span><span style=\"color: #D4D4D4\">?: { &#91;<\/span><span style=\"color: #9CDCFE\">key<\/span><span style=\"color: #D4D4D4\">: <\/span><span style=\"color: #4EC9B0\">string<\/span><span style=\"color: #D4D4D4\">&#93;: <\/span><span style=\"color: #4EC9B0\">IWorkIQConversationReference<\/span><span style=\"color: #D4D4D4\"> }<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">): <\/span><span style=\"color: #4EC9B0\">ICitation<\/span><span style=\"color: #D4D4D4\">[] {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #569CD6\">const<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #4FC1FF\">fromAttributions<\/span><span style=\"color: #D4D4D4\"> = <\/span><span style=\"color: #DCDCAA\">toCitationsFromAttributions<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #9CDCFE\">attributions<\/span><span style=\"color: #D4D4D4\"> || []);<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #C586C0\">if<\/span><span style=\"color: #D4D4D4\"> (<\/span><span style=\"color: #9CDCFE\">fromAttributions<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">length<\/span><span style=\"color: #D4D4D4\"> &gt; <\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">) {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    <\/span><span style=\"color: #C586C0\">return<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #9CDCFE\">fromAttributions<\/span><span style=\"color: #D4D4D4\">;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  }<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #C586C0\">return<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #DCDCAA\">toCitationsFromReferences<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #9CDCFE\">references<\/span><span style=\"color: #D4D4D4\">);<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">}<\/span><\/span>\n<span class=\"line\"><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">The comment sitting above that function in the file says it plainly: attributions is &#8220;empty on every live response so far.&#8221; references is what actually carries the data, every time, so the fallback is really the primary path in practice, and the function is written to make that obvious rather than bury it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Re-running the same query afterward produced 9 separate, clickable citations and a source panel with 9 real rows, each one linking straight into the actual Teams meeting.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s not a manual check I ran once and moved on from. The exact text that produced 123456789 is now a permanent fixture, shared by entityParser.test.ts and citations.test.ts, with the failure mode itself written directly into the assertion:<\/p>\n\n\n\n<div class=\"wp-block-kevinbatdorf-code-block-pro\" data-code-block-pro-font-family=\"Code-Pro-JetBrains-Mono\" style=\"font-size:.875rem;font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;line-height:1.25rem;--cbp-tab-width:2;tab-size:var(--cbp-tab-width, 2)\"><span style=\"display:flex;align-items:center;padding:10px 0px 10px 16px;margin-bottom:-2px;width:100%;text-align:left;background-color:#2b2b2b;color:#c7c7c7\">TypeScript<\/span><span role=\"button\" tabindex=\"0\" style=\"color:#D4D4D4;display:none\" aria-label=\"Copy\" class=\"code-block-pro-copy-button\"><pre class=\"code-block-pro-copy-button-pre\" aria-hidden=\"true\"><textarea class=\"code-block-pro-copy-button-textarea\" tabindex=\"-1\" aria-hidden=\"true\" readonly>it('parses the opening sentence into nine distinct citationLink segments, not one concatenated string', () => {\n  const blocks = parseAnswer(MOCK_MEETINGS_ANSWER_TEXT);\n  const opening = blocks&#91;0&#93;;\n\n  const citationLinks = opening.segments.filter(\n    (segment) => segment.kind === 'citationLink'\n  );\n\n  expect(citationLinks.length).toBe(9);\n\n  const rawConcatenatedDigits = opening.segments.filter(\n    (segment) => segment.kind === 'text' &amp;&amp; \/^\\d{2,}$\/.test(segment.text)\n  );\n  expect(rawConcatenatedDigits).toEqual([]);\n});<\/textarea><\/pre><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" style=\"width:24px;height:24px\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path class=\"with-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\"><\/path><path class=\"without-check\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2\"><\/path><\/svg><\/span><pre class=\"shiki dark-plus\" style=\"background-color: #1E1E1E\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color: #DCDCAA\">it<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #CE9178\">&#39;parses the opening sentence into nine distinct citationLink segments, not one concatenated string&#39;<\/span><span style=\"color: #D4D4D4\">, () <\/span><span style=\"color: #569CD6\">=&gt;<\/span><span style=\"color: #D4D4D4\"> {<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #569CD6\">const<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #4FC1FF\">blocks<\/span><span style=\"color: #D4D4D4\"> = <\/span><span style=\"color: #DCDCAA\">parseAnswer<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #4FC1FF\">MOCK_MEETINGS_ANSWER_TEXT<\/span><span style=\"color: #D4D4D4\">);<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #569CD6\">const<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #4FC1FF\">opening<\/span><span style=\"color: #D4D4D4\"> = <\/span><span style=\"color: #9CDCFE\">blocks<\/span><span style=\"color: #D4D4D4\">&#91;<\/span><span style=\"color: #B5CEA8\">0<\/span><span style=\"color: #D4D4D4\">&#93;;<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #569CD6\">const<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #4FC1FF\">citationLinks<\/span><span style=\"color: #D4D4D4\"> = <\/span><span style=\"color: #9CDCFE\">opening<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">segments<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #DCDCAA\">filter<\/span><span style=\"color: #D4D4D4\">(<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    (<\/span><span style=\"color: #9CDCFE\">segment<\/span><span style=\"color: #D4D4D4\">) <\/span><span style=\"color: #569CD6\">=&gt;<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #9CDCFE\">segment<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">kind<\/span><span style=\"color: #D4D4D4\"> === <\/span><span style=\"color: #CE9178\">&#39;citationLink&#39;<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  );<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #DCDCAA\">expect<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #9CDCFE\">citationLinks<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">length<\/span><span style=\"color: #D4D4D4\">).<\/span><span style=\"color: #DCDCAA\">toBe<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #B5CEA8\">9<\/span><span style=\"color: #D4D4D4\">);<\/span><\/span>\n<span class=\"line\"><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #569CD6\">const<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #4FC1FF\">rawConcatenatedDigits<\/span><span style=\"color: #D4D4D4\"> = <\/span><span style=\"color: #9CDCFE\">opening<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">segments<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #DCDCAA\">filter<\/span><span style=\"color: #D4D4D4\">(<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">    (<\/span><span style=\"color: #9CDCFE\">segment<\/span><span style=\"color: #D4D4D4\">) <\/span><span style=\"color: #569CD6\">=&gt;<\/span><span style=\"color: #D4D4D4\"> <\/span><span style=\"color: #9CDCFE\">segment<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">kind<\/span><span style=\"color: #D4D4D4\"> === <\/span><span style=\"color: #CE9178\">&#39;text&#39;<\/span><span style=\"color: #D4D4D4\"> &amp;&amp;<\/span><span style=\"color: #D16969\"> \/<\/span><span style=\"color: #DCDCAA\">^<\/span><span style=\"color: #D16969\">\\d<\/span><span style=\"color: #D7BA7D\">{2,}<\/span><span style=\"color: #DCDCAA\">$<\/span><span style=\"color: #D16969\">\/<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #DCDCAA\">test<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #9CDCFE\">segment<\/span><span style=\"color: #D4D4D4\">.<\/span><span style=\"color: #9CDCFE\">text<\/span><span style=\"color: #D4D4D4\">)<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  );<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">  <\/span><span style=\"color: #DCDCAA\">expect<\/span><span style=\"color: #D4D4D4\">(<\/span><span style=\"color: #9CDCFE\">rawConcatenatedDigits<\/span><span style=\"color: #D4D4D4\">).<\/span><span style=\"color: #DCDCAA\">toEqual<\/span><span style=\"color: #D4D4D4\">([]);<\/span><\/span>\n<span class=\"line\"><span style=\"color: #D4D4D4\">});<\/span><\/span><\/code><\/pre><\/div>\n\n\n\n<p class=\"wp-block-paragraph\">That second assertion isn&#8217;t testing that the parser works in general. It&#8217;s testing that this specific bug, the one that actually rendered on a real screen, can&#8217;t come back without a test failing first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The citation-resolution tests go a step further than the bug required. One of them deliberately shuffles the mock references out of the order their markers appear in the text, to prove resolution happens by URL fragment and not by assuming the nth marker lines up with the nth reference. Nothing about the original bug demanded that test. It&#8217;s the kind of edge case that&#8217;s easy to get right by accident and only stays right on purpose.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"743\" height=\"1024\" src=\"https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-151642-743x1024.png\" alt=\"\" class=\"wp-image-854\" srcset=\"https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-151642-743x1024.png 743w, https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-151642-218x300.png 218w, https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-151642-768x1058.png 768w, https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-151642-769x1060.png 769w, https:\/\/codingwithramin.com\/wp-content\/uploads\/2026\/08\/Screenshot-2026-08-21-151642.png 1024w\" sizes=\"auto, (max-width: 743px) 100vw, 743px\" \/><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Where it stands<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/github.com\/pnp\/spfx-copilot-apps\/pull\/32\">The PR is open now<\/a>. It ships with useMock: true by default, like every other sample in the gallery. The mock responses now model both citation shapes: what the docs show, and what a live tenant actually returns. And it leaves a documented, working pattern behind for calling a non-Graph, Entra-secured API from a Copilot Component with no middle tier. It&#8217;s the first sample in the gallery that needed one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">references doesn&#8217;t appear anywhere in Microsoft&#8217;s own documented example. The only way I found it was by making the actual call against a real tenant and reading exactly what came back, not by reading the docs more carefully.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Ask Copilot to show your team&#8217;s open approvals and it hands you a paragraph. No buttons, nothing to click. You still have to leave the chat and open SharePoint to act on any of it. SharePoint Copilot Apps exist to close that gap, and once I understood what that actually meant, I wanted to build one rather than just read the announcement. Public preview shipped July 9, 2026. I picked a scenario nobody in the&hellip;<\/p>\n","protected":false},"author":1,"featured_media":855,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[55,4,3,77],"tags":[7,11,78],"class_list":["post-851","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai","category-sharepoint-framework","category-sharepoint-online","category-work-iq","tag-sharepoint","tag-spfx","tag-work-iq"],"_links":{"self":[{"href":"https:\/\/codingwithramin.com\/index.php?rest_route=\/wp\/v2\/posts\/851","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/codingwithramin.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/codingwithramin.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/codingwithramin.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/codingwithramin.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=851"}],"version-history":[{"count":2,"href":"https:\/\/codingwithramin.com\/index.php?rest_route=\/wp\/v2\/posts\/851\/revisions"}],"predecessor-version":[{"id":858,"href":"https:\/\/codingwithramin.com\/index.php?rest_route=\/wp\/v2\/posts\/851\/revisions\/858"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/codingwithramin.com\/index.php?rest_route=\/wp\/v2\/media\/855"}],"wp:attachment":[{"href":"https:\/\/codingwithramin.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=851"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/codingwithramin.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=851"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/codingwithramin.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=851"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}