A sideloadable Teams app package that renders a Rocket.Chat workspace as a personal (static) tab in the Teams desktop client. Pure iframe wrapper: no bot, no message sync, no Graph, no SSO bridge. Teams is the container; Rocket.Chat runs as-is.
rocketchat-teams-app.zip ← ready to sideload (points at open.rocket.chat)
package/
manifest.json ← app manifest, schema v1.27
color.png ← 192×192 color icon
outline.png ← 32×32 white/transparent outline icon
wrapper/
wrapper.html ← OPTIONAL runtime-URL-entry page (see below)
App ID (GUID): 80898bb0-c18f-4662-8624-c21bdd392035 — regenerate if you fork this package for a second app in the same tenant.
Note: packageName was removed from the manifest schema in v1.17+ and is rejected by validation ("additional properties" error). The id GUID is the app's unique identifier; do not re-add packageName unless you downgrade to manifestVersion ≤1.16.
The zip ships pointed at no specific workspace, and users can input the workspace they prefer. To point it at your own instance, edit package/manifest.json:
- Replace both
contentUrlandwebsiteUrlinstaticTabs[0]withhttps://chat.yourcompany.com/. - Replace the
validDomainsentries with your domain, e.g."chat.yourcompany.com"(a wildcard like"*.yourcompany.com"is allowed). This is the step people miss — if the tab's domain is not invalidDomains, Teams refuses to load it and you get a blank tab. Note thatvalidDomainsmust contain hostnames only, nohttps://scheme and no paths. - Re-zip
manifest.json,color.png,outline.pngat the root of the zip (no containing folder) and re-sideload.
cd package && zip -r ../rocketchat-teams-app.zip manifest.json color.png outline.pngRocket.Chat ships with X-Frame-Options: sameorigin by default, which blocks framing by Teams.
Admin → Settings → General → "Restrict access inside any Iframe" — turn this OFF. This stops Rocket.Chat from sending X-Frame-Options. (There is a companion "Options to X-Frame-Options" text setting, but it is of limited use: the only per-domain value X-Frame-Options ever supported, ALLOW-FROM, is dead in all modern browsers/WebView2, so you cannot allow-list Teams via this header.)
What is not configurable in the admin UI: a CSP frame-ancestors allow-list. Rocket.Chat does not expose frame-ancestors as an admin setting (a long-standing open request, see RocketChat/Rocket.Chat#18485). So the correct pattern is: disable the header in the admin UI, then re-add a scoped frame-ancestors policy at the reverse proxy so you're not open to framing by any site.
server {
...
location / {
proxy_pass http://rocketchat_backend;
# Belt-and-braces: strip any X-Frame-Options coming from the app
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
# Allow framing ONLY by Teams (classic + new Teams hosts) and ourselves
add_header Content-Security-Policy "frame-ancestors 'self' https://teams.microsoft.com https://*.teams.microsoft.com https://teams.cloud.microsoft https://*.cloud.microsoft" always;
}
}Notes:
frame-ancestorssupersedesX-Frame-Optionsin every modern engine; where both are present, browsers honorframe-ancestors. Stripping XFO at the proxy avoids conflicts.- The new Teams client is served from
*.cloud.microsofthosts; the classic domain isteams.microsoft.com. Including both covers desktop (WebView2 renders the same origins), web, and the transition period. - If you use the optional wrapper page (below), its hosting domain must also be in the
frame-ancestorslist, because the ancestor chain becomesteams.microsoft.com → wrapper host → rocket.chat. - If Rocket.Chat sets its own CSP for other reasons, merge rather than
proxy_hide_headerit.
- Tenant prerequisite: an admin must allow custom app uploads (Teams admin center → Teams apps → Setup policies → Upload custom apps ON, or org-wide app settings → allow custom apps).
- In Teams desktop: Apps → Manage your apps → Upload an app → Upload a custom app, pick
rocketchat-teams-app.zip. - The app appears in the left rail (personal scope). Pin it via right-click if desired.
- To update after editing the manifest: remove the app, bump
"version"in the manifest, re-upload.
Optional pre-flight check: upload the zip to the Developer Portal for Teams (dev.teams.microsoft.com → Apps → Import app) — it runs full manifest validation and pinpoints schema errors.
The shipped package hardcodes the workspace URL — simplest, zero hosting. If you want end users to type their own workspace URL on first launch, use wrapper/wrapper.html:
- Host it on any HTTPS static host you control (e.g.
https://apps.yourcompany.com/rc-tab/wrapper.html). - Point
contentUrl/websiteUrlat the wrapper URL and add the wrapper's domain tovalidDomains. - The wrapper asks for the workspace URL once, stores it in
localStorage(per device), and iframes it. A small "change workspace" button lets users switch.
Caveats specific to this mode:
- The RC server's
frame-ancestorsmust include the wrapper's domain (see above). - Storage partitioning means the saved URL lives inside the Teams webview only — users re-enter it once per device/client.
- The wrapper calls
app.initialize()/app.notifySuccess()via TeamsJS, so it stays compatible even if a loading indicator is enabled later.
Verified against current Microsoft docs:
- Latest app manifest schema is v1.27 (OfficeDev/microsoft-teams-app-schema releases; MS Learn schema reference). This package targets
manifestVersion: "1.27". - A bare external URL does work in a static tab without the Teams JS SDK, provided
showLoadingIndicatoris not set to true. Per MS Learn "Build a content page for tab": callingapp.initialize()+app.notifySuccess()is required only when the loading indicator is enabled — otherwise Teams shows an error/blank after a 30s timeout. This manifest deliberately omitsshowLoadingIndicator, so pointing straight at the RC root is safe. Decision point resolved: no thin wrapper is required for the hardcoded variant. - Rocket.Chat's iframe restriction lives at Admin → Settings → General → "Restrict access inside any Iframe" and controls
X-Frame-Optionsonly;frame-ancestorsis not admin-configurable and must be done at the proxy.
Inferred / not independently verifiable without your environment:
- The exact
frame-ancestorshost list.teams.microsoft.com+*.cloud.microsoftcovers current classic/new Teams; if you see a blocked frame, check the webview console for the reporting ancestor origin and add it. - Behavior of your specific auth providers (SAML/OAuth) inside the webview — test with your IdP.
- Auth: users log into Rocket.Chat manually inside the tab; there is no Teams SSO. Rocket.Chat's own session token is kept in
localStorage(not cookies), which is why plain username/password login generally survives framing and third-party-cookie blocking. However: (a) webview storage partitioning means the Teams-embedded session is separate from the user's browser session — they log in again inside Teams; (b) OAuth/SAML logins can break inside the iframe if the IdP itself refuses framing or relies on third-party cookies — Google, Microsoft, and most IdPs block being framed, so redirect-based SSO flows may dead-end. If your instance uses SAML/OAuth only, test this first; popup-based flows fare better than redirect flows. If anything auth-related sets cookies at your proxy, mark themSameSite=None; Secureor they'll be dropped in the third-party context. - No deep-linking: Teams notifications,
msteams://links, and channel deep links don't map to RC rooms; RC's own desktop notifications may not fire from inside the webview. - Calls/media: Jitsi/Pexip/RC calls need camera/mic/screen-capture permissions inside the webview; behavior varies by Teams client build. Treat A/V inside the tab as best-effort.
- Downloads/uploads are handled by the Teams webview and can behave differently than a browser.
- One shared iframe context: no per-channel tabs, no presence sync, no cross-product search — Teams knows nothing about the content.