TutuHai

Doc ouverte des mini-apps TutuHai

Intégration du SDK · API de capacités · Données cloud · Autorisation · Publication

Les mini-apps TutuHai sont des applications légères qui s'exécutent dans TutuHai. Les développeurs téléversent uniquement un bundle de code frontend, hébergé par la plateforme ; les capacités dialoguent avec TutuHai via le SDK window.tt, sans backend qui vous soit propre (les données métier passent par les données cloud de TutuHai). Intégration du SDK · API de capacités · données cloud · autorisation · publication — le tout sur une seule page, avec des exemples prêts à copier-coller.

Créer une mini-appli dans la console 7 catégories · 26 sujets · Cliquez sur un titre pour développer
Sommaire · 26 sujets

Prise en main

Capacités de données

Conversations et multijoueur

Fichiers et cloud drive

Interaction inter-apps

Interface · fenêtre · hôte

Référence

Prise en main

Introduction

Les mini-apps TutuHai sont des applications légères qui s'exécutent dans TutuHai. Les développeurs téléversent uniquement un bundle de code frontend, hébergé par la plateforme ; les capacités dialoguent avec TutuHai via le SDK window.tt, sans backend qui vous soit propre (les données métier passent par les données cloud de TutuHai).

Isolation et sécurité : les mini-apps s'exécutent dans une iframe en bac à sable sur une origine isolée ; la session de connexion de l'hôte n'entre jamais dans la mini-app. Chaque appel se voit délivrer par l'hôte un jeton à courte durée de vie et à portée restreinte, et le backend revalide par capacité (scope).

Démarrage rapide
  1. Dans la console des mini-apps (/applets), cliquez sur « Nouveau » pour créer une mini-app (nom + slug unique).
  2. Écrivez un HTML mono-fichier (incluez le SDK, appelez les capacités via window.tt.*).
  3. Créez une version → renseignez les capacités demandées → téléversez le bundle de code (HTML mono-fichier).
  4. Soumettez à la revue → l'administrateur approuve → publiez en production en un clic.
  5. Les utilisateurs la trouvent/ouvrent via la recherche « Découvrir », ou vous partagez une carte / copiez un lien pour un accès direct.
Spécification d'empaquetage

Les mini-apps prennent en charge deux formes de téléversement : ① un bundle HTML mono-fichier (autonome, point d'entrée = racine, le plus simple) ; ② une sortie de build d'un vrai framework en zip (le dist/ de npm run build, avec index.html + assets répartis sur plusieurs fichiers — voir « Sortie de build de framework »). La plateforme effectue une vérification de conformité et une optimisation avant le téléversement.

Structure du bundle (sortie de build)

your-applet/            # dev directory (any structure: src, components, assets…)
├─ src/ …               # your source (React / Vue / Svelte / vanilla)
└─ dist/index.html      # ★build output: single-file HTML (← upload this)
                        #   inlined CSS/JS, or referencing whitelisted CDNs; self-contained, no server

Le bundle de code doit satisfaire (vérifié automatiquement au téléversement) :

  • Point d'entrée : un unique fichier HTML avec <!doctype html> et un <html> racine.
  • Adaptation mobile : doit inclure <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">.
  • SDK : incluez <script src="/applet-sdk.js"> (la plateforme le réécrit vers l'adresse absolue de l'hôte).
  • Autonome : CSS/JS en ligne ; si vous avez besoin de scripts externes, seuls le SDK de la plateforme + les CDN de frameworks connus (unpkg / jsdelivr / cdnjs / esm.sh) sont autorisés — les scripts distants arbitraires sont interdits (sécurité). Les images et autres médias passent par tt.uploadImage ou un CDN.
  • Taille : HTML mono-fichier ≤ 1 Mo ; zip multi-fichiers ≤ 8 Mo au total, ≤ 1 Mo par fichier, ≤ 100 fichiers ; images téléversées ≤ 4 Mo.
  • Données : sans backend qui vous soit propre — les données métier passent par les données cloud tt.cloud / tt.*storage.

📱🖥 Universel mobile / bureau (une seule base de code, les deux surfaces)

Le même bundle s'exécute dans une iframe isolée à l'intérieur de TutuHai ; l'hôte le porte à la fois sur mobile (plein écran) et sur bureau (panneau / peut passer en plein écran). Écrivez une base de code unique et universelle avec une mise en page responsive : ① viewport-fit=cover + zones sûres env(safe-area-inset-*) ; ② les superpositions en feuilles de bas d'écran (mobile) ↔ centrées (bureau, @media(min-width:480px)) ; ③ cibles tactiles ≥ 44 px ; ④ suivez le thème sombre/clair de l'hôte (tt.onThemeChange / [data-theme]) ; ⑤ DOM pur, aucune largeur codée en dur. L'expérience reste ainsi cohérente sur téléphone comme sur ordinateur.

Sortie de build de framework (dist.zip)

Outre le HTML mono-fichier, vous pouvez aussi téléverser la sortie de build d'un vrai framework — utilisez React / Vue / Svelte / Angular / Solid / Astro / Next (export statique) / vanilla… le npm run build de n'importe quelle chaîne d'outils, zippez le dist/ (avec index.html + assets/*.js/css + polices/images) et téléversez-le pour l'hébergement.

Agnostique au framework : la plateforme ne reconnaît qu'un seul « contrat de bundle statique universel » — point d'entrée index.html + références d'assets relatives + SDK. Tout framework capable de produire un dist statique respectant ce contrat est pris en charge ; les scaffolds ci-dessous ne sont que des raccourcis choisis, pas la limite de la prise en charge.

Structure du zip (sortie de build, racine du zip = racine du bundle)

myapp.zip
├─ index.html          # ★entry (zip root)
├─ manifest.json       # declares slug/name/version/scopes (see below)
└─ assets/
   ├─ index-*.js       # built JS (relative references)
   ├─ index-*.css
   └─ font/img…

Adaptation en trois étapes (fonctionne pour tout framework) :

  1. Définir une base relative (recommandé, le plus sûr) — faites en sorte que la sortie référence les assets par chemin relatif (./assets/x.js plutôt que le /assets/x.js absolu à la racine), pour que l'hébergement sous /<slug>/ soit infaillible. (La base par défaut fonctionne aussi : la plateforme réécrit automatiquement les références statiques absolues à la racine dans le HTML/CSS et utilise un repli par Referer pour les assets absolus à la racine générés à l'exécution — comme les préchargements de CSS issus du code splitting ; mais sous des cas limites de Referrer-Policy stricte / préchargement hors ligne, le Referer peut manquer et le repli échoue, donc une base relative est la plus sûre.)
  2. Ajouter un manifest — placez manifest.json dans le répertoire statique (par ex. le static/ de Vite/SvelteKit, le public/ de la plupart des frameworks) pour qu'il atterrisse à la racine du dist après le build ; ou omettez le manifest et ajoutez <meta name="tt:slug" content="…"> (plus tt:name / tt:version / tt:scopes) dans index.html comme repli.
  3. Inclure le SDK — deux façons : ① installation npm (recommandé, idéal pour de vrais scaffolds) : après npm i @tutuhai/applet-sdk, import { tt } from '@tutuhai/applet-sdk' — regroupé dans la sortie au moment du build, avec les types TypeScript, sans modifier index.html ; ② ou écrivez <script src="/applet-sdk.js"> dans index.html (la plateforme le réécrit vers l'adresse absolue de l'hôte) et utilisez le window.tt global.

