02 Get your data in /Integrations
Microsoft Copilot Studio
Send agent conversations from Microsoft Copilot Studio to Dimension Labs with the universal API.
- MC Microsoft Copilot StudioTickets, chats, calls, reviews
- ⟳ Your jobPull new · map fields · every 15 min
- { } Universal APIPOST /track · /trackhistorical
- Dimension LabsRead in full · enriched daily
This brings in your Copilot Studio agent conversations, message by message: what each user wrote and what the agent answered. Copilot Studio saves each conversation as a transcript in the Dataverse ConversationTranscript table, and each transcript becomes one conversation. You run it on your side: a small script pulls from the Dataverse Web API and posts each message to the universal API.
What you need
Section titled “What you need”- An agent in a regular Power Platform environment. Transcripts aren’t written for Dataverse for Teams environments, and they aren’t stored for agents in developer environments.
- Transcript saving turned on for the environment (it is on by default in new environments). See Control transcript access.
- A Microsoft Entra ID admin who can register an app, and a Power Platform admin who can add it to the environment as an application user with the Bot Transcript Viewer security role. Only admins can grant that role; the Environment Maker role doesn’t include it.
- Your environment URL, for example
https://yourorg.crm.dynamics.com. - A Dimension Labs API key for a new Universal API source. See Your API key.
Run it on a schedule from day one. By default, a Power Apps bulk-delete job removes transcripts older than 30 days, so the API only ever holds about a month of conversations. A job that stops for longer than that loses data for good. You can keep transcripts longer by replacing that bulk-delete job with one on a longer schedule (see “Change the default retention period” in Understand downloaded conversation transcripts).
1. Get API access in Copilot Studio
Section titled “1. Get API access in Copilot Studio”The script signs in as an app, not as a person (Use OAuth with Dataverse).
- In the Microsoft Entra admin center, browse to Entra ID > App registrations and select New registration. Enter a name, keep Single tenant only, and select Register.
- On the app’s Overview page, copy the Application (client) ID and the Directory (tenant) ID.
- Select Certificates & secrets > Client secrets > New client secret, choose an expiry, and select Add. Copy the secret Value now: Entra shows it only once.
- In the Power Platform admin center, select Manage, then Environments, and pick your agent’s environment. Select Settings, then Users + permissions > Application users.
- Select + New app user, then + Add an app, pick the app you registered, and select Add. Choose a business unit, and under security roles add Bot Transcript Viewer. Select Save, then Create (manage application users).
The script gets a token with the client credentials flow, using the scope https://yourorg.crm.dynamics.com/.default, and sends it as Authorization: Bearer <token>.
2. Pull new transcripts
Section titled “2. Pull new transcripts”List the ConversationTranscript rows written since your last run, oldest first:
curl -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/json" -H "OData-MaxVersion: 4.0" -H "OData-Version: 4.0" \ -H "Prefer: odata.maxpagesize=50" \ "https://yourorg.crm.dynamics.com/api/data/v9.2/conversationtranscripts?\$select=conversationtranscriptid,name,content,metadata,conversationstarttime,createdon&\$filter=createdon%20ge%202026-10-01T00:00:00Z&\$orderby=createdon%20asc"- Filter on
createdon, the time the row was written. Copilot Studio writes a transcript after 30 minutes of inactivity, soconversationstarttimeis often earlier than the time the row appears. Prefer: odata.maxpagesizesets the page size (Dataverse returns up to 5,000 rows a page). Eachcontentvalue can be up to 1 MB, so keep pages small. Follow@odata.nextLinkuntil it is missing (page results).contentis the whole transcript as JSON: every activity in the conversation. Message activities carry thetext;from.roleis1for the user and0for the agent;timestampis in Unix seconds.- A transcript larger than 1 MB is split into several rows with the same
nameandconversationstarttimeand differentmetadata.BatchIdvalues. A conversation that resumes after 30 minutes gets a new row with the samename. Both land in the same conversation in Dimension Labs, because the guide usesnameas the id.
Dataverse’s default service protection limits, per user: 6,000 requests and 20 minutes of combined execution time in a 5-minute sliding window, and 52 or more concurrent requests. Going over returns 429 with a Retry-After header in seconds (API limits).
3. Map the fields
Section titled “3. Map the fields”| Dimension Labs | Copilot Studio | Notes |
|---|---|---|
text | activity text | Only activities with type message. Skip events, traces and empty text. |
userId | from.id of the user’s activities | Copilot Studio hashes this id before it writes the transcript. Use it on both the user’s and the agent’s messages. Fall back to the sessionId. |
incoming | activity from.role | 1 is the user, so incoming: true. 0 is the agent. |
sessionId | row name | name is the conversation id followed by the agent id. Prefix it, for example copilot-<name>. |
conversationId | row name | Same value as sessionId. |
dimensionlabs_timestamp | activity timestamp | Unix seconds; toMillis turns them into milliseconds. Activities in the same second are spaced 1,000 ms apart. |
platformJson | activity channelId; metadata.BotName; the SessionInfo activity’s outcome; conversationtranscriptid | outcome is Escalated, Resolved or Abandon. |
Conversations from the Copilot Studio test pane carry a ConversationInfo activity with isDesignMode set; the code skips them. Agent answers that use SharePoint as a knowledge source aren’t in transcripts: the answer is marked REDACTED.
Personal data. Users sometimes type names, emails or order numbers: redact them before you send. See PII redaction.
4. Send it
Section titled “4. Send it”// copilot-studio.mjs. Environment: DATAVERSE_URL (https://yourorg.crm.dynamics.com), AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, DL_API_KEY.import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
const HOUR = 60 * 60 * 1000;const sleep = (ms) => new Promise((r) => setTimeout(r, ms));const ORG = process.env.DATAVERSE_URL.replace(/\/$/, '');const iso = (ms) => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');const parse = (s) => { try { return JSON.parse(s); } catch { return null; } };
// Gets an app token from Microsoft Entra ID and reuses it until shortly before it expires.let token = { value: '', expires: 0 };async function getToken() { if (Date.now() < token.expires - 60_000) return token.value; const res = await fetch(`https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: process.env.AZURE_CLIENT_ID, client_secret: process.env.AZURE_CLIENT_SECRET, scope: `${ORG}/.default`, grant_type: 'client_credentials', }), }); if (!res.ok) throw new Error(`Microsoft Entra ID returned ${res.status}: ${await res.text()}`); const t = await res.json(); token = { value: t.access_token, expires: Date.now() + t.expires_in * 1000 }; return token.value;}
// Retries Dataverse's rate limits (429) and server errors (5xx), honoring Retry-After.async function get(url) { for (let attempt = 0; ; attempt++) { const res = await fetch(url, { headers: { Authorization: `Bearer ${await getToken()}`, Accept: 'application/json', 'OData-MaxVersion': '4.0', 'OData-Version': '4.0', Prefer: 'odata.maxpagesize=50', // each transcript can be up to 1 MB }, }); if (res.ok) return res.json(); if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Dataverse returned ${res.status}: ${await res.text()}`); await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000); }}
// Sends every message in transcripts written at or after `since` (Unix ms). Returns the new `since`.export async function syncCopilotStudio({ since, historical = false }) { let newest = since; const select = 'conversationtranscriptid,name,content,metadata,conversationstarttime,createdon'; let next = `${ORG}/api/data/v9.2/conversationtranscripts?$select=${select}` + `&$filter=${encodeURIComponent(`createdon ge ${iso(since)}`)}&$orderby=${encodeURIComponent('createdon asc')}`; while (next) { const page = await get(next); for (const row of page.value ?? []) { newest = Math.max(newest, toMillis(row.createdon)); const content = parse(row.content); const activities = (Array.isArray(content) ? content : content?.activities ?? []) .slice().sort((a, b) => toMillis(a.timestamp ?? 0) - toMillis(b.timestamp ?? 0)); if (activities.some((a) => a.valueType === 'ConversationInfo' && a.value?.isDesignMode)) continue; // test pane const meta = parse(row.metadata) ?? {}; const id = `copilot-${row.name}`.replace(/:/g, '-'); const user = activities.find((a) => Number(a.from?.role) === 1 && a.from?.id)?.from.id; const outcome = activities.find((a) => a.valueType === 'SessionInfo')?.value?.outcome; let last = 0; for (const a of activities) { if (a.type !== 'message' || !a.text?.trim()) continue; let ts = toMillis(a.timestamp); if (ts <= last) ts = last + 1000; // keep messages in the same second apart last = ts; await sendToDimensionLabs({ text: a.text, userId: user ? String(user).replace(/:/g, '-') : id, incoming: Number(a.from?.role) === 1, sessionId: id, conversationId: id, dimensionlabs_timestamp: ts, platformJson: { channel: a.channelId, bot_name: meta.BotName, outcome, transcript_id: row.conversationtranscriptid, activity_id: a.id }, }, { historical: historical || ts < Date.now() - HOUR }); } } next = page['@odata.nextLink']; } return newest;}5. Run it
Section titled “5. Run it”Save the code as copilot-studio.mjs in a project set up as in Set up your project, and run it with the scheduled sync runner: import { syncCopilotStudio as sync } from './copilot-studio.mjs';.
- History first:
node run.mjs 2026-09-27sends every transcript written since that date to/trackhistorical. With the default retention, the table holds only the past 30 days. Then start a backfill from the Backfill tab. - Then every 15 minutes: the scheduled job sends new messages to
/track, and they are enriched automatically every day. Because transcripts are written after 30 minutes of inactivity, many messages are already more than an hour old when the job sees them. Those go to/trackhistoricaland need a backfill. - Keep it running: transcripts older than the retention period are deleted, so a gap longer than that can’t be filled from the API.
- Re-runs are safe: duplicates are removed automatically. Each run re-reads the last transcript it saw, which is harmless.
You can also export the table instead: in Power Apps, select Tables > All, open ConversationTranscript, and choose Export > Export data. Convert the transcripts to the conversation format, upload the file (check it first with the file checker), then run a backfill.
Copilot Studio reference
Section titled “Copilot Studio reference”- Understand downloaded conversation transcripts from Power Apps
- Control transcript access
- conversationtranscript table reference
- Use OAuth authentication with Dataverse
- Manage application users
- Register an application in Microsoft Entra ID
- Add a client secret
- Client credentials flow
- Page results using OData
- Service protection API limits
Checked against Microsoft’s documentation on 2026-10-26.