Saturday, May 16, 2026

Build a Conversation Transcript Viewer Power App for Copilot Studio

Copilot Studio stores every bot conversation as a JSON payload inside the ConversationTranscripts Dataverse table. That raw JSON contains every user message, every bot reply, timestamps, session metadata, and even GPT trace data — but there is no built-in UI to browse it meaningfully. In this guide, we build a fully functional 3-screen Power Apps canvas app that lets you filter sessions, browse turns, and read full conversation detail — all driven directly from Dataverse with no intermediate API.

📋 Table of Contents

  1. What Is the ConversationTranscripts Table?
  2. App Architecture Overview
  3. Collections and Global Variables
  4. Screen 1 — Session List
  5. Screen 2 — Conversation Detail
  6. Screen 3 — Turn Detail
  7. Parsing the JSON Activities Array
  8. Matching User Prompts to Bot Responses
  9. Common Issues and Fixes
  10. Full Formula Reference

1. What Is the ConversationTranscripts Table?

Every time a user has a conversation with a Copilot Studio bot, a new record is created in the ConversationTranscripts Dataverse table. Each record contains a Content column that holds the full conversation as a JSON string. The JSON has an activities array — each activity is either a message, event, or trace.

Key columns used in this app:

ColumnTypePurpose
NameTextUnique record identifier (SessionId)
ContentText (JSON)Full activities array for the session
ConversationStartTimeDateTimeWhen the session started
bot_conversationtranscriptLookupThe bot that handled the session
conversationtranscriptTextSession GUID used as display identifier

A Turn = one complete user message + bot reply. A session with TurnCount = 0 means the user opened the bot but sent no message — nothing worth displaying.


2. App Architecture Overview

The app uses a 3-screen navigation model — session list → conversation detail → turn detail. Navigation flows forward with ScreenTransition.Cover and back with ScreenTransition.UnCover, giving a native app feel.

ScreenNameShows
Screen 1scrSessionListAll sessions, filtered by agent/date/search
Screen 2scrConversationDetailAll turns (Q&A pairs) for the selected session
Screen 3scrTurnDetailFull text of selected user prompt + bot response

3. Collections and Global Variables

NameTypePurpose
colSessionsCollectionSession list built from filtered Dataverse records
colCurrentConversationCollectionParsed turns for the selected session
gSelectedSessionRecord variableSession clicked on Screen 1
gSelectedTurnRecord variableTurn clicked on Screen 2

colSessions schema:

{
  SessionId:             Text,
  AgentName:             Text,
  ConversationStartTime: DateTime,
  Session:               Text,
  FirstPrompt:           Text,
  TurnCount:             Number
}

colCurrentConversation schema:

{
  UserPrompt:        Text,
  PromptTimestamp:   DateTime,
  BotResponse:       Text,
  ResponseTimestamp: DateTime
}

4. Screen 1 — Session List

Screen 1 has three filter controls (agent dropdown, start date, end date), a search box, and a Load Sessions button that populates colSessions.


Figure 1 — Screen 1: Session list with agent filter, date range, and keyword search

Conversation Transcripts Viewer
All Agents ▾ 01/05/2026 📅 15/05/2026 📅 Load Sessions Search by user prompt...
How do I check the warranty status of my SSD?
Support Bot  |  10 May 2026 09:14  |  Turns: 3
What is the difference between SLC and MLC NAND flash?
Support Bot  |  11 May 2026 11:30  |  Turns: 2
My drive is not showing up in Disk Management, what should I do?
Support Bot  |  12 May 2026 14:05  |  Turns: 4
Is the XD700 series compatible with PCIe Gen 5 slots?
Support Bot  |  13 May 2026 16:22  |  Turns: 2
How do I update firmware using the storage management tool?
Support Bot  |  14 May 2026 08:47  |  Turns: 1

Agent Dropdown Items:

=Table(
    {Value: "All"},
    Distinct(ConversationTranscripts, 'bot_conversationtranscript'.Name)
)

btnLoad OnSelect:

=Clear(colSessions);
ForAll(
    Filter(
        ConversationTranscripts,
        (ddAgent.Selected.Value = "All" ||
         'bot_conversationtranscript'.Name = ddAgent.Selected.Value) &&
        ConversationStartTime >= dpStart.SelectedDate &&
        ConversationStartTime <= DateAdd(dpEnd.SelectedDate, 1, TimeUnit.Days)
    ) As T,
    With({parsed: ParseJSON(T.Content)},
        With({firstUserMsg: First(Filter(Table(parsed.activities),
            Text(ThisRecord.Value.type) = "message" &&
            Text(ThisRecord.Value.from.role) = "1"))},
            Collect(colSessions, {
                SessionId:             T.Name,
                AgentName:             T.'bot_conversationtranscript'.Name,
                ConversationStartTime: T.ConversationStartTime,
                Session:               T.conversationtranscript,
                FirstPrompt:           Text(firstUserMsg.Value.text),
                TurnCount:             CountRows(Filter(Table(parsed.activities),
                                           Text(ThisRecord.Value.type) = "message" &&
                                           Text(ThisRecord.Value.from.role) = "1"))
            })
        )
    )
)

