Skip to content
CloudOps
Newsletter
All guides
AI for Microsoft Teams By James Joyner IV · · 8 min read

Teams Tabs and Personal Apps for DevOps Dashboards

Stop making engineers tab out to Grafana. Embed your dashboards, runbooks, and on-call view as Teams tabs and personal apps that load in context.

  • #microsoft-teams
  • #tabs
  • #personal-apps
  • #dashboards
  • #teams-js
  • #devops

Every team I’ve worked with eventually accumulates a “links” channel — a pinned message full of URLs to Grafana, the runbook wiki, the on-call schedule, the deploy dashboard. It’s where context goes to die. Nobody clicks fifteen tabs at 2am. Teams tabs and personal apps fix this by embedding those surfaces inside Teams, scoped to the right channel or to the individual engineer, so the dashboard is one click away in the place where the conversation already happens.

Three tab scopes, three jobs

Teams tabs come in scopes, and the scope is the design decision:

  • Channel/group tabs — pinned to a specific team or channel. Use these for shared, team-wide context: the service dashboard for the team that owns the service, the incident war-room board, the deploy queue.
  • Personal tabs — part of a personal app, scoped to one user. Use these for “my stuff”: my on-call shifts, my open PRs, my assigned incidents.
  • Configurable vs static — channel tabs are configurable (the user picks what to show when they add it); personal tabs are static (defined in the manifest, always the same).

A static personal tab is the fastest win

You can ship a personal “my on-call” view with almost no Teams-specific code — it’s just a web page you host, declared in the manifest under staticTabs:

{
  "staticTabs": [
    {
      "entityId": "myOncall",
      "name": "My On-Call",
      "contentUrl": "https://devops.example.com/teams/oncall",
      "scopes": ["personal"]
    }
  ]
}

The one thing your page must do is initialize the Teams JS SDK and tell Teams it’s ready, or the tab spins forever:

import { app } from "@microsoft/teams-js";

await app.initialize();
const context = await app.getContext();
// context.user.id, context.user.userPrincipalName, context.team?.internalId
app.notifySuccess();

That getContext() call is gold: it gives you the signed-in user, their tenant, and (in a channel tab) the team and channel. Use context.user.userPrincipalName to scope the dashboard to that engineer’s on-call shifts without making them log in again.

Configurable channel tabs: ask once, render forever

A configurable tab shows a setup page when someone adds it. You collect a setting — say, which service’s dashboard to show — and hand Teams the final content URL:

import { pages } from "@microsoft/teams-js";

pages.config.registerOnSaveHandler((saveEvent) => {
  pages.config.setConfig({
    entityId: `dash-${selectedService}`,
    contentUrl: `https://devops.example.com/teams/service/${selectedService}`,
    suggestedDisplayName: `${selectedService} dashboard`,
  });
  saveEvent.notifySuccess();
});

pages.config.setValidityState(true);

Now the team that owns checkout pins a checkout dashboard tab, and the team that owns payments pins theirs — same app, different config.

Theming and iframes: the two things that bite

Tabs render in an iframe, so two practical gotchas:

  • Respect the Teams theme. app.getContext() returns context.app.theme (default, dark, or contrast). Subscribe to changes with app.registerOnThemeChangeHandler and swap your CSS, or your white dashboard will blind everyone in dark mode.
  • Set frame-ancestors, not X-Frame-Options. Teams must be allowed to iframe your page. Send Content-Security-Policy: frame-ancestors teams.microsoft.com *.teams.microsoft.com *.skype.com;. A blanket X-Frame-Options: DENY from your reverse proxy is the single most common reason a tab shows a blank box.

A dashboard is more useful when its rows link back into Teams — “this alert came from #checkout-alerts, jump there.” You can construct deep links to channels, chats, or even other tabs, or use pages.navigateToApp() from the SDK to move the user to a specific entity in your own app. I go deeper on the deep-link URL formats in the dedicated deep-links guide; for tabs, the SDK navigation calls are cleaner than hand-built URLs because they survive Teams routing changes.

Personal apps tie it together

A personal app is the container: it can hold static tabs and a bot, so an engineer gets a “DevOps” icon in the left rail that opens their on-call view, their incidents, and a chat bot in one place. Declare the bot in bots with "scopes": ["personal"] and the tabs in staticTabs, and Teams stitches them into a single app the user installs once.

Don’t rebuild what you have

The mistake here is treating tabs as a reason to rebuild dashboards. You almost never should. If you already have a Grafana or internal dashboard, the fastest path is a thin wrapper page that initializes the Teams SDK, reads the user/team context, and embeds your existing view with the right filters pre-applied. The value is context and placement, not net-new UI.

Where this fits

Tabs and personal apps are how you stop the tab-out tax. Start with one static personal tab — “my on-call” — because it needs no per-channel config and proves the SDK plumbing. Then add a configurable channel tab so each team pins its own dashboard. For adaptive card and Teams JS snippets to scaffold these, see the prompt library, and there’s more Teams material in the Microsoft Teams category.

SDK method names and manifest keys evolve; check them against the current Teams JavaScript library docs before building.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.