An agent skill that makes a web page run inside a tweet
Post a link and X loads your page in an iframe inside the tweet, so people use the demo or scroll the landing page without leaving the timeline. This skill has the exact meta tags and the frame-ancestors header, plus four things that broke for me after that. An edge worker served Twitterbot a different page than browsers got. The page would not scroll inside the frame. Sign-up links break in a third-party iframe. X caches the card per URL. I hit all four putting the AdaptlyPost landing page in a tweet.
---
name: x-player-card
description: >
Makes a web page run inside a tweet. X loads the page in an iframe when a link with a `twitter:card=player` card is posted, so a demo, a game, an emulator or a whole landing page becomes usable without leaving the timeline. Covers the exact meta tags, the framing headers, the crawler path, the embed mode the page needs once it is framed (scrolling, links, banners) and X's card cache.
TRIGGER when: the user wants a page, app, demo, game or landing page to load, run or be interactive "inside a tweet" or "inside a post" on X/Twitter, mentions an X or Twitter player card, `twitter:card` `player`, `twitter:player`, or asks how someone embedded a live site in a tweet.
TRIGGER when: a player card shows as a plain image card, the framed page is blank, will not scroll, or its links break inside the tweet.
DO NOT TRIGGER when: the user wants a normal link preview image (`summary_large_image`), wants to embed a tweet on their own site, or is posting native video.
---
# X player card: run a page inside a tweet
Post a link whose page declares a player card and X renders that page in an iframe inside the tweet. It was built for video players, but the iframe takes any HTTPS page.
Everything here was shipped and checked on a production site (Next.js on Cloudflare Workers). Each step lists the failure it prevents. The steps are stack-agnostic; the Next.js notes are marked.
## 1. Use two URLs in your head, even if they are one
- The **share URL** is what goes in the tweet. X's crawler (`Twitterbot`) fetches it once and reads the card tags.
- The **player URL** is what X puts in the iframe (`twitter:player`).
They can be the same URL. Use a dedicated route either way (`/in-tweet`, `/embed`), never the homepage, because this route gets headers and behavior the rest of the site must not have.
## 2. Card tags on the share URL
```html
<meta name="twitter:card" content="player">
<meta name="twitter:site" content="@yourhandle">
<meta name="twitter:title" content="Short title">
<meta name="twitter:description" content="One sentence.">
<meta name="twitter:image" content="https://example.com/card.png">
<meta name="twitter:player" content="https://example.com/in-tweet">
<meta name="twitter:player:width" content="640">
<meta name="twitter:player:height" content="480">
```
- Every URL is absolute HTTPS. `twitter:image` is the still shown before the frame loads.
- Emit exactly one `twitter:card`. With a second `summary_large_image` tag on the page, which card you get is X's choice, not yours.
- Leave out `twitter:player:stream`. It means a raw video file, not a page.
- If the page duplicates another one, add `<meta name="robots" content="noindex, follow">` and point the canonical at the original.
**Next.js:** the typed `twitter: { card: 'player', players }` metadata always prints `twitter:player:stream`, so write the tags through `other`. Also set `twitter: null` **and** `openGraph: null` on that page. With `openGraph` present, Next fills in a second `twitter:card=summary_large_image` on its own.
## 3. Let X frame the player URL, and only that URL
Send this on the player route:
```
Content-Security-Policy: frame-ancestors 'self' https://x.com https://*.x.com https://twitter.com https://*.twitter.com
```
and send no `X-Frame-Options` there. Keep `frame-ancestors 'self'` everywhere else.
Look for a second place that sets framing headers. On the site this came from, the framework config set one `X-Frame-Options` and a middleware set `DENY` on every response. The config value won on every route that had one, so the middleware's `DENY` only surfaced on the new route, the one route that deliberately set none. Check the response, not the config.
## 4. Fetch the share URL as the crawler
```bash
curl -s -A "Twitterbot/1.0" https://example.com/in-tweet | grep -o '<meta name="twitter:[^>]*>'
curl -s -o /dev/null -D - -A "Twitterbot/1.0" https://example.com/in-tweet | grep -i 'content-security\|x-frame'
```
Do this against production, with that user agent. Sites often answer social crawlers from a different code path: an edge worker that returns a small prebuilt OG document, a prerender service, bot protection. The site this came from had such a worker, and it fell back to the homepage's card for any path it did not know. Browsers got the player tags, Twitterbot got `summary_large_image`, and the tweet showed a plain image. A local dev server never runs that layer, so a green local test proves nothing here. Let the player route pass through to the real page.
Plain `curl` can be a third code path (CLI user agents may get Markdown or a block page), so always pass `-A`.
## 5. Give the page an embed mode
Detect the frame with `window.self !== window.top` and change four things.
**Scrolling.** In X's frame the page could not scroll at all. A test iframe with `scrolling="no"` reproduces it exactly: the wheel does nothing. Either design the content to fit the declared size, or lock the document and scroll an inner element, which still takes wheel and touch:
```css
html.framed, html.framed body { height: 100%; overflow: hidden; }
html.framed main { height: 100dvh; overflow-y: auto; overscroll-behavior: contain; }
```
Code that reads `window.scrollY` (a navbar that changes after 200px) sees 0 in this mode.
**Links.** Open every link in a new tab. Sign-up and login do not work in a third-party iframe: OAuth providers refuse to be framed and the session cookie is third-party. A capture-phase listener covers plain anchors and framework links alike:
```js
document.addEventListener('click', (event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = event.target.closest?.('a[href]');
if (!anchor || anchor.getAttribute('href').startsWith('#')) return;
event.preventDefault();
window.open(anchor.href, '_blank', 'noopener');
}, true);
```
`preventDefault` without `stopPropagation` keeps existing click tracking alive, and Next.js `<Link>` skips its own navigation once the event is default-prevented.
**Overlays.** Hide the cookie banner, chat widget and any floating button. In a 640×480 frame they cover the page.
**Size.** X scales the frame to the timeline width, so expect the page's mobile layout.
## 6. X caches the card per URL
X keeps the card it built for a URL. If you tweeted the link while anything in steps 2 to 4 was wrong, that URL keeps showing the wrong card. Move the route to a new path and post that. The page inside the frame is not cached: it loads live, so later fixes to the page itself (scrolling, links) show up on reload without a new tweet.
## 7. Verify, in this order
1. Step 4's two `curl` commands against production.
2. A local page holding `<iframe scrolling="no" src="/in-tweet" width="560" height="420">`. Scroll it, click a link, look for overlays. Serve the test page from the same origin, or the `frame-ancestors` rule will refuse it, which is the rule working.
3. Post the link from a spare account. `https://api.fxtwitter.com/<handle>/status/<id>` should report `"twitter_card": "player"` with your player URL under `media.external`.
4. Open the tweet on x.com, click the card, scroll, click a link.
Checked on x.com in a desktop browser. The X mobile apps were not tested: check them yourself before promising the behavior there.