galSessions Items:

=SortByColumns(
    Filter(
        colSessions,
        TurnCount > 0 &&
        (IsBlank(txtSearch.Text) || txtSearch.Text in FirstPrompt)
    ),
    "ConversationStartTime",
    SortOrder.Descending
)

💡 Why TurnCount > 0? Sessions where the user opened the bot but typed nothing still create a Dataverse record with zero user message activities. Filtering these out keeps the list meaningful.

galSessions OnSelect:

=Set(gSelectedSession, ThisItem);
If(
    ThisItem.TurnCount > 0,
    Clear(colCurrentConversation);
    With({parsed: ParseJSON(LookUp(ConversationTranscripts,
        Name = gSelectedSession.SessionId).Content)},
        ForAll(
            Filter(Table(parsed.activities),
                Text(ThisRecord.Value.type) = "message" &&
                Text(ThisRecord.Value.from.role) = "1") As UserMsg,
            Collect(colCurrentConversation, {
                UserPrompt:        Text(UserMsg.Value.text),
                PromptTimestamp:   DateAdd(DateTime(1970,1,1,0,0,0),
                                       Value(UserMsg.Value.timestamp), TimeUnit.Seconds),
                BotResponse:       Text(First(Filter(Table(parsed.activities),
                                       Text(ThisRecord.Value.type) = "message" &&
                                       Text(ThisRecord.Value.from.role) = "0" &&
                                       Text(ThisRecord.Value.replyToId) = Text(UserMsg.Value.id)
                                   )).Value.text),
                ResponseTimestamp: DateAdd(DateTime(1970,1,1,0,0,0),
                                       Value(First(Filter(Table(parsed.activities),
                                           Text(ThisRecord.Value.type) = "message" &&
                                           Text(ThisRecord.Value.from.role) = "0" &&
                                           Text(ThisRecord.Value.replyToId) = Text(UserMsg.Value.id)
                                       )).Value.timestamp), TimeUnit.Seconds)
            })
        )
    );
    Navigate(scrConversationDetail, ScreenTransition.Cover)
)

5. Screen 2 — Conversation Detail

Screen 2 shows all turns for the selected session sorted by timestamp, with a search box to find specific turns by keyword.


Figure 2 — Screen 2: All Q&A turns for the selected session, with turn-level search

← Back Support Bot — Conversation Detail 12 May 2026  |  4 turns
Search by user prompt...
👤 My drive is not showing up in Disk Management, what should I do?
🤖 First, check that the drive is properly seated in its slot and all cables are securely connected. Then open Device Manager to check if the drive appears with a warning...
👤 It does appear in Device Manager but shows an error code 43.
🤖 Error code 43 typically means Windows has stopped the device due to a reported problem. Try uninstalling the device driver and restarting your PC...
👤 I reinstalled the driver but the issue persists. Should I update the firmware?
🤖 Yes, updating the firmware is a good next step. Download the Storage Management Tool from the support page and follow the firmware update wizard...
👤 The firmware update completed but the drive still shows the same error.
🤖 If the issue persists after a firmware update, the drive may need to be replaced under warranty. Please contact customer support with your drive serial number...

galConversation Items:

=SortByColumns(
    Filter(
        colCurrentConversation,
        !IsBlank(ThisRecord.UserPrompt) &&
        !IsBlank(ThisRecord.BotResponse) &&
        (IsBlank(txtSearchTurn.Text) || txtSearchTurn.Text in ThisRecord.UserPrompt)
    ),
    "PromptTimestamp",
    SortOrder.Ascending
)

galConversation OnSelect:

=Set(gSelectedTurn, ThisItem);
Navigate(scrTurnDetail, ScreenTransition.Cover)

💡 Tip: The three conditions in the gallery Items formula work together — blank turns are excluded, and search is applied only when the user types something. When the search box is empty, IsBlank(txtSearchTurn.Text) returns true and all valid turns are shown.


6. Screen 3 — Turn Detail

Screen 3 displays the full text of a single selected turn — user prompt and bot response — using read-only multiline text inputs. This solves the gallery truncation problem for long bot responses.


Figure 3 — Screen 3: Full user prompt and bot response for the selected turn

← Back Support Bot — Turn Detail 12 May 2026
👤 User Prompt
My drive is not showing up in Disk Management, what should I do?
🤖 Bot Response
First, check that the drive is properly seated in its slot and all cables are securely connected.

Then open Device Manager (Win + X → Device Manager) to see if the drive appears with a warning icon. If it does, right-click and select Update Driver.

If the drive does not appear at all, try connecting it to a different port or testing it on another machine to rule out a hardware fault. You can also run the Storage Management Tool to diagnose drive health.
🕐 Prompt: 12 May 2026 02:05 PM 🕐 Response: 12 May 2026 02:06 PM 🤖 Agent: Support Bot 🆔 Session: f3a91c02-7b4e-41d2...