📦 SDK npm (testé avec les scaffolds officiels React / Vue / Svelte)

Créez un projet avec npm create vite@latest -- --template react-ts | vue-ts | svelte-ts, installez le même @tutuhai/applet-sdk et faites un import — vraie source multi-fichiers, npm run build produit plusieurs chunks + un point d'entrée index.html ; zippez et téléversez.

# 1) Create a project with an official scaffold
npm create vite@latest my-applet -- --template react-ts   # or vue-ts / svelte-ts

# 2) Install the SDK (same package for all three frameworks)
npm i @tutuhai/applet-sdk

# 3) import and use in your source (typed)
#    src/App.tsx / App.vue / App.svelte
import { tt } from '@tutuhai/applet-sdk';
tt.ready((ctx) => {
  tt.getProfile().then((me) => console.log('hi', me?.nickname));
});
await tt.cloud.add('notes', { text: 'hello' });   // cloud data, no backend of your own

# 4) vite.config: relative base; public/manifest.json declares slug/name/scopes
#    export default { base: './', plugins: [react()] }

# 5) official build → zip dist → upload
npm run build && cd dist && zip -r ../my-applet.zip .

Des exemples exécutables sont dans le dépôt : applets/frameworks/{react,vue,svelte} (trois vrais projets à scaffold officiel, tous important le même paquet SDK). Source du paquet SDK : applet-sdk/.

Champs de manifest.json

{
  "slug": "myapp",              // ★required, globally unique, determines hosting path /myapp/
  "name": "My Mini-App",        // ★required, display name
  "version": "1.0.0",           // ★required, must increment on every upload
  "scopes": ["user.profile"],   // requested capabilities (see "Authorization model")
  "description": "One-line summary",  // optional, shown on discover/detail
  "icon": "icon.png",           // optional, relative path in the bundle (or change it in the console after upload)
  "display": "fullscreen",      // optional, default open mode: window (floating, default) | fullscreen
  "fileHandlers": [             // optional, declares "Open with" — which file kinds you can handle from a chat
    { "kinds": ["image"], "role": "editor", "label": "TutuEdit · Retouch" }
  ]
}
  • display : déclare le mode d'ouverture sur bureau — pour les apps canevas/tableau blanc/éditeur, préférez fullscreen ; pour les cartes/formulaires légers, utilisez le window par défaut. Ce n'est qu'une valeur initiale ; après publication, vous pouvez changer le « mode d'ouverture » à tout moment dans la console (la console prime). Le mobile est toujours en plein écran, indépendamment de ce champ. Si le manifest est omis, <meta name="tt:display" content="fullscreen"> sert de repli.
  • fileHandlers : déclare quels types de fichiers votre mini-app peut traiter depuis une conversation — quand un utilisateur tape « Ouvrir avec » sur un fichier dans une conversation, les mini-apps ayant déclaré un type correspondant apparaissent comme candidates ; en taper une envoie ce fichier directement dans votre mini-app (voir « Traiter les fichiers de conversation »). Chaque élément : kinds est l'un de image / video / audio / pdf / office / text / any (plusieurs autorisés, any = tout fichier) ; role est editor (ouvrir dans un éditeur) ou viewer (aperçu) ; label optionnel (≤20 caractères, le nom d'affichage du candidat). Jusqu'à 8 éléments. Le filtrage suit la visibilité : private/self-use fonctionne sans revue (n'apparaît que dans votre propre « Ouvrir avec ») ; public requiert l'approbation d'un administrateur avant de prendre effet pour tous.

Configuration « base relative » en une ligne par framework :

// Vite (React/Vue/Svelte/Solid/Preact/Lit…)
export default { base: './' }
// SvelteKit (client-side routing → base must = slug) — static export + base set to your slug (else routes 404)
import adapter from '@sveltejs/adapter-static';
export default { kit: {
  adapter: adapter({ fallback: 'index.html' }),
  paths: { base: '/your-slug', relative: true }
} };
// Astro — astro.config.mjs
export default { base: './', build: { assets: 'assets' } }
# Angular — set a relative base href at build time
ng build --base-href ./ --output-path dist
// Next.js (static export) — next.config.js
module.exports = { output: 'export', images: { unoptimized: true }, assetPrefix: './' }
// Nuxt 3 (static) — nuxt.config.ts
export default defineNuxtConfig({ app: { baseURL: './', cdnURL: './' }, ssr: false })
// Vue CLI / webpack — vue.config.js (or webpack output.publicPath)
module.exports = { publicPath: './' }
<!-- Vanilla / no build: just use relative paths -->
<script src="./app.js"></script>
<link rel="stylesheet" href="./style.css">

⚠ SPA à routage côté client (SvelteKit / React Router / Vue Router / Angular) Les mini-apps sont hébergées sous le sous-chemin /<slug>/. Une SPA à routage côté client doit définir sa « base de routeur » sur votre slug, sinon le routeur du framework ne peut pas faire correspondre le chemin actuel → 404 sur toute la page (les assets se chargent, mais le routage signale « introuvable »). Définir seulement une base d'assets relative ne suffit pas — cela ne corrige que les URL d'assets, pas le routage. Par framework : SvelteKit kit.paths.base='/<slug>' ; React Router <BrowserRouter basename="/<slug>"> ; Vue Router createWebHistory('/<slug>/') ; Angular APP_BASE_HREF='/<slug>/'. (Les apps sans routage côté client — rendu pur / un React mono-page sans Router / vanilla — ne sont pas concernées.)

