tabs
Use this skill when the user is building a tab inside a Microsoft Teams app on Teams SDK 2.1 — serving a static page with `app.tab()`, calling the agent from the page with `@microsoft/teams.client` an
SKILL.mdteams-dev/skills/tabs/SKILL.md
GitHubTabs — web pages inside Teams
Two pieces, two packages — do not mix them up:
- Server (
@microsoft/teams.apps) —app.tab(name, dir)serves a built single-page app;app.function(name, handler)exposes REST functions the page can call with an Entra token. - Browser —
@microsoft/teams-js(TeamsJS) is the Teams host API: context, theme, dialogs, tab configuration.@microsoft/teams.clientis a separate class built on TeamsJS and MSAL: it starts both, acquires tokens, calls Graph, and calls the server's functions.
The CLI template tab (teams project new typescript my-tab -t tab) wires all of this with Vite and React.
1. Serve the page
import path from 'node:path';
app.tab('settings', path.resolve('dist/client')); // served at /tabs/settings
app.tab mounts the directory at https://<bot-host>/tabs/<name>; scopes default to personal. Build the SPA into dist/client and keep it out of the server bundle.
2. Manifest
Tabs are declared in the manifest (teams app manifest download|upload|update, see cli-recipes):
{
"staticTabs": [{
"entityId": "settings",
"name": "Settings",
"contentUrl": "https://<bot-host>/tabs/settings",
"scopes": ["personal"]
}],
"configurableTabs": [{
"configurationUrl": "https://<bot-host>/tabs/configure",
"scopes": ["team", "groupChat"]
}],
"validDomains": ["<bot-host>"]
}
Without the host in validDomains the Teams client refuses to render the page. A configurable tab needs a configuration page that calls pages.config.setConfig and setValidityState(true) through TeamsJS.
3. The client library
import * as teamsJs from '@microsoft/teams-js';
import { App as ClientApp } from '@microsoft/teams.client';
import * as endpoints from '@microsoft/teams.graph-endpoints';
const clientId = String(import.meta.env.VITE_CLIENT_ID); // the Entra app that backs the tab
async function boot() {
const app = new ClientApp(clientId, {
msalOptions: { prewarmScopes: ['User.Read', 'Team.ReadBasic.All'] }, // explicit scopes; `.default` does not work on Teams desktop
});
await app.start(); // initialises TeamsJS and MSAL
const context = await teamsJs.app.getContext(); // theme, user, channel, ...
const me = await app.graph.call(endpoints.me.get); // typed Graph client with the user's token
const reply = await app.exec<{ conversationId: string }>('post-to-chat', { message: `Hello from ${me.displayName}` });
return { context, reply };
}
void boot;
app.start()fails outside Teams (the TeamsJS initialisation rejects or times out). Catch it and show an "open in Teams" message in a browser.app.graph.call(endpoints.…)uses the same endpoint builders as the server (graph-integration).hasConsentForScopes(scopes)tests,ensureConsentForScopes(scopes)tests and prompts.prewarmScopesasks for consent at start;falsedisables it. The scopes must belong to one resource.
4. Functions — call the agent from the page
app.function<{ message: string }>('post-to-chat', async ({ data, send, getCurrentConversationId, log }) => {
log.info('post-to-chat called');
await send(data.message); // into the conversation the tab runs in
return { conversationId: await getCurrentConversationId() };
});
The function is served at POST /api/functions/post-to-chat. The SDK validates the Entra bearer token before the handler runs (401 otherwise). Context values from the caller (chatId, channelId, teamId, pageId, meetingId, …) are not validated — only userId, tenantId and authToken come from the token. Check access yourself before acting on them, and validate data. A returned number is an HTTP status code; a string, object or array is the body.
5. Nested App Authentication with plain MSAL
When you do not use @microsoft/teams.client, create an MSAL client that talks to the Teams host instead of opening a popup:
import { createNestablePublicClientApplication } from '@azure/msal-browser';
async function getGraphToken(clientId: string) {
const msal = await createNestablePublicClientApplication({
auth: { clientId, authority: 'https://login.microsoftonline.com/common' },
});
const accounts = msal.getAllAccounts();
const result = await msal.acquireTokenSilent({ scopes: ['User.Read'], account: accounts[0] });
return result.accessToken;
}
void getGraphToken;
If acquireTokenSilent throws interaction_required, fall back to acquireTokenPopup or a sign-in button. The Entra app registration needs the nested-app redirect URI and its SPA platform set up — see authentication.
6. Dialogs from a page
TeamsJS opens and closes URL dialogs:
import * as microsoftTeams from '@microsoft/teams-js';
function pickValue() {
microsoftTeams.dialog.url.open(
{ title: 'Pick a value', url: 'https://<bot-host>/tabs/picker', size: { width: 400, height: 300 } },
(result) => { console.log('dialog closed', result); },
);
}
void pickValue;
The picker page calls microsoftTeams.dialog.url.submit(value). Dialogs opened by the bot are in dialogs.
Common pitfalls
- The tab is blank in Teams —
validDomainsmisses the host, or the build output path inapp.tabis wrong (404 on/tabs/<name>). teams.clientor TeamsJS rejects in a browser — expected outside Teams.- TeamsJS functions imported from
@microsoft/teams.client— the client package has its ownAppclass; context, pages and dialogs come from@microsoft/teams-js. - A function answers 401 — the token audience does not match the app, or the call came without a bearer token.
- A configurable tab does not save —
setValidityState(true)was never called.