A finding can carry an app — a prediction game for the morning’s movers, a calculator for a line move, a daily check-in tracker. Readers see a poster with a Play button; the app loads only when they tap it, inside a locked-down sandbox. A finding without an app works exactly as before.
Add app_url to a finding in push_findings (or POST /api/v1/findings), with an optional app_height from 280 to 720 (default 420).
{
"tab_id": "…",
"idempotency_key": "money-movers-2026-10-03",
"findings": [{
"title": "Money Movers — Oct 3",
"summary": "Five pre-market movers and why.",
"app_url": "https://apps.example.com/predict",
"app_height": 480
}]
}frame-ancestors https://tabtab.com, and no X-Frame-Options.tabtab_tab_id, tabtab_finding_id, tabtab_theme and tabtab_access to the query string.Built your app in an assistant like Muse, whose hosting cannot be framed by other sites? Send the file itself as app_html: one self-contained HTML document with its CSS and JavaScript inline, up to 300 KB. TabTab serves it from tabtabapps.com in the same sandbox, and reviews hosted apps individually.
{
"findings": [{
"title": "Your check-in for Oct 3",
"summary": "Movement, nutrition, sleep and stress.",
"app_html": "<!doctype html><html>…your whole app…</html>",
"app_height": 560
}]
}
// The result includes "apps": [{ "app_id": "…", "app_url": "https://tabtabapps.com/…" }]
// Next time send "app_id": "…" instead of the whole file.Send one of app_url, app_html or app_id per finding. Pushing the same file again reuses the stored copy. Apps hosted by TabTab share one saved state per reader per Tab, so a new version of your app keeps everyone’s progress.
Every app runs in a frame with sandbox="allow-scripts" and nothing else. If your page does not load within five seconds, the finding falls back to its normal link and image.
Messages to and from TabTab carry tabtab: "tabtab-mini-app/1". Anything else is ignored. Saving goes to the reader’s TabTab account when they are signed in, so it follows them across devices, and to their device when they are not.
const PROTOCOL = "tabtab-mini-app/1";
const send = (msg) => parent.postMessage({ tabtab: PROTOCOL, ...msg }, "*");
let saved = null;
addEventListener("message", (event) => {
if (event.source !== parent) return;
const msg = event.data;
if (!msg || msg.tabtab !== PROTOCOL) return;
if (msg.type === "init") {
// msg.state is what you last saved for this reader (or null).
// msg.access is "open", "member", "full" or "preview".
saved = msg.state;
render(saved);
}
if (msg.type === "saved" && !msg.ok) console.warn("save failed", msg.reason);
if (msg.type === "shared") console.log("share card", msg.url);
});
// Say hello once your page has loaded. TabTab answers with "init".
send({ type: "ready" });
// Save whenever your state changes (any JSON, 16 KB max).
function save(state) { send({ type: "save", state }); }
// Fit the frame to your content (280–720 px).
send({ type: "resize", height: document.body.scrollHeight });
// Ask TabTab for a branded share card: a headline and up to two stats.
send({ type: "share", headline: "Seven days in a row",
stats: [{ label: "Days", value: "7" }, { label: "Today", value: "4/4" }] });
// Tell TabTab a session was completed (counted for the Tab's metrics).
send({ type: "complete" });
// On a creator-priced Tab, ask TabTab to show the subscribe prompt.
send({ type: "subscribe" });Building the app with Claude, ChatGPT or Muse? Paste these after your app idea. The first makes any app run inside TabTab; the second keeps each player’s scores, streaks or progress; the third publishes it.
Make this web app ready to run inside TabTab as a Mini App.
Constraints (TabTab runs it in a sandboxed iframe, scripts only):
- One self-contained HTML file: inline CSS and JS, under 300 KB. Web fonts and images over https are fine.
- No localStorage, sessionStorage, IndexedDB or cookies: they throw in the sandbox. Wrap any existing use in try/catch.
- No alert(), confirm() or prompt(): they are blocked. Use inline confirmation (e.g. tap twice) instead.
- No popups, new windows or form submits.
Add one self-contained "TabTab bridge" block at the top of the script:
- Messages both ways carry tabtab: "tabtab-mini-app/1". Send with parent.postMessage(msg, "*"); accept only events where event.source === parent and data.tabtab matches.
- On load, send { type: "ready" }. TabTab answers { type: "init", state, access, persist, theme }.
- Fit the frame: send { type: "resize", height } (280-720) after rendering.
- When a session finishes, send { type: "complete" }.
- If the app runs fine outside TabTab too, fall back to localStorage when there is no parent frame.
Keep the rest of the app as it is.Add personal progress to this TabTab Mini App, saved through the TabTab bridge.
- Keep one small JSON record per player, e.g. { best, lastScore, gamesPlayed } (or streaks, levels, check-ins). Keep it under 16 KB; prune old history.
- On { type: "init" }, read data.state as the saved record (null the first time) and show it (e.g. "Best" next to the score).
- Whenever the record changes (game over, level cleared, check-in), send { type: "save", state: record }. TabTab stores it in the player's account when they are signed in, and on their device when not, and returns it in the next "init". Listen for { type: "saved", ok } and show a short note if ok is false.
- After a run, offer a "Share" button that sends { type: "share", headline, stats: [{ label, value }, ...up to 2] }; TabTab turns it into a branded share card.
- Records are per player only: do not build a leaderboard or compare players. TabTab does not store shared rankings.
- Outside TabTab, fall back to localStorage inside try/catch.Records are personal: TabTab keeps each player’s own, and does not store shared rankings. An app on its own server can keep its own, using the reader token below to tell members apart.
Push this as a playable finding to my TabTab Tab "<Tab name>" with push_findings:
- title: <today's headline>, summary: one or two plain sentences about what the reader can do.
- app_html: the full HTML file of the app.
- app_height: <280-720>.
From the result, keep apps[0].app_id. Next time, send app_id instead of app_html unless the app changed.access from init and sends subscribe when a preview ends.#tabtab_token=…), so your own server can keep their progress. It is signed, scoped to one Tab and your origin, expires in 24 hours, and carries an anonymous reader id — never a name, email or payment detail. Only subscribers ever receive one; that, not your app, is what keeps paid content for subscribers.// Your server, if you have one. Verify offline against TabTab's key set:
// GET https://tabtab.com/api/v1/mini-apps/keys (EdDSA, Ed25519)
// or ask TabTab:
const res = await fetch("https://tabtab.com/api/v1/mini-apps/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, audience: "https://apps.example.com" }),
});
const { valid, claims } = await res.json();
// claims: { iss, aud, sub, tab_id, access: "member", iat, exp }