Skip to main content

Track used skills, agents and MCP servers with the Copilot usage metrics API

As part of our AI adoption journey, we wanted to know which custom skills, agents and MCP servers were actually in use. So, we built a small tool that scanned our repositories for SKILL.md files, .agent.md files and MCP configuration.

It worked, but it answered the wrong question. It told us what existed, not what developers used.

The Copilot usage metrics API now closes that gap.

What changed?

In September the API got CLI customization metrics. The catch: every customer-defined skill, agent and MCP server was grouped under other, and slash commands under custom. Useful for trends, useless for “is our new skill being used?”.

Since October 22, the totals_by_skill, totals_by_custom_agent, totals_by_mcp, totals_by_slash_cmd and totals_by_plugin arrays show the configured name of your own items. Each list also holds up to 25 entries per record, up from five.

Remark: names are reported from Copilot CLI, the Copilot app, clients built on the Copilot SDK (like VS Code and JetBrains) and ACP integrations. You need Copilot CLI 1.0.97 or later, or Copilot SDK 1.0.21 or later. IDE integrations that aren’t built on the SDK don’t report names.

Getting the report

The API doesn’t return the data directly. It returns signed download links to NDJSON files. For an organization, the latest 28-day aggregated report looks like this:

gh api `
  -H "Accept: application/vnd.github+json" `
  -H "X-GitHub-Api-Version: 2026-03-10" `
  /orgs/ORG/copilot/metrics/reports/organization-28-day/latest

The response contains download_links, report_start_day and report_end_day. Let’s parse it and download the files:

$report = gh api `
  -H "X-GitHub-Api-Version: 2026-03-10" `
  /orgs/ORG/copilot/metrics/reports/organization-28-day/latest | ConvertFrom-Json

$i = 0
foreach ($url in $report.download_links) {
    Invoke-WebRequest -Uri $url -OutFile "report-$((++$i)).ndjson"
}

Per-user reports live under users-28-day/latest, and single days under organization-1-day?day=YYYY-MM-DD.

Remark: you need the Copilot usage metrics policy enabled, and either to be an organization owner or to have a role with the “View Organization Copilot Metrics” permission.

What does the data look like?

NDJSON is one JSON object per line, so we parse it line by line. In the 28-day aggregated report, the daily records sit in a day_totals array. Let’s look at what we got:

$records = Get-Content report-*.ndjson | ForEach-Object { $_ | ConvertFrom-Json }

$records.day_totals |
  Select-Object -First 3 -Property day, distinct_skill_use_count,
    totals_by_skill, totals_by_mcp, totals_by_custom_agent |
  ConvertTo-Json -Depth 5

Every entry holds a name and an interaction_count. The name field is called after the category. A skill entry looks like this:

{
  "skill": "my-skill",
  "interaction_count": 7
}

MCP entries use mcp in the same way.

A few things to know before you start charting:

  • Skills, slash commands and agents count invocations (custom agents count starts).
  • MCP servers only count connection and reconnection attempts, successful or not. Calling tools on an already connected server doesn’t increase the number.
  • Plugins count only skill invocations that come from a plugin. They’re a subset of the skill totals, so don’t add the two together.
  • Anything beyond 25 entries is combined into a trailing other.

Remark: the data comes from client telemetry. Outdated clients or blocked telemetry mean undercounting. The goal is to get adoption trends, not exact accounting.

Why do I still see “other”?

When I first ran this, I got this:

"totals_by_skill": [ { "skill": "other", "interaction_count": 7 } ],
"totals_by_mcp":   [ { "mcp": "other",   "interaction_count": 10 } ]

The changelog promised names, so what was wrong? Nothing. Names only appear for report days processed after the release on October 22. Earlier days are not reprocessed and keep other (or custom for slash commands).

The 28-day report spans four weeks, so if you add up the whole window, the old days dominate. Let’s look per day instead:

$records.day_totals | ForEach-Object {
    [pscustomobject]@{
        day    = $_.day
        skills = ($_.totals_by_skill | ForEach-Object { "$($_.skill)=$($_.interaction_count)" }) -join ', '
        mcp    = ($_.totals_by_mcp   | ForEach-Object { "$($_.mcp)=$($_.interaction_count)" }) -join ', '
    }
} | Sort-Object day | Format-Table -Wrap

The recent days show names, the older ones show other. The window rolls forward by itself.

If recent days also show only other, check these in order:

  • Client version: users on Copilot CLI older than 1.0.97 or an older SDK-based client still send unnamed telemetry. The per-user report shows totals_by_cli.last_known_cli_version.
  • Path-like names: names that look like file paths stay grouped under other by design.
  • Telemetry: blocked or outdated clients mean missing data.

Building a canvas extension to visualize it

Reading JSON in a terminal gets old quickly. The GitHub Copilot app has canvases for this: an interface that you and the agent share, opening in the app’s right side panel.

You don’t write the canvas by hand. In an agent session, type / and select /create-canvas, then describe what you want. A good prompt covers the workflow, what you should be able to do in the interface, and the data it works with:

/create-canvas Create a Copilot customization usage canvas for our organization.

Data: use the authenticated gh CLI to call
/orgs/ORG/copilot/metrics/reports/organization-28-day/latest
(X-GitHub-Api-Version: 2026-03-10), download the NDJSON files from
download_links and read the day_totals records.
Entries in totals_by_skill use a "skill" field and entries in
totals_by_mcp use an "mcp" field, both with interaction_count.
Inspect the first record to find the name field for totals_by_custom_agent.

Show: a tab per category (skills, custom agents, MCP servers) with a
horizontal bar chart of interaction_count over the whole period, and a
daily trend for the item I select. Show the distinct_*_use_count
values as a small summary. Days processed before 22 October 2026 only
contain "other"; leave those out of the chart and mention that in a note.

Actions: refresh data, and an agent-callable action that returns the
top 10 items per category.

Read-only. No GitHub writes and no source edits.

The agent generates the extension and opens it in the right panel. It’s saved as a canvas extension, and you choose where it lives:

  • Project scope: .github/extensions, shared with the repository team
  • User scope: ~/.copilot/extensions, personal to your machine

Remark: I have planned to package this canvas in a plugin and share it on GitHub. Once ready I'll update this post with a link.

That’s it!

We introduced a scanner for the “is it used?” question, keep it for “does it exist?”, and let a canvas do the charting.

Happy AI coding!

More information

Popular posts from this blog

Podman– Command execution failed with exit code 125

After updating WSL on one of the developer machines, Podman failed to work. When we took a look through Podman Desktop, we noticed that Podman had stopped running and returned the following error message: Error: Command execution failed with exit code 125 Here are the steps we tried to fix the issue: We started by running podman info to get some extra details on what could be wrong: >podman info OS: windows/amd64 provider: wsl version: 5.3.1 Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM Error: unable to connect to Podman socket: failed to connect: dial tcp 127.0.0.1:2655: connectex: No connection could be made because the target machine actively refused it. That makes sense as the podman VM was not running. Let’s check the VM: >podman machine list NAME         ...

Cache stampede: when our cache turned against us

While investigating some performance issues, we ran into an ASP.NET Core API that cached a fairly expensive aggregation query for 60 seconds. Under normal load, that was fine: one request rebuilds the cache, everyone else reads from it. Under peak load, dozens of requests would arrive in that same expiry window, all see a cache miss, and all fire the same expensive query in parallel. The database didn't like that. That was the moment when our caching layer stopped helping and started hurting. A burst of requests comes in at the same time, all miss the cache, and all go hammer the database or the downstream API at once. That's a cache stampede . The cache was supposed to protect our backend, and for a few hundred milliseconds it did the opposite. Why this happens IMemoryCache.GetOrCreate (and its async sibling) looks like it protects you, but it doesn't add any locking on its own. Look at the naive version: public async Task<Report> GetReportAsync(string key) ...

VS Code Planning mode

After the introduction of Plan mode in Visual Studio , it now also found its way into VS Code. Planning mode, or as I like to call it 'Hannibal mode', extends GitHub Copilot's Agent Mode capabilities to handle larger, multi-step coding tasks with a structured approach. Instead of jumping straight into code generation, Planning mode creates a detailed execution plan. If you want more details, have a look at my previous post . Putting plan mode into action VS Code takes a different approach compared to Visual Studio when using plan mode. Instead of a configuration setting that you can activate but have limited control over, planning is available as a separate chat mode/agent: I like this approach better than how Visual Studio does it as you have explicit control when plan mode is activated. Instead of immediately diving into execution, the plan agent creates a plan and asks some follow up questions: You can further edit the plan by clicking on ‘Open in Editor’: ...