ControlPropertyValue
txtUserPromptDefault=gSelectedTurn.UserPrompt
txtUserPromptDisplayMode=DisplayMode.View
txtUserPromptMode=TextMode.MultiLine
txtBotResponseDefault=gSelectedTurn.BotResponse
txtBotResponseDisplayMode=DisplayMode.View
txtBotResponseMode=TextMode.MultiLine
lblPromptTimestampText="Prompt: " & Text(gSelectedTurn.PromptTimestamp, "dd mmm yyyy hh:mm AM/PM")
lblResponseTimestampText="Response: " & Text(gSelectedTurn.ResponseTimestamp, "dd mmm yyyy hh:mm AM/PM")
lblAgentNameText="Agent: " & gSelectedSession.AgentName
btnBack3OnSelect=Navigate(scrConversationDetail, ScreenTransition.UnCover)

7. Parsing the JSON Activities Array

The Content column is a raw JSON string. Power Fx's ParseJSON() function converts it to an untyped object. Wrapping it in Table() converts the activities array into a Power Apps table with a Value column — each row representing one activity.

With({parsed: ParseJSON(Transcript.Content)},
    Table(parsed.activities)
)

The from.role field identifies who sent each message:

role valueMeaning
1User message
0Bot message

⚠️ Important: All values from ParseJSON are untyped. Always wrap field access in Text() before comparing:

// Correct
Text(ThisRecord.Value.from.role) = "1"

// Incorrect — silently fails
ThisRecord.Value.from.role = 1

8. Matching User Prompts to Bot Responses

Each bot response activity has a replyToId field containing the id of the user message it responds to. This is the key to correctly pairing prompts and responses.

BotResponse: Text(
    First(
        Filter(
            Table(parsed.activities),
            Text(ThisRecord.Value.type) = "message" &&
            Text(ThisRecord.Value.from.role) = "0" &&
            Text(ThisRecord.Value.replyToId) = Text(UserMsg.Value.id)
        )
    ).Value.text
)

⚠️ Critical scoping rule: Always reference parsed.activities directly inside the bot response Filter — never use an intermediate variable like allMessages. Power Apps ForAll closures can cause stale variable references across transcript records, resulting in bot responses from a different session appearing for the wrong user prompt.


9. Common Issues and Fixes

IssueCauseFix
Bot response shows text from a different session Intermediate variable leaks across ForAll iterations Reference Table(parsed.activities) directly in all filters
TurnCount = 0 sessions appear in list Gallery Items formula missing turn filter Add TurnCount > 0 to gallery Items Filter
Timestamps show 1970 or wrong date JSON timestamp is Unix epoch in seconds Use DateAdd(DateTime(1970,1,1,0,0,0), Value(...), TimeUnit.Seconds)
ParseJSON comparison returns no rows Comparing untyped value without Text() wrapper Always use Text(ThisRecord.Value.field) = "value"
Empty BotResponse for some turns Session abandoned before bot replied; no matching replyToId Expected behavior — filter with !IsBlank(ThisRecord.BotResponse)
Gallery shows all sessions despite filter Delegation limit exceeded on Dataverse Narrow the date range to stay within delegation limits

10. Full Formula Reference

Screen 1 — Gallery Items:

=SortByColumns(
    Filter(
        colSessions,
        TurnCount > 0 &&
        (IsBlank(txtSearch.Text) || txtSearch.Text in FirstPrompt)
    ),
    "ConversationStartTime",
    SortOrder.Descending
)

Screen 2 — Gallery Items:

=SortByColumns(
    Filter(
        colCurrentConversation,
        !IsBlank(ThisRecord.UserPrompt) &&
        !IsBlank(ThisRecord.BotResponse) &&
        (IsBlank(txtSearchTurn.Text) || txtSearchTurn.Text in ThisRecord.UserPrompt)
    ),
    "PromptTimestamp",
    SortOrder.Ascending
)

Epoch to DateTime conversion:

=DateAdd(
    DateTime(1970, 1, 1, 0, 0, 0),
    Value(activity.timestamp),
    TimeUnit.Seconds
)

Summary: The ConversationTranscripts Dataverse table is a rich source of Copilot Studio conversation data, but it requires JSON parsing to be useful. By combining Dataverse delegation for filtering, ParseJSON + Table() for activity parsing, and replyToId matching for turn pairing, you can build a production-ready 3-screen conversation viewer entirely in Power Apps with no code, no flows, and no external APIs. The scoping rule — always reference parsed.activities directly rather than through intermediate variables — is the most important detail to get right when working across multiple transcript records.

Labels: Copilot Studio, Dataverse, Power Apps, Power Fx, Power Platform, Canvas App

No comments:

Post a Comment

Featured Post

Microsoft Teams Development & Extensibility Complete Guide

Microsoft Teams Development & Extensibility — Complete Guide Tabs · Bots · Messaging Extensions · Adaptive Cards · Meeting Apps · Live ...

Popular posts