⚠ Vérification au téléversement Au téléversement, la plateforme effectue une « vérification de contrat » sur le dist : point d'entrée / chemins relatifs / manifest / référence au SDK / limites / MIME sont chacun validés avec des indices en ligne. Total ≤ 8 Mo, ≤ 1 Mo par fichier, ≤ 100 fichiers, MIME sur liste blanche uniquement (html/css/js/json/images/polices/map/wasm). Compressez et sous-ensemblez les polices pour limiter la taille. Pour des sorties de framework lourdes (par ex. tldraw / excalidraw avec un chunk unique > 1 Mo) qui dépassent les limites par défaut par fichier/total, demandez à l'équipe d'exploitation de relever les limites « octets par fichier » / « total décompressé » dans le panneau d'administration (modifiable à l'exécution, effet immédiat) ; scinder manualChunks peut aussi ramener le vendor sous la limite.

Exemple minimal

Une mini-app complète et exécutable — incluez le SDK, lisez le pseudo de l'utilisateur :

<!doctype html>
<html>
  <body>
    <div id="who">Loading…</div>
    <!-- Relative path — maintenance-free: survives domain changes/blocks (platform rewrites to the host's absolute URL) -->
    <script src="/applet-sdk.js"></script>
    <script>
      window.tt.ready(function () {
        window.tt.getProfile().then(function (me) {
          document.getElementById('who').textContent = 'Hi, ' + me.nickname;
        });
      });
    </script>
  </body>
</html>
Intégration du SDK

Incluez le script du SDK dans le HTML de votre mini-app, puis utilisez window.tt :

<!-- Include the SDK in your mini-app HTML. Use a relative path — don't hardcode a domain -->
<script src="/applet-sdk.js"></script>

Ne codez jamais en dur le domaine de l'hôte dans votre mini-app. Écrivez le /applet-sdk.js relatif (ou n'importe quelle origine fictive) — la plateforme réécrit l'URL du script SDK vers l'hôte actuel au moment du service lorsque votre app s'exécute dans son iframe. Ainsi, même si TutuHai change de domaine, ou qu'un domaine est bloqué, chaque mini-app publiée continue de fonctionner sans changement de code et sans republication — un opérateur bascule une seule valeur de configuration.

Callback ready, contexte, thème/langue :

// After ready you get the context (appId / granted scopes / deep-link path / query / theme / locale / whether inline)
window.tt.ready(function (ctx) {
  console.log(ctx.appId, ctx.scopes, ctx.path, ctx.query, ctx.theme, ctx.colorScheme, ctx.locale, ctx.inline);
});
window.tt.context();   // get the current context snapshot anytime (same as ready's ctx)

// Theme switching (fires live when the host toggles light/dark)
window.tt.onThemeChange(function (theme) {
  document.documentElement.setAttribute('data-theme', theme);
});

// Locale switching (synced with the host's i18n; fires live when the host changes language — same mechanism as theme)
window.tt.onLocaleChange(function (locale) {   // e.g. 'zh-CN' / 'en-US'
  document.documentElement.setAttribute('lang', locale);  // the SDK sets it already; you can also localize your own copy
});
Exemples par framework

window.tt est agnostique au framework et fonctionne directement dans tous les frameworks courants (mono-fichier, sans build). Chaque exemple inclut la gestion du succès ✅ et de l'échec ❌ (autorisation refusée / erreur réseau) :

Vanilla JS

// No framework — vanilla DOM
window.tt.ready(function () {
  window.tt.getProfile()
    .then(function (me) {              // ✅ success
      document.getElementById('who').textContent = 'Hi, ' + me.nickname;
    })
    .catch(function (err) {            // ❌ failure (user denied authorization / network error)
      document.getElementById('who').textContent = 'Failed: ' + err.message;
    });
});

React

// React 18 + htm (no build)
const { useState, useEffect } = React;
function App() {
  const [me, setMe] = useState(null);
  const [err, setErr] = useState('');
  useEffect(() => {
    window.tt.ready(() =>
      window.tt.getProfile().then(setMe).catch((e) => setErr(e.message))
    );
  }, []);
  if (err) return html`<div>Failed: ${err}</div>`;      // ❌
  return html`<div>Hi ${me ? me.nickname : '…'}</div>`;  // ✅
}

Preact

// Preact + htm (no build)
const { useState, useEffect } = preactHooks;
function App() {
  const [me, setMe] = useState(null), [err, setErr] = useState('');
  useEffect(() => {
    window.tt.ready(() =>
      window.tt.getProfile().then(setMe).catch((e) => setErr(e.message))
    );
  }, []);
  return html`<div>${err ? 'Failed: ' + err : 'Hi ' + (me ? me.nickname : '…')}</div>`;
}

Vue 3

// Vue 3 (CDN)
const { createApp, ref, onMounted } = Vue;
createApp({
  setup() {
    const me = ref(null), err = ref('');
    onMounted(() => window.tt.ready(() =>
      window.tt.getProfile()
        .then((p) => (me.value = p))     // ✅
        .catch((e) => (err.value = e.message))  // ❌
    ));
    return { me, err };
  },
  template: `<div>{{ err ? 'Failed: ' + err : 'Hi ' + (me?.nickname ?? '…') }}</div>`
}).mount('#app');

Svelte

// Svelte (runtime compile)
let me = $state(null), err = $state('');
window.tt.ready(() =>
  window.tt.getProfile()
    .then((p) => (me = p))            // ✅
    .catch((e) => (err = e.message))  // ❌
);
// template: <div>{err ? 'Failed: ' + err : 'Hi ' + (me?.nickname ?? '…')}</div>

Solid

// SolidJS
import { createSignal, onMount } from 'solid-js';
function App() {
  const [me, setMe] = createSignal(null), [err, setErr] = createSignal('');
  onMount(() => window.tt.ready(() =>
    window.tt.getProfile().then(setMe).catch((e) => setErr(e.message))
  ));
  return <div>{err() ? 'Failed: ' + err() : 'Hi ' + (me()?.nickname ?? '…')}</div>;
}

Alpine.js

<!-- Alpine.js: declarative in HTML, zero build -->
<div x-data="{ me: null, err: '' }"
     x-init="window.tt.ready(() =>
       window.tt.getProfile()
         .then(p => me = p)              /* ✅ */
         .catch(e => err = e.message))"> <!-- ❌ -->
  <span x-text="err ? 'Failed: ' + err : 'Hi ' + (me?.nickname ?? '…')"></span>
