# Microsoft Copilot Studio

> Send agent conversations from Microsoft Copilot Studio to Dimension Labs with the universal API.

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](/api/tracker).

## 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](https://learn.microsoft.com/en-us/microsoft-copilot-studio/admin-transcript-controls).
- 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](/api/tracker#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](https://learn.microsoft.com/en-us/microsoft-copilot-studio/analytics-transcripts-powerapps)).

## 1. Get API access in Copilot Studio

The script signs in as an app, not as a person ([Use OAuth with Dataverse](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/authenticate-oauth)).

1. 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**.
2. On the app's **Overview** page, copy the **Application (client) ID** and the **Directory (tenant) ID**.
3. Select **Certificates & secrets > Client secrets > New client secret**, choose an expiry, and select **Add**. Copy the secret **Value** now: Entra shows it only once.
4. In the Power Platform admin center, select **Manage**, then **Environments**, and pick your agent's environment. Select **Settings**, then **Users + permissions > Application users**.
5. 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](https://learn.microsoft.com/en-us/power-platform/admin/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

List the ConversationTranscript rows written since your last run, oldest first:

```bash
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, so `conversationstarttime` is often earlier than the time the row appears.
- `Prefer: odata.maxpagesize` sets the page size (Dataverse returns up to 5,000 rows a page). Each `content` value can be up to 1 MB, so keep pages small. Follow `@odata.nextLink` until it is missing ([page results](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/query/page-results)).
- `content` is the whole transcript as JSON: every activity in the conversation. Message activities carry the `text`; `from.role` is `1` for the user and `0` for the agent; `timestamp` is in Unix seconds.
- A transcript larger than 1 MB is split into several rows with the same `name` and `conversationstarttime` and different `metadata.BatchId` values. A conversation that resumes after 30 minutes gets a new row with the same `name`. Both land in the same conversation in Dimension Labs, because the guide uses `name` as 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](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/api-limits)).

## 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](/data/pii-redaction).

## 4. Send it

```js
// copilot-studio.mjs. Environment: DATAVERSE_URL (https://yourorg.crm.dynamics.com), AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, DL_API_KEY.


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

Save the code as `copilot-studio.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncCopilotStudio as sync } from './copilot-studio.mjs';`.

- **History first:** `node run.mjs 2026-09-27` sends every transcript written since that date to `/trackhistorical`. With the default retention, the table holds only the past 30 days. Then start a [backfill](/dimensions/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 `/trackhistorical` and 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](/data/upload-a-file) (check it first with the [file checker](/data/check-a-file)), then run a backfill.

## Copilot Studio reference

- [Understand downloaded conversation transcripts from Power Apps](https://learn.microsoft.com/en-us/microsoft-copilot-studio/analytics-transcripts-powerapps)
- [Control transcript access](https://learn.microsoft.com/en-us/microsoft-copilot-studio/admin-transcript-controls)
- [conversationtranscript table reference](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/conversationtranscript)
- [Use OAuth authentication with Dataverse](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/authenticate-oauth)
- [Manage application users](https://learn.microsoft.com/en-us/power-platform/admin/manage-application-users)
- [Register an application in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app)
- [Add a client secret](https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials)
- [Client credentials flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow)
- [Page results using OData](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/query/page-results)
- [Service protection API limits](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/api-limits)

Checked against Microsoft's documentation on 2026-10-26.