</div>

Lit

// Lit (Web Components)
import { LitElement, html } from 'lit';
class MyApp extends LitElement {
  static properties = { me: {}, err: {} };
  connectedCallback() {
    super.connectedCallback();
    window.tt.ready(() =>
      window.tt.getProfile()
        .then((p) => (this.me = p))            // ✅
        .catch((e) => (this.err = e.message))  // ❌
    );
  }
  render() {
    return html`<div>${this.err ? 'Failed: ' + this.err : 'Hi ' + (this.me?.nickname ?? '…')}</div>`;
  }
}
customElements.define('my-app', MyApp);

jQuery

// jQuery
$(function () {
  window.tt.ready(function () {
    window.tt.getProfile()
      .then(function (me) { $('#who').text('Hi, ' + me.nickname); })      // ✅
      .catch(function (err) { $('#who').text('Failed: ' + err.message); }); // ❌
  });
});

Angular

// Angular (component)
@Component({ selector: 'app-root', template: `<div>{{ msg }}</div>` })
export class AppComponent implements OnInit {
  msg = 'Loading…';
  ngOnInit() {
    const tt = (window as any).tt;
    tt.ready(() =>
      tt.getProfile()
        .then((me: any) => (this.msg = 'Hi, ' + me.nickname))       // ✅
        .catch((e: any) => (this.msg = 'Failed: ' + e.message))     // ❌
    );
  }
}

Des exemples complets et exécutables sont dans le dépôt : applet-platform/samples/demo-react.html, demo-svelte.html, demo-vue.html.

Capacités de données

Profil utilisateur · user.profile
// Get the current user's profile (needs user.profile; on first call the host prompts for authorization as needed)
try {
  const me = await window.tt.getProfile();       // ✅ success
  console.log(me.userId, me.nickname, me.avatarUrl);
} catch (err) {                                   // ❌ failure
  // err.message: "User denied authorization" (tapped deny) / network error
  console.warn('Failed to get profile:', err.message);
}

// Profile changes (you or someone in the room changed nickname/avatar) → re-fetch and refresh display
window.tt.onProfileChange(() => refreshWhoUI());
Données cloud · cloud.data

Des collections structurées hébergées qui permettent à une mini-app de persister des données métier sans backend qui lui soit propre. Trois niveaux de visibilité :

  • mine : lecture/écriture de vos seuls documents (par défaut).
  • all : lecture/écriture de tout, uniquement le développeur (propriétaire) de la mini-app — pour une « console marchand » voyant toutes les commandes/tickets.
  • public : tout utilisateur connecté peut tout lire, le nom de la collection doit commencer par pub_ — pour communauté/place de marché/forum ; les écritures et modifications restent limitées à l'auteur.
// Cloud data: no backend of your own, business data hosted by the TutuHai platform. All calls return a Promise — always handle failure.
try {
  // Create a document (owned by the current user)
  const { id } = await window.tt.cloud.add('orders', { items: cart, total: 68, status: 'pending' });
  // Idempotent upsert: create or update by docKey (most common for "one vote per person" / one record per user, avoids fetch-id-then-update)
  await window.tt.cloud.put('votes', me.userId, { choice: 'A' });
  // My documents
  const mine = await window.tt.cloud.list('orders', { scope: 'mine' });
  // Read one (by id; returns null if not found)
  const doc = await window.tt.cloud.get('orders', id);
  // All documents (developer/owner only, for the merchant console; regular users → 403)
  const all = await window.tt.cloud.list('orders', { scope: 'all' });
  // Public collection: name starts with pub_ → any logged-in user can read everything (community/marketplace)
  const posts = await window.tt.cloud.list('pub_posts', { scope: 'public' });
  // where equality filter (server filters on a single data field; on large collections it narrows by parent key to avoid child docs being cut off by the 200 cap)
  const votes = await window.tt.cloud.list('pub_votes', { scope: 'public', where: { pollId: id } });
  // Beyond 200 rows: listPage cursor pagination (mine/all; can take where), returns { docs, nextCursor }
  const pg = await window.tt.cloud.listPage('orders', { scope: 'mine', limit: 100, before: cursor });
  // Field-level update (owner or developer; patch merges with the original data)
  await window.tt.cloud.update('orders', id, { status: 'done' });
  // Delete a document (owner or developer; idempotent) — completes CRUD, no need to pile up soft-delete flags
  await window.tt.cloud.delete('orders', id);
} catch (err) {                                   // ❌ failure
  // Insufficient permission (403) / using public on a non-pub_ collection (400) / quota exceeded / network
  console.warn('Cloud data error:', err.message);
}

Chaque ligne se relit comme { id, ownerId, mine, data:{…vos champs}, createdAt, updatedAt } — vos champs sont tous dans data (par ex. row.data.title).

Stockage KV · storage.kv

Clé-valeur isolé par (mini-app, utilisateur), idéal pour un petit état privé comme les compteurs de pointage, les brouillons, etc. tt.cloudStorage (setItem/getItem/getKeys/removeItem) en est l'alias de style Telegram.

// Hosted KV (needs storage.kv): isolated per (mini-app, user), stores private state
try {
  await window.tt.setStorage('count', 3);
  const n = await window.tt.getStorage('count'); // 3 (returns null if absent)
  await window.tt.removeStorage('count');
  const keys = await window.tt.getStorageKeys();
} catch (err) {                                   // ❌ quota (≤64 keys / 8KB) / network
  console.warn('Storage failed:', err.message);
}

// Telegram-style alias (same as above, needs storage.kv):
await window.tt.cloudStorage.setItem('draft', 'unsent content');
const draft = await window.tt.cloudStorage.getItem('draft');   // returns null if absent
const ks = await window.tt.cloudStorage.getKeys();
await window.tt.cloudStorage.removeItem('draft');

Conversations et multijoueur

Capacités de conversation · im.share / im.send / im.read / media.upload

Toute interaction avec les conversations TutuHai est médiée par l'hôte (l'utilisateur choisit activement une conversation) ; les mini-apps ne peuvent pas obtenir la liste complète des conversations. im.read est une capacité sensible.

// Conversation capabilities are all host-mediated (the user actively picks a conversation); always handle "user cancelled" and failure.
try {
  // Share this mini-app's card to a conversation (needs im.share; if no conversation is passed the host shows a picker)
  await window.tt.shareToChat({ title: 'Come vote for lunch 🍜' });
  // Send a text notification to a conversation (needs im.send; the host shows a picker + preview, signed "via the X mini-app")
  await window.tt.sendMessage('Vote result: Lanzhou beef noodles win');
  // Read conversation messages (needs im.read, sensitive; the user picks a conversation each time, non-text is redacted)
  const r = await window.tt.readMessages({ limit: 30 });
  // Upload an image (needs media.upload; pass a dataURL, returns an absolute URL)
  const url = await window.tt.uploadImage(dataUrl);
} catch (err) {                                   // ❌ failure
  // "User cancelled" (picker/preview cancelled) / authorization denied / "Call timed out" / network
  console.warn('Capability call failed:', err.message);
}
Salles multijoueur · im.room

Transformez une « conversation » en salle temps réel pour des mini-jeux / de la collaboration : créer/rejoindre une salle, messages persistants et signaux temps réel en salle (synchro d'état, ≤2 Ko, éphémères, non persistés). L'hôte relaie les trames temps réel en votre nom, le JWT de l'hôte n'entre jamais dans la mini-app ; limité aux salles créées par cette mini-app / aux conversations dans lesquelles vous avez été partagé — il ne peut pas toucher les autres chats privés de l'utilisateur.

// Multiplayer rooms (needs im.room): create/join/leave + in-room persistent messages + realtime signals (≤2KB, ephemeral, not persisted).
const { conversationId } = await window.tt.room.create({ title: 'Gomoku match' }); // create room, host auto-subscribes
await window.tt.room.join(conversationId);                 // idempotent join; host auto-subscribes to realtime frames
await window.tt.room.subscribe(conversationId);            // subscribe to an existing conversation's realtime frames (e.g. a group you were shared into)
await window.tt.room.send(conversationId, 'Game on!');     // persistent text (visible even without opening the mini-app)
window.tt.room.signal(conversationId, { type:'move', cell:4 }); // send a realtime signal (state sync)
window.tt.room.setTyping(conversationId, true);            // typing state (transient)
const members = await window.tt.room.members(conversationId);   // roster [{userId,nickname,avatarUrl,online,isOwner}]
const past = await window.tt.room.history(conversationId, { limit: 50 }); // hydrate on reconnect (ascending)
await window.tt.room.leave(conversationId);                // leave (empty rooms are auto-reclaimed)

// Realtime events (all under tt.room):
window.tt.room.onMessage((m) => appendMsg(m));      // new message {conversationId,id,senderId,senderName,kind,text,createdAt}
window.tt.room.onSignal((s) => applyMove(s.payload));// opponent's realtime action {conversationId,senderId,payload}
window.tt.room.onPresence((p) => refreshOnline(p)); // online/offline {userId,online}
window.tt.room.onTyping((t) => showTyping(t));       // typing {conversationId,userId,typing}
window.tt.room.onMember(() => reloadMembers());      // member joined/left {conversationId} → re-fetch members()
window.tt.room.onReconnect(() => rehydrate());       // dropped & reconnected → re-hydrate current state from history()/cloud

Hydratation à la reconnexion : les signaux sont au mieux (best-effort) et la perte de trames à la déconnexion est normale. Sur onReconnect, ré-hydratez l'état final depuis room.history() ou les données cloud — ne comptez pas sur les signaux comme source unique de vérité.

Composants en ligne · façon composant natif · respectueux de la vie privée

Une carte envoyée avec shareToChat({inline:true}) rend un composant interactif directement dans la bulle de conversation (par ex. un sondage, une note) ; les destinataires l'utilisent comme une fonctionnalité native sans ouvrir de fenêtre flottante. La mini-app rend une interface compacte selon ctx.inline ; la bulle s'adapte automatiquement au contenu et se re-thématise en direct avec le clair/sombre de l'hôte. Vie privée : une instance en ligne ne reçoit qu'un jeton restreint « cloud.data seulement, sans effets de bord » — elle peut lire les collections publiques + écrire ses propres documents, ne peut pas toucher aux données privées d'autrui, et ne demande pas d'autorisation.

// —— Inline components: make a mini-app interact right in the chat bubble like a native feature (polls/ratings/relays…) ——
// 1) Send an inline card to a conversation (needs im.share): recipients operate it in the bubble without opening the mini-app
await window.tt.shareToChat({ inline: true, query: { pollId }, title: 'Poll', height: 200 });

// 2) The mini-app renders two forms based on ctx.inline
window.tt.ready((ctx) => {
  if (ctx.inline) {
    renderCompact(ctx.query.pollId);   // inline: a compact "native-component-like" UI
    window.tt.reportHeight();          // report height (the SDK also auto-reports via ResizeObserver; the bubble auto-sizes to content)
    // When full functionality is needed, open the full page (floating/fullscreen) from the inline card:
    // openBtn.onclick = () => window.tt.openFullPage('/detail?pollId=' + ctx.query.pollId);
  } else {
    renderFull();                      // floating window: full creation UI
  }
});
// Privacy: an inline instance gets only a "cloud.data only, no side effects" restricted token — it can read public collections + write its own documents,
// can't touch others' private data; doesn't prompt for authorization or pollute the authorization list. Sensitive capabilities (upload/send/location) are unavailable inline.
// Dark/mobile: inline cards switch light/dark live with the host and auto-fit width — no extra work for the developer.
Recherche de mots / traduction · text.lookup / text.provider

Le popover de recherche de mots est lui-même la page en ligne d'une « mini-app fournisseur » — son contenu/fonctionnalité est entièrement rendu par cette mini-app ; l'hôte ne fournit que la sélection + le positionnement au survol + un SDK flexible. Consommateurs (rendre sélectionnable le texte de votre propre mini-app) : déclarez text.lookup, zéro code — sélectionner du texte fait apparaître la page en ligne du fournisseur juste sous la sélection (le texte des messages de conversation est aussi pris en charge). Fournisseurs (créer une mini-app de recherche) : déclarez text.provider (accordé après revue), la page en ligne reçoit les mots via tt.text.onLookup et se rend elle-même ; le fournisseur actif se configure dans le panneau d'administration — si aucun n'est configuré/autorisé, la recherche est désactivée. Ajoutez data-tt-no-lookup pour exclure une zone.

// ── A. Make "text inside your mini-app" selectable for lookup (consumer, needs text.lookup) ──【zero code】
// After declaring "text.lookup" in the manifest, when a user selects text in your mini-app, a popover
// 【the provider mini-app's inline page】pops up right below the selection (auto dictionary/translation). Chat message text is also supported (host built-in).
// Tap blank/scroll/Esc to hide; operating inside the popover doesn't close it. No JS needed.
// Opt out: add data-tt-no-lookup to regions you don't want selectable; <input type=password> is auto-excluded.

// ── B. Build a "word-lookup provider mini-app" (provider, needs text.provider, granted after review) ──
// The provider page (inline mode) receives the selected text pushed by the host via onLookup (pushed initially + on every new word, stays resident without reload),
// and looks up/translates/renders it into any UI; you can use tt.text.lookup to call the built-in engine, or your own cloud.data dictionary.
tt.ready(function () {
  tt.text.onLookup(function (text) {          // host pushes the selected text
    tt.text.lookup(text).then(function (r) {  // r={kind:'dict'|'translate',...}
      render(r);                              // render into your own UI (auto-height)
    });
  });
});

// ── Escape hatch / flexible SDK primitives (available to any mini-app) ──
const r = await window.tt.text.lookup('lazy', { to:'en' });        // look up / translate on demand
window.tt.text.onSelect(function (sel){ /* {text, rect}; registering takes over, the default popover steps aside */ });
window.tt.openFloating({ path:'/detail', anchor: sel.rect, width:320, height:220 }); // open a floating window at the selection
window.tt.floating.moveTo(100, 200);  // the floating window's position/size are fully adjustable via the SDK: setRect/moveTo/resize/close

Fichiers et cloud drive

Traiter les fichiers de conversation · media.upload / im.share

Quand un utilisateur tape « Ouvrir avec » sur une image/un fichier dans une conversation, il peut choisir votre mini-app pour le traiter — à condition que vous ayez déclaré un type de fichier correspondant dans les fileHandlers de manifest.json (image/video/audio/pdf/office/text/any). Une fois ouvert : getContextFile() récupère le fichier, readFile() récupère les octets en même origine via l'hôte (évitant le CORS, pour analyser images/audio/vidéo/tout format), puis après traitement uploadFile() + sendFileToChat() le renvoie dans la conversation, ou saveFile() le télécharge — un flux sans couture.

// —— Handling chat files: the user taps "Open with" on a file and picks your mini-app (must declare matching kinds in manifest.fileHandlers) ——
const f = window.tt.getContextFile();
// f = {url,name,mime,size,kind:'image'|'file',conversationId,messageId} or null (when opened standalone)
if (f) {
  const bytes = await window.tt.readFile(f.url);   // host fetches bytes same-origin (avoids iframe CORS; limited to this site's /uploads output)
  // bytes = {dataUrl, mime, name, size, url} — feed to <img>/<video>/<audio>/canvas to analyze any format
  imgEl.src = bytes.dataUrl;
}
// —— Process the output → send back to the conversation or download (needs media.upload / im.share) ——
const out = canvas.toDataURL('image/webp', 0.9);            // e.g. convert image to WebP
const up = await window.tt.uploadFile({ dataUrl: out, name: 'result.webp' });   // {url,name,size,mime} (≤20MB)
await window.tt.sendFileToChat({
  url: up.url, name: up.name, mime: up.mime, size: up.size,
  conversationId: f.conversationId    // pass it to send straight to the original conversation (skip the picker); omit it and the host shows a conversation picker
});
await window.tt.saveFile({ dataUrl: out, name: 'result.webp' });   // or: download locally (host downloads on your behalf)
Tutu Drive · tt.drive (nécessite media.upload)

Importez des fichiers depuis Tutu Drive, ou enregistrez votre sortie dans le drive (médié par l'hôte : l'utilisateur choisit les fichiers un par un dans le sélecteur de drive à l'intérieur de la page de l'hôte ; la mini-app ne détient pas de jeton de drive). Rejette si le drive est injoignable / si le Tutu ID n'est pas fédéré — après .catch, la mini-app peut se rabattre sur un uploadFile local.

// Pick files from the drive (the user picks in the host's drive picker; returns [] on cancel):
const picked = await window.tt.drive.pick({ multiple: true, accept: 'image/*' });
// picked = [{ nodeId, name, size, mime, downloadUrl }]
for (const file of picked) {
  const bytes = await window.tt.readFile(file.downloadUrl);  // fetch bytes to analyze/display
  render(bytes.dataUrl);
}

// Save a file into the drive (pass this site's /uploads output absolutized as pullUrl, or a dataUrl directly):
const saved = await window.tt.drive.save({ pullUrl: up.url, name: 'export-result.png' });
// saved = { nodeId, name }

Interaction inter-apps

Interaction inter-apps · glisser entre apps · tt.link / tt.tray / tt.drag / tt.drop (aucune autorisation requise)

Plusieurs mini-apps peuvent être ouvertes en même temps (enregistrées comme un combo à ouvrir ensemble en un clic, dispositions multi-fenêtres 2/3/4, une barre latérale ancrée — le tout géré par l'hôte, sans code), et interagissent via les capacités suivantes :

  • tt.link : synchroniser en temps réel événements/état avec les autres mini-apps ouvertes (diffusion ou ciblé, ≤2 Ko, limité à 25/s ; disparaît à la fermeture de la fenêtre, non persisté, non inter-utilisateur/conversation).
  • tt.tray : ramasser du contenu dans le plateau de l'hôte pour le relayer, puis l'injecter dans une autre mini-app ou conversation.
  • tt.drag.start / tt.drag.bind : démarrer un glisser / lier un élément comme une source de glisser directement déposable dans une autre mini-app (glisser-déposer HTML5 natif entre mini-apps de même origine).
  • tt.drop.accept : la mini-app entière peut recevoir des dépôts / injections de plateau.
  • tt.drop.zone : ★laisser un élément interne précis détecter un dépôt (avec retour visuel au survol) et y réagir. Une mini-app peut avoir plusieurs zones, chacune détectant indépendamment.

Sécurité : une url encapsulée en file n'accepte que les handles /uploads/ de ce site (le destinataire récupère les octets via tt.readFile) ; les liens externes cross-origin falsifiés sont rejetés ; text/json/name ont tous des plafonds de taille.

// —— ① Event/state sync tt.link (broadcast in realtime with "other open mini-apps"; drops on window close, not persisted, not cross-user/conversation) ——
tt.link.send({ type: 'color', color: '#ef4444' });        // broadcast to all linked mini-apps (≤2KB, rate-limited 25/s)
tt.link.sendTo(appId, { type: 'ping' });                  // send to a specific peer
tt.link.on((from, msg) => { /* from={appId,slug,name} */ apply(msg); });
const peers = await tt.link.peers();                      // the other linked mini-apps right now [{appId,slug,name}]

// —— ② Tray relay tt.tray (pick up → drop into another mini-app / conversation) ——
await tt.tray.put({ kind:'json', name:'color', data:{ color:'#ef4444' } }); // put into the host tray
const items = await tt.tray.list();                       // view the tray [parcel]
const p = await tt.tray.take(id);                         // take a parcel out
// parcel = { kind:'file'|'text'|'json', url?/text?/data?, name?, mime? }

// —— ③ Direct drag between apps 【drag source】tt.drag ——
tt.drag.start({ kind:'json', name:'color', data:{ color:'#ef4444' } }); // start a drag, returns {id}
tt.drag.bind(swatchEl, () => ({ kind:'json', name:'color', data:{ type:'color', color:'#ef4444' } }));
// getParcel() returns this drag's parcel; return null to not start. Between same-origin mini-apps it uses native HTML5 drag-and-drop with the browser's built-in ghost.

// —— ④ Receive 【whole window】tt.drop.accept ——
tt.drop.accept(['json','file'], (parcel) => { apply(parcel); }); // callback on drop anywhere in this window / tray injection

// —— ⑤ ★Receive 【element-level】tt.drop.zone ——
tt.drop.zone(slotEl, ['json','file'], {
  onEnter: () => slotEl.classList.add('hot'),    // drag into this element → only it highlights (internal elements sense independently)
  onOver:  () => {},                             // while hovering (do continuous feedback)
  onLeave: () => slotEl.classList.remove('hot'), // move out → clear highlight
  onDrop:  (parcel) => fill(slotEl, parcel),     // dropped on this element → only it receives and acts
});

Interface · fenêtre · hôte

Interface · fenêtre · appareil (aucune autorisation requise)

Des capacités d'interface façon wx.* de WeChat — l'hôte rend de vrais toasts/dialogues/visionneuses d'images, contrôle la fenêtre capsule de la mini-app, et accède au presse-papiers/vibration/appel/localisation/réseau — appelables sans demander d'autorisation, rendant une mini-app aussi puissante qu'une app native.

// —— Interaction feedback ——
window.tt.showToast({ title: 'Saved', icon: 'success' });   // icon: success|error|loading|none
window.tt.hideToast();
window.tt.showLoading({ title: 'Processing…' });            // pair with window.tt.hideLoading()
window.tt.hideLoading();
const { confirm } = await window.tt.showModal({ title: 'Confirm', content: 'Delete this?' });
const { tapIndex } = await window.tt.showActionSheet({ itemList: ['Camera', 'Album'] }); // rejects on cancel
// —— Window / system info ——
window.tt.setNavigationBarTitle({ title: 'My page' });      // change the mini-app capsule title
const info = await window.tt.getSystemInfo();               // {theme, platform, windowWidth, windowHeight, safeAreaInsets, appName, version}
// —— Device ——
await window.tt.setClipboardData({ data: 'copied text' });
const { data } = await window.tt.getClipboardData();
window.tt.vibrateShort();  window.tt.vibrateLong();          // haptic feedback
window.tt.makePhoneCall({ phoneNumber: '10086' });
// —— Media ——
window.tt.previewImage({ urls: [url1, url2], current: url1 }); // fullscreen image preview
// —— Location / external link / network ——
const loc = await window.tt.getLocation();                    // browser prompts for permission → {latitude, longitude, accuracy, speed}
window.tt.openLocation({ latitude: loc.latitude, longitude: loc.longitude, name: 'Store' }); // view on a map
window.tt.openLink({ url: 'https://example.com' });           // open in a new tab (http/https only)
const net = await window.tt.getNetworkType();                 // {isConnected, networkType: wifi|4g|...}
Fenêtre flottante · plein écran · image de marque

Contrôlez la fenêtre de la mini-app : ouvrir une fenêtre flottante déplaçable depuis le mode en ligne/une sélection, agrandir en plein écran / restaurer, teinter la capsule de l'hôte, et écouter les changements d'affichage/de taille.

// —— Floating window (open a draggable floating window from inline / a selection) ——
window.tt.openFloating({ path:'/detail', anchor: rect, x:100, y:120, width:320, height:220 });
window.tt.floating.setRect({ x, y, width, height });  // also moveTo(x,y) / resize(w,h) / close()
window.tt.openFullPage('/detail');                    // open the full page from an inline card (floating/fullscreen)

// —— Fullscreen / restore / close (desktop; mobile is already fullscreen) ——
window.tt.expand();  window.tt.collapse();  window.tt.close();
const dm = await window.tt.getDisplayMode();          // {maximized, mobile} — whether fullscreen / whether mobile
window.tt.onEvent('displayChanged', (p) => updateFullscreenChip(p.maximized)); // two-way sync with the host capsule's "fullscreen/restore"
window.tt.onEvent('viewportChanged', (p) => relayout(p.width, p.height));       // iframe size change → responsive re-layout

// —— Branding: tint the host capsule header/background ——
window.tt.setHeaderColor('#4f46e5');
window.tt.setBackgroundColor('#fdf6e3');
Boutons de l'hôte · retour haptique · façon Telegram (aucune autorisation requise)

Une mini-app contrôle les boutons de l'interface de l'hôte et reçoit leurs événements de clic (bidirectionnel) — le bouton principal du bas mainButton, le bouton retour de l'en-tête backButton, le retour haptique hapticFeedback. Cela permet à une mini-app de s'intégrer en profondeur à l'interface de l'hôte (plutôt que d'être une page isolée).

// MainButton (the host's big bottom button, controlled by the mini-app + receives clicks) — Telegram-style
window.tt.mainButton.setText('Submit order').show();  // chainable; setText/setParams/show/hide/enable/disable/showProgress/hideProgress
window.tt.mainButton.onClick(() => {                  // click callback (host → mini-app event); offClick to unbind
  window.tt.mainButton.showProgress();
  submit().finally(() => window.tt.mainButton.hideProgress());
});
// BackButton (the host header's back button)
window.tt.backButton.show();                           // show/hide/onClick/offClick
window.tt.backButton.onClick(() => history.back());
// Generic event listening (same as the onClick above)
window.tt.onEvent('mainButtonClicked', handler);
window.tt.onEvent('backButtonClicked', handler);
window.tt.offEvent('mainButtonClicked', handler);      // unbind
// HapticFeedback
window.tt.hapticFeedback.impactOccurred('light');      // light|medium|heavy|rigid|soft
window.tt.hapticFeedback.notificationOccurred('success'); // error|success|warning
window.tt.hapticFeedback.selectionChanged();
Thème · stockage cloud · façon Telegram

colorScheme / themeParams gardent les couleurs de la mini-app cohérentes avec l'hôte et basculent avec le clair/sombre ; locale se synchronise avec l'i18n de l'hôte.

// —— Theme (consistent with the host's colors, switches with light/dark) ——
window.tt.colorScheme;          // 'light' | 'dark'
window.tt.themeParams;          // {bgColor,textColor,hintColor,linkColor,buttonColor,buttonTextColor,secondaryBgColor}
document.body.style.background = window.tt.themeParams.bgColor;   // use the host color, consistent with the host
window.tt.onEvent('themeChanged', () => {                        // fires when the host toggles light/dark (same as onThemeChange)
  applyTheme(window.tt.colorScheme, window.tt.themeParams);
});
// —— Locale i18n (synced with the host; the SDK already sets <html lang>) ——
window.tt.locale;               // e.g. 'zh-CN' / 'en-US'; same as tt.context().locale
window.tt.onLocaleChange((locale) => renderInLang(locale));      // or onEvent('localeChanged')

Référence

Gestion des erreurs

Chaque window.tt.* renvoie une Promise et rejette une Error en cas d'échec. Les mini-apps en production doivent .catch / try-catch chaque appel. err.message courants :

err.message Signification / traitement suggéré
User denied authorization L'invite d'autorisation à la demande a été refusée → guider vers une nouvelle tentative
User cancelled Le sélecteur/aperçu de conversation a été annulé → rester silencieux
Call timed out L'hôte est resté longtemps sans répondre (rare) → proposer une nouvelle tentative
Data too large / too many documents / too many storage items Quota dépassé → réduire les données
public reads are limited to pub_-prefixed public collections Nom de collection incohérent → utiliser un préfixe pub_
Forbidden (403) Un non-développeur a utilisé scope=all → pas d'autorisation
Network error / Failed to fetch Échec réseau → message convivial + nouvelle tentative
// Every tt.* returns a Promise and rejects an Error on failure; handle with .catch / try-catch.
window.tt.getProfile()
  .then((me) => { /* … */ })
  .catch((err) => {
    switch (err.message) {
      case 'User denied authorization': /* guide the user to retry authorization */ break;
      case 'User cancelled':            /* the user cancelled the conversation picker, stay silent */ break;
      case 'Call timed out':            /* the host was unresponsive for a long time (rare), prompt a retry */ break;
      default:                          /* quota / permission (403) / network, etc — give a friendly prompt */
    }
  });
Modèle d'autorisation

Autorisation à la demande : ouvrir une mini-app ne nécessite pas d'accorder toutes les permissions d'emblée ; l'hôte demande un seul élément uniquement lorsqu'une capacité est appelée pour la première fois (autoriser/refuser). L'utilisateur peut « faire confiance à cette mini-app » pour tout accorder d'un coup, ou basculer les éléments un par un et consulter l'historique d'utilisation sur la page des paramètres. Le backend revalide à chaque appel (défense en profondeur). Les capacités interface/fenêtre/appareil/thème/inter-apps sont utilisables sans autorisation.

Capacité (scope) Description Sensible
user.profile Obtenir votre pseudo et votre avatar
cloud.data Données cloud (collections ; commandes/enregistrements, etc.)
storage.kv Stockage de données (KV)
media.upload Téléverser images/fichiers (y compris le drive tt.drive)
im.share Partager une carte dans une conversation
im.send Envoyer des messages
im.read Lire l'historique des conversations Sensible
im.room Salles multijoueur : envoyer/recevoir des messages et lire le chat de la salle en votre nom Sensible
text.lookup Recherche de mots/traduction (le texte sélectionné est envoyé à un service de traduction) Sensible
text.provider Fournisseur de recherche (la page de cette mini-app sert de popover de recherche/traduction) Sensible
Visibilité
  • Public : apparaît dans « Découvrir » et la recherche ; tout le monde peut la trouver.
  • Non répertorié : absent de découvrir/recherche ; accessible uniquement via une carte partagée ou un lien copié (lien profond) — pour une diffusion en trafic privé sans exposition publique. Basculez-le dans la console en un clic.
  • Mode d'ouverture (bureau) : le « mode d'ouverture » de la console bascule « flottant (par défaut) / plein écran » — les apps canevas/tableau blanc/éditeur mettent fullscreen pour remplir l'écran à l'ouverture ; les cartes/formulaires légers utilisent le flottant. Vous pouvez aussi déclarer la valeur initiale dans le display de manifest.json. Le mobile est toujours en plein écran, indépendamment de ce réglage.
Versions et publication

Modèle de version : brouillon → en revue → prêt → en ligne.

Toutes les versions historiques sont conservées, avec retour arrière en un clic vers n'importe quelle version historique (échange instantané de la version en ligne). Les rejets affichent la raison dans le centre de notifications.

Contraintes et quotas
  • Données cloud : un document ≤ 8 Ko, ≤ 500 documents par (mini-app, utilisateur), list() ≤ 200 à la fois (par les plus récents) ; utilisez la pagination par curseur listPage() (mine/all) au-delà. Note : faire une agrégation côté frontend (comptage/moyenne) directement avec list() sous-comptera la partie la plus ancienne quand une collection dépasse 200 et donnera un résultat faible — pour des totaux complets, utilisez la pagination listPage ou acceptez une approximation.
  • KV : une valeur ≤ 8 Ko, ≤ 64 clés par (mini-app, utilisateur).
  • Images téléversées ≤ 4 Mo ; fichiers téléversés ≤ 20 Mo ; l'envoi de messages est limité en débit (≤ 20 par utilisateur par minute) ; charge utile d'un signal de salle ≤ 2 Ko ; un message tt.link ≤ 2 Ko, limité à 25/s.
  • Le bundle de code est un HTML mono-fichier (préférez le rendu DOM pur ; évitez innerHTML pour prévenir le XSS).
  • Les jetons sont à courte durée de vie (environ 2 heures) ; l'hôte les renouvelle silencieusement après expiration ; les appels de capacités sont revalidés par le backend.

Exemples de référence : les applets/food (commande, données cloud) et applets/repair (demandes de réparation) du dépôt sont tous deux des mini-apps purement frontend + données cloud.