# CONVENTION — Application Constellae

> Plateforme Constellae **v3.249.0** · contrat **v4** · généré le 2026-10-13.
> Version toujours à jour : `GET /api/public/convention` (ou connecteur MCP `/api/mcp`). Vérifie la fraîcheur via `GET /api/public/version`.

> **Mode d'emploi (utilisateur)** : joignez **ce seul fichier** à votre IA (Claude, ChatGPT, Gemini…), copiez le prompt ci-dessous, et remplacez la dernière phrase par la description de votre application. L'IA produira une application **100 % compatible**, à importer ensuite via « ＋ Nouveau ».

---

## 1) Le prompt à copier-coller

Tu vas créer une application (« artifact ») pour Constellae. Ce document EST la convention : respecte STRICTEMENT les règles et l'API décrites plus bas. Exporte par défaut un composant React (export default function App() { ... }) — ou fournis une page .html autonome si je le demande. Utilise UNIQUEMENT les variables CSS --brand-* (avec valeur de repli) pour couleurs/rayon/police. N'utilise PAS localStorage/sessionStorage (utilise window.storage), ni <a download>/window.open (utilise window.download), ni d'appel réseau direct (utilise window.api). N'active une capacité que si nécessaire. Si l'application a plusieurs sections, déclare son menu avec window.menu.set([{id,label,icon}]) et window.menu.onSelect(...) pour qu'il s'affiche dans la barre latérale. Rends l'application MULTILANGUE (i18n) : n'écris jamais de texte visible en dur, fournis tes traductions (au moins fr + en) et affiche-les via window.i18n.t({ fr:'…', en:'…' }) selon window.i18n.locale ; réagis à window.i18n.onChange si tu mémorises la langue. Si l'application s'y prête, propose un ou plusieurs WIDGETS (tuiles compactes pour la page d'accueil) : déclare-les dans le bloc de métadonnées (champ "widgets") ET ajoute dans le MÊME fichier un export « export function Widget({ id }) { ... } » qui affiche le bon widget selon id (voir section « Widgets »). Code simple, responsive (mobile d'abord), accessible. IMPORTANT : place tout en HAUT du fichier le bloc de métadonnées (voir section « Métadonnées ») rempli pour mon application — il remplit automatiquement la fiche du catalogue à l'upload. Le bloc DOIT porter une `category` valide de la taxonomie officielle figée (id listé par GET /api/public/categories ; sous-catégorie optionnelle appartenant à la catégorie) — n'invente jamais de catégorie hors référentiel. Renseigne aussi `version` (semver MAJEUR.MINEUR.CORRECTIF) et `releaseNotes` (historique). Livre le tout dans UN SEUL fichier prêt à importer. Mon application à créer : [DÉCRIS ICI TON APPLICATION EN UNE OU DEUX PHRASES].

---

## 2) Métadonnées (OBLIGATOIRE — remplit la fiche du catalogue)

Place **tout en haut du fichier** ce bloc de commentaire, rempli pour l'application. À l'upload, il **remplit automatiquement** la fiche de dépôt (titre, description, catégorie, usages…). Le bloc est ignoré à l'exécution.

**Dans un fichier .tsx / .jsx** (commentaire `/* … */`) :
```js
/* ARTIFACT-META
{
  "title": "Suivi des visites clients",
  "subtitle": "Notez vos visites et exportez en PDF",
  "description": "Application de suivi commercial : liste de clients, prise de notes par visite, export PDF.",
  "category": "Productivité",
  "profession": "Commercial",
  "sector": "Services",
  "languages": ["fr"],
  "price": 0,
  "uses": ["storage", "download"],
  "channels": [{ "name": "clients", "desc": "Annuaire partagé" }],
  "actions": [],
  "entities": [{ "name": "visite", "desc": "Une visite client" }],
  "apiNeeds": [],
  "widgets": [
    { "id": "agenda",  "name": "Agenda du jour", "description": "Prochaines visites", "size": "medium", "default": true },
    { "id": "compteur", "name": "Visites du mois", "description": "Total courant", "size": "small" }
  ]
}
ARTIFACT-META-END */
```
**Dans un fichier .html** : même bloc entre `<!-- ARTIFACT-META` et `ARTIFACT-META-END -->`.

Champs : `title` (requis), **`category` (REQUISE)** — un `id` de la **taxonomie officielle figée** (voir `GET /api/public/categories`) ; **`subcategory`** (optionnelle) doit appartenir à la catégorie parente. Une catégorie invalide/absente range l'application en **« Non classés »** (jamais perdue). Puis `subtitle`, `description`, `profession`, `sector`, `languages` (liste), `price` (€/utilisateur, 0 = gratuit), `uses` (parmi `storage`, `files`, `ai`, `api`), `channels` (liste `{name, desc, intent?, dataClass?, schema?}` — voir « Échanger entre artifacts » : tout canal utilisé DOIT être déclaré ici) / `actions`/`entities` (listes `{name, desc}`), `apiNeeds` (liste de besoins de services externes), `widgets` (liste de tuiles d'accueil, voir section « Widgets »). Renseigne au mieux pour une catégorisation parfaite.

**IMPORTANT — `description` = mini-README (obligatoire et soigné).** La `description` n'est pas qu'un résumé : elle alimente l'assistant « Voilà » (base de connaissances). Rédige-la comme un **petit README** : (1) à quoi sert l'application, (2) ses **fonctions clés**, (3) **comment l'utiliser** en quelques étapes, (4) 2–4 **questions fréquentes** et leurs réponses, et (5) des **mots-clés / synonymes** que les utilisateurs pourraient employer. Plus elle est riche et concrète, mieux Voilà saura présenter ton application et répondre aux questions à son sujet (en citant la source).

---

## 3) Règles à respecter

Une application (« artifact ») s'exécute dans un **bac à sable iframe**. Par défaut, c'est un **composant React/TSX** ; ce peut aussi être une **page HTML autonome**.

- **Export** : `export default function App() { ... }` (TSX), ou un fichier `.html` complet.
- **Charte** : utilise **uniquement** les variables `--brand-*` (section 4) pour couleurs/rayon/police, **avec valeur de repli** : `color: var(--brand-text, #27272b)`.
- **Interdits du bac à sable** :
  - Pas de `localStorage` / `sessionStorage` → `window.storage`.
  - Pas de `<a download>` ni `window.open` → `window.download`.
  - Aucun appel réseau direct (`fetch` externe) → `window.api`.
- **Sobriété** : responsive (mobile d'abord), accessible (contraste AA), code lisible.
- N'utilise une capacité que **si nécessaire**. Tout est optionnel.
- Livre le résultat dans **un seul fichier** prêt à importer.

---

## 4) API du bac à sable (toutes optionnelles)

### Stockage de données — `window.storage`
```js
await window.storage.set('cle', valeur);     // JSON sérialisable
const r = await window.storage.get('cle');   // r.value
await window.storage.delete('cle');
const { keys } = await window.storage.list();
```
Recharge au démarrage, enregistre à chaque modification. Données **par utilisateur** (privées). Pour des données **partagées** dans l'organisation, passe `true` en dernier argument (`set(cle, valeur, true)`).

> **Exposer des données à l'IA d'une organisation (MCP).** Le serveur MCP ne voit que le stockage **partagé** et les **canaux** — jamais le stockage **privé** (le défaut). Si tu veux qu'un assistant connecté en MCP puisse lire/écrire les données de ton app (ex. un annuaire de clients partagé par l'équipe), écris-les dans un **canal** (`window.channel`) ou en **stockage partagé**, pas en stockage privé.

### Fichiers / photos — `window.files`
```js
const f = await window.files.upload(file);   // { id, name, mime, size }
const url = await window.files.url(f.id);     // utilisable dans <img src=...>
const { files } = await window.files.list();
await window.files.delete(id);
await window.files.save(id);                  // déclenche le téléchargement
```
Mémorise les `id` avec `window.storage`.

> **COMPRESSE TOUJOURS les images AVANT `window.files.upload()` (OBLIGATOIRE).** Une photo de téléphone brute pèse 3–8 Mo : sans compression, l'upload prend plusieurs minutes (et alourdit énormément un paquet `.nova`). Redimensionne + réencode en JPEG côté client avant l'envoi. Repères : bannière/couverture `maxPx≈900, q≈0.82` (≈60–100 Ko) ; avatar/miniature `maxPx≈400, q≈0.80` (≈20–40 Ko) ; document à garder lisible `maxPx≈1600, q≈0.88`.
> ```ts
> async function compressImage(file, maxPx = 900, quality = 0.82) {
>   if (!/^image\//.test(file.type) || /svg/.test(file.type)) return file; // ne pas toucher aux SVG
>   return await new Promise((resolve) => {
>     const img = new Image(); const url = URL.createObjectURL(file);
>     img.onload = () => {
>       URL.revokeObjectURL(url);
>       const s = Math.min(1, maxPx / Math.max(img.width, img.height));
>       const c = document.createElement('canvas');
>       c.width = Math.round(img.width * s); c.height = Math.round(img.height * s);
>       c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
>       c.toBlob((b) => resolve(b ? new File([b], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }) : file), 'image/jpeg', quality);
>     };
>     img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
>     img.src = url;
>   });
> }
> // Usage : const up = await window.files.upload(await compressImage(file));
> ```

### Base de données portable — `window.db` (SQLite)
Pour des données relationnelles (tables, jointures, requêtes SQL), une vraie base **SQLite** tourne **dans le bac à sable** (WebAssembly, côté client) : aucun accès à la base du site. Les octets du fichier `.db` sont persistés via `window.files` (donc **par utilisateur**, dans le quota de fichiers). Pour de simples paires clé/valeur, préfère `window.storage` (plus léger).
```js
const db = await window.db.open('agenda');        // restaure la base si elle existe
db.run('CREATE TABLE IF NOT EXISTS rdv(id INTEGER PRIMARY KEY, titre TEXT, jour TEXT)');
db.run('INSERT INTO rdv(titre, jour) VALUES (?, ?)', ['Dentiste', '2026-07-10']);
const lignes = db.query('SELECT * FROM rdv WHERE jour >= ?', ['2026-07-01']); // -> [{id,titre,jour}]
await db.save();                                   // persiste (à rappeler après des écritures)
```
Appelle `db.save()` après un lot d'écritures (pas à chaque `run`). Par défaut, les données sont **privées à l'utilisateur**.

**Base COMMUNE À L'ORGANISATION** — passe `{ shared:true }` pour une base partagée par tous les membres de l'org (cloisonnée par organisation ET par Nova) :
```js
const db = await window.db.open('annuaire', { shared:true });   // base de l'org
db.run('INSERT INTO contacts(nom) VALUES (?)', ['Dupont']);
try {
  await db.save();                       // versionnage optimiste côté serveur
} catch (e) {
  if (e.code === 'DB_CONFLICT') {         // un autre membre a écrit entre-temps
    const fresh = await window.db.open('annuaire', { shared:true }); // repart de la version fraîche
    // …ré-applique ton écriture sur `fresh` puis fresh.save()
  } else { throw e; }
}
```
Le versionnage optimiste évite qu'une sauvegarde en écrase silencieusement une autre : en cas de `DB_CONFLICT`, **rouvre** la base et **rejoue** ton écriture. Réserve la base partagée aux données réellement communes ; pour de la messagerie temps réel, un canal (`window.channel`) reste plus adapté.

### Configuration de la Nova (org + perso) — `window.config`
Beaucoup de Novas « métier » ont besoin d'une **configuration profonde** : des réglages valables pour **toute l'organisation** (ex. un SIRET, une URL de service, des options d'affichage par défaut) ET une **personnalisation par utilisateur**. C'est exactement le rôle de `window.config` — cloisonné par organisation **et** par Nova.
```js
const { org, user, effective } = await window.config.get();
// effective = config org + préférences perso fusionnées (la perso l'emporte)
await window.config.setUser({ theme:'sombre', densite:'compacte' });   // préférences de CET utilisateur
await window.config.setOrg({ siret:'12345678900012', devise:'EUR' });  // pour TOUTE l'org — 403 si non autorisé
```
- `setUser` : accessible à **chaque** utilisateur (ne touche que SA config).
- `setOrg` : réservé à l'**administrateur de l'organisation** OU à un **administrateur délégué** de cette Nova (voir la fiche « Partager » de la Nova). Un `setOrg` non autorisé renvoie **403** — prévois ce cas dans l'UI (masque le réglage org si l'utilisateur n'y a pas droit).
- Lis `effective` au démarrage pour appliquer la config ; n'affiche les réglages « org » que si l'utilisateur peut les modifier.

### Disque privé de la Nova — `window.vault`
Pour des données que la Nova doit stocker **sans les exposer à l'utilisateur** (états internes, index, cache métier), utilise `window.vault` : un espace sur le stockage interne **appartenant à la Nova**. Contrairement à `window.drive`, l'utilisateur **ne peut pas parcourir** ce contenu depuis « Mon Disque » — cela évite erreurs de manipulation et fuites.
```js
await window.vault.write('/etat/derniere-sync.json', new Blob([JSON.stringify(x)], { type:'application/json' }));
const blob = await window.vault.read('/etat/derniere-sync.json');       // Blob (ou null)
const { items } = await window.vault.list('/etat/');                    // [{path,name,mime,size,updatedAt}]
await window.vault.delete('/etat/derniere-sync.json');
```
- `{ shared:false }` (défaut) : espace de la Nova **propre à chaque utilisateur**.
- `{ shared:true }` : espace de la Nova **commun à l'organisation**.
Dans les deux cas, l'accès est **cloisonné par organisation et par Nova**, et **jamais** exposé dans l'interface disque de l'utilisateur.

### Halos (compléments) — `window.store`
Un **Halo** est un **complément** qu'une organisation acquiert dans le **Cosmos** pour enrichir ses Novas : thème, jeu de données, gabarit, ou déverrouillage d'une fonction premium. Ta Nova s'y adapte à l'exécution via `window.store`, **cloisonné par organisation** :
```js
if (await window.store.owns('demo-premium')) {   // l'org possède-t-elle ce Halo ?
  // …active la fonctionnalité premium…
}
const theme = await window.store.get('theme-aurora'); // charge utile (ou null si non possédé)
if (theme?.palette) applyPalette(theme.palette);
const mine = await window.store.list();               // [{slug, kind, title}] possédés
```
- `owns(slug)` → booléen de **déverrouillage** (l'org détient le Halo).
- `get(slug)` → la **charge utile publique** du Halo (JSON) **uniquement si l'org le possède**, sinon `null`.
- `list()` → les Halos possédés par l'org.

Conçois toujours une **dégradation gracieuse** : si le Halo n'est pas possédé, la Nova reste utilisable (fonction verrouillée, thème par défaut). Tu peux **déclarer** les Halos utiles dans le META (`"halos": ["theme-aurora"]`) pour les afficher sur la fiche de la Nova — c'est indicatif, la vérification fait toujours foi côté serveur.

### Base externe (Supabase, API SQL en HTTP)
Le navigateur ne peut **pas** ouvrir de connexion SQL brute (Postgres/MySQL TCP). En revanche, tout service exposé en **HTTP** passe par `window.api` (clé injectée côté serveur, jamais exposée). Exemple **Supabase** (PostgREST), une fois le besoin `supabase` déclaré par un administrateur :
```js
const r = await window.api('supabase', { method: 'GET', path: '/rest/v1/clients?select=*' });
```
La clé peut être **propre à chaque utilisateur** (chacun branche son compte dans « Mes clés personnelles ») : la même application sert alors les données de l'utilisateur courant, sans jamais voir le secret.

### Télécharger un fichier généré (Word, PDF, CSV…) — `window.download`
N'utilise PAS `<a download>` ni `window.open`. Génère le contenu en mémoire (Blob/ArrayBuffer), puis :
```js
await window.download(blob);
await window.download({ name: 'cv.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', data });
// data = Blob | ArrayBuffer | base64 | data URL
```

### Intelligence artificielle — `window.claude`
```js
const texte = await window.claude.complete('ta consigne', { model: 'claude-sonnet-4-6' });
```
La clé reste sur le serveur. Disponible selon les autorisations de l'organisation. **Fournisseur au choix de l'org** : une organisation peut router `window.claude` vers **son propre LLM compatible OpenAI** (Mistral, OpenAI…) — c'est transparent pour toi, l'API ne change pas ; note simplement que, dans ce cas, le **modèle est imposé par l'organisation** (le `model` que tu passes peut être ignoré) et la réponse est normalisée au format Messages (`content:[{type:'text',text}]`). Écris donc ton code sans dépendre d'un identifiant de modèle précis.

### Échanger entre artifacts — `window.channel`
```js
const ch = window.channel('NOM_DU_CANAL');
const { read, write, admin, exists } = await ch.can();   // droits + le canal existe-t-il
await ch.set('cle', valeur);                     // écriture -> nécessite write
const r = await ch.get('cle');                   // lecture  -> nécessite read ; r.value
const { keys } = await ch.list();
await ch.files.upload(file); await ch.files.list(); await ch.files.url(id);
```
Un canal est un **espace de données partagé (clé/valeur + fichiers) entre des membres choisis d'une organisation** (idéal pour une base commune : annuaire, référentiel…). L'admin gère les membres et leur **rôle** : **Lecteur** (lecture seule) ou **Éditeur** (lecture + écriture) ; l'admin a un accès total. Un accès refusé renvoie `403`.

**GÈRE LE CAS « canal pas encore créé ».** `can()` renvoie `exists` : `false` signifie que **l'administrateur de l'organisation n'a pas encore créé le canal** (fréquent si l'application a été installée par un simple utilisateur). Distingue ce cas de « pas membre » et affiche un message utile — ex. si `!exists` : « Ce partage n'est pas encore activé par votre administrateur (Paramètres → Canaux → “Créer”) » ; si `exists && !read` : « Demandez à un administrateur de vous ajouter à ce canal. » Ne te contente pas d'un « Accès refusé » ambigu.

**RÈGLE OBLIGATOIRE — déclare tes canaux.** Tout canal que tu utilises doit être **déclaré dans le bloc de métadonnées** (champ `channels`, voir « Métadonnées »). À la validation sur le Cosmos, tes canaux sont inscrits au **registre des canaux** de la plateforme, et à l'exécution une Nova **ne peut accéder qu'aux canaux qu'elle a déclarés** — un canal non déclaré (ou en litige) renvoie `403`, même si l'utilisateur en est membre. Déclare `intent` (`publish` | `consume` | `both`) et, si possible, `dataClass` et `schema` :
```json
"channels": [{ "name": "clients", "desc": "Annuaire partagé", "intent": "both", "dataClass": "internal" }]
```

**Propriété et collisions.** Le **premier** Nova à déclarer un nom en devient le **propriétaire**. Si tu déclares un canal **déjà possédé par un autre Nova** (pour collaborer avec lui), ta déclaration part en **co-usage** et doit être **validée par la plateforme** (le canal est alors marqué « partagé ») avant de fonctionner à l'exécution ; sinon elle reste « en litige » et l'accès est refusé. **Ne squatte pas** le canal d'un autre : choisis un nom clair et spécifique. Les noms `system`, `constellae`, `admin`, `platform` sont **réservés**.

**Découverte.** Pour savoir quels canaux existent déjà, qui les possède et ce qu'ils exposent, consulte le registre public (aucune authentification) : page lisible **`/channels.php`** et JSON machine-lisible **`GET /api/public/channels`** (fiche d'un canal : `/api/public/channels/{nom}`). Au moment de **développer**, ouvre cette URL pour relever le **nom exact** du canal à consommer.

#### GROUPES multi-invités — `window.channel.createGroup()` / `myGroups()` (le bon modèle « SplitMates »)
Une même Nova gère souvent **PLUSIEURS groupes** d'invités différents (les vacances, les colocs, la famille…). Chaque groupe doit avoir ses **propres données cloisonnées**. **Ne mets JAMAIS tous les groupes dans un canal fixe** (`window.channel('splitmates')`) — ils partageraient le même pot. Et **ne demande pas à un admin de créer un canal** : la Nova crée elle-même **un « groupe » (sous-canal isolé) par groupe**, à la demande.
```js
// 0) AU DÉMARRAGE : détecte si l'org autorise les groupes et l'invitation (sans canal) —
//    et masque proprement les boutons correspondants si c'est désactivé.
const caps = await window.channel.capabilities();   // -> { groupChannels, guestAccess }
if (!caps.groupChannels) { /* masque « Créer un groupe » (désactivé par l'org) */ }

// 1) L'utilisateur crée un groupe : la Nova crée un sous-canal NEUF, isolé, dont il est propriétaire.
const g = await window.channel.createGroup('Vacances 2026');   // -> { name, label, code, guestOpen }
const grp = window.channel(g.name);                            // on lit/écrit dedans normalement
await grp.set('depenses', []);

// 2) On partage l'accès aux invités — g.code (si l'accès invité est activé) OU un lien magique :
if (g.code) { /* affiche/partage g.code */ }
const inv = await grp.invite({ email: 'ami@exemple.fr' });     // lien magique (crée le compte invité + rejoint CE groupe)

// 3) Un invité CONNECTÉ (compte Constellae ou « Lite ») rejoint par le code :
const j = await window.channel.joinByCode('K7P2M9QX');         // -> { name, label }
const grp2 = window.channel(j.name);

// 4) Écran « Mes groupes » : liste les groupes de CETTE Nova dont l'utilisateur est membre.
const groups = await window.channel.myGroups();
// [{ name, label, code, guestOpen, role, owner, members, createdAt }]  (code rendu aux écrivains)

// 5) Gérer un groupe (créateur uniquement) :
await grp.renameGroup('Vacances été 2026');
await grp.deleteGroup();     // supprime le groupe ET toutes ses données (irréversible)
```
- **Un groupe = un sous-canal isolé**, créé par la Nova, **propriété de son créateur** (membre écrivain), rejoint **sur invitation**. Sûr par construction : créé **vierge**, il n'accède à **aucune** donnée existante de l'organisation ni d'un autre groupe. Le nom technique (`g.name`) est **aléatoire et non devinable** : l'accès repose sur l'appartenance, jamais sur la connaissance du nom — inutile de le déclarer au registre.
- **Range TOUT l'état partagé du groupe dans son canal** (`window.channel(g.name)`), **jamais** en `window.storage` privé. Utilise `myGroups()` pour l'écran d'accueil (sélecteur de groupe), et masque « renommer / supprimer » aux non-créateurs (`owner === true`).
- **Détecte les autorisations de l'org AVANT de proposer les boutons.** Au démarrage, `const caps = await window.channel.capabilities()` → `{ groupChannels, guestAccess }` (aucun canal requis). Masque « Créer un groupe » si `!caps.groupChannels`, et « Inviter » si `!caps.guestAccess`. Les mêmes drapeaux sont aussi présents sur `(await ch.can())` (`.groupChannels`, `.guestAccess`) quand tu as déjà un canal sous la main.
- **Le code / l'invitation exigent l'accès invité activé** (admin de l'org **et** plateforme). `createGroup()` fonctionne toujours (groupe entre membres de l'org), mais `code` vaut `null` et `ch.invite()` échoue si l'accès invité est désactivé. La création de groupes elle-même peut être désactivée par l'org (rare) : `createGroup()` renvoie alors une erreur claire — d'où l'intérêt de tester `capabilities()` d'abord.
- Le **code est GLOBAL** (unique plateforme) → résout vers **exactement un** groupe. `shareCode({ regenerate:true })` en réémet un (invalide l'ancien).
- **Il faut être connecté** pour rejoindre (saisie du code **après** authentification). Un externe sans compte crée un compte **Lite** (accès limité aux Novas partagées, **sans IA**).

> **Mode invité — dégrade proprement.** `window.user` indiquera `guest`/`lite` pour un invité : n'exige jamais de fonctions réservées (IA, données perso d'un membre) ; propose le parcours « rejoindre avec un code » et un état de bienvenue clair.

À l'**exécution**, ton Nova (bac à sable) ne peut pas appeler cette URL directement — utilise l'API dédiée **`window.channels`** :
```js
const chans = await window.channels.list();   // registre : [{ name, owner, purpose, schema, participants, … }]
const annuaire = chans.find(c => c.owner && /annuaire/i.test(c.name));   // trouve le canal sans coder son nom en dur
const mine = await window.channels.mine();    // canaux dont l'utilisateur est déjà membre
```
Rappel : pour **lire** le canal d'un autre Nova (co-usage), tu dois (1) le **déclarer** dans ton META avec `intent:"consume"` — il devient « participant » après validation —, et (2) l'utilisateur doit en être **membre** (`ch.can().read`). Sans droit ou sans donnée, retombe proprement sur la saisie libre.

**Bonnes pratiques** : appelle `ch.can()` au démarrage et **masque/désactive les actions d'édition** (créer, modifier, supprimer) si `write` est faux — ne te contente pas de gérer l'erreur 403. Le cloisonnement par organisation est garanti côté serveur.

### Recevoir des webhooks (cartes interactives) — endpoint `/api/hooks/{id}`
Une Nova **ne peut pas écouter du HTTP** (pas de serveur dans le bac à sable). Pour recevoir des événements d'un système externe (SIRH, CRM, tickets…), Constellae fournit un **serveur de webhooks générique** qui écrit dans un **canal**. Schéma :
1. Un administrateur crée un **webhook sur un canal** (console → Canaux → « Webhooks entrants ») ; la plateforme fournit une **URL** `/api/hooks/{id}` et un **secret**.
2. Le système externe **POSTe une carte JSON** sur cette URL (authentifiée).
3. Constellae valide, puis **écrit la carte dans le canal** sous une clé préfixée `hook.msg.`.
4. Ton Nova, abonné au canal, la **lit au polling** et l'affiche.
5. À l'action de l'utilisateur (Approuver, Signer, Étape suivante…), ton Nova **met à jour la carte** (visible par tous) et, si un rappel est prévu, **rappelle l'émetteur**.

**Authentification (côté émetteur).** Trois modes au choix à la création :
- `token` : mettre le secret dans le corps `"token":"…"` **ou** l'en-tête `X-Webhook-Token`.
- `hmac` : en-tête `X-Constellae-Signature: sha256=<hex>`, où `<hex>` = HMAC-SHA256 du **corps brut** avec le secret.
- `either` : l'un OU l'autre.

Réponses : `201` accepté (renvoie `messageId`), `401` authentification invalide, `422` charge invalide (ex. `card.title` manquant, ou champ/action refusé par les options), `413` trop volumineux.

**Modèle JSON de la carte** (POST `application/json` sur `/api/hooks/{id}`) :
```json
{
  "token": "LE_SECRET_DU_WEBHOOK",
  "sender": { "name": "SIRH", "icon": "🏢" },
  "card": {
    "color": "#5B34D6",
    "icon": "🗓️",
    "title": "Demande de congé",
    "subtitle": "Ressources humaines",
    "body": "Texte **markdown** optionnel.",
    "image": "https://…/apercu.png",
    "fields": [
      { "label": "Demandeur", "value": "Camille Roy" },
      { "label": "Période",   "value": "12 → 16 août" }
    ],
    "workflow": { "steps": ["Soumis", "Manager", "Validé"], "current": 1 },
    "signaturesRequired": 2,
    "actions": [
      { "id": "approve", "label": "Approuver", "style": "success", "emoji": "✅", "type": "decision", "resolvedLabel": "Approuvé" },
      { "id": "reject",  "label": "Refuser",   "style": "danger",  "emoji": "⛔", "type": "decision", "resolvedLabel": "Refusé" },
      { "id": "sign",    "label": "Signer",    "style": "primary", "type": "signature" },
      { "id": "next",    "label": "Étape +",   "style": "primary", "type": "workflow" },
      { "id": "open",    "label": "Ouvrir",    "style": "link",    "type": "link", "url": "https://…" }
    ],
    "callback_url": "https://votre-systeme/callback"
  }
}
```

**Champs.** `card.title` est **requis**. `color` = `#RRGGBB` ; `icon` = emoji court ; `image` = URL **https**. `fields` : liste `{label, value}` (max 30). `workflow` : `{steps:[…], current}` (barre d'étapes). `signaturesRequired` : nombre de signatures attendues. `callback_url` (https) : rappelé à l'action — **jamais exposé à la Nova**. Champs inconnus ignorés ; longueurs bornées côté serveur.

**Actions.** Liste (max 10) `{id, label, type, style, emoji?, resolvedLabel?, url?}`.
- `type` : `decision` (accord/refus, utilise `resolvedLabel`), `signature`, `workflow` (avance d'une étape), `link` (ouvre `url` https).
- `style` : `primary` | `secondary` | `success` | `danger` | `warning` | `link` | `ghost`.

**Options de validation (à la création du webhook).** `requireFields` (labels obligatoires — sinon 422), `allowActions` (liste blanche d'`id` d'actions), `maxBytes` (taille max). Le bouton **« Tester »** injecte une carte de démonstration pour vérifier l'affichage.

**Côté Nova — LIRE les cartes.** Déclare le canal (obligatoire, cf. section canaux), puis interroge le préfixe `hook.msg.` :
```js
const ch = window.channel('NOM_DU_CANAL');
const { keys } = await ch.list('hook.msg.');            // clés des cartes reçues
for (const k of keys) {
  const { value } = await ch.get(k);                    // value = { id, key, sender, card, status, hasCallback, ... }
  render(value);
}
```
Interroge régulièrement (ex. toutes les 5–10 s) pour détecter les nouvelles cartes. Ne code jamais l'URL de l'endpoint : le canal suffit.

**Côté Nova — RÉPONDRE à une action.** Au clic de l'utilisateur :
```js
// 1) mets à jour la carte pour TOUS les membres (nécessite le rôle Éditeur) :
await ch.set(value.key, { ...value, status: 'resolved', decision: 'approve' });
// 2) si un rappel est prévu, notifie l'émetteur d'origine :
if (value.hasCallback) await ch.callback(value.id, 'approve', 'commentaire optionnel');
```
`ch.callback(messageId, action, note?)` fait rappeler l'`callback_url` par la plateforme : requête **signée** (`X-Constellae-Signature`), vers une **adresse publique https uniquement** (garde SSRF). La Nova ne voit jamais l'URL ; il ne fait AUCUN appel réseau direct.

**Bonnes pratiques cartes** : rends l'affichage joli et responsive à partir des champs (`color`, `icon`, `fields`, `workflow`) et des variables `--brand-*` ; désactive les actions déjà résolues (`status`), gère `signaturesRequired` en comptant les signatures dans `value`, et masque les actions d'édition si `write` est faux. Pour un affichage **instantané** (nouvelles cartes, décisions des collègues), utilise le temps réel ci-dessous plutôt qu'un rafraîchissement périodique.

### Temps réel (mises à jour instantanées) — `channel.subscribe`
Plutôt que de rafraîchir toutes les X secondes, **abonne-toi au canal** : `subscribe(onChange)` appelle `onChange` **dès qu'une donnée du canal change** (mise à jour **sous la seconde**, via long-poll — **aucun WebSocket requis**, rien à installer). Idéal pour un **chat**, la **présence** (« en ligne »), les **indicateurs de frappe**, les **accusés de lecture** et les **cartes de webhook** en direct.
```js
const ch = window.channel('NOM_DU_CANAL');
const stop = ch.subscribe(async ({ initial }) => {
  const { keys } = await ch.list();      // recharge ce qui t'intéresse (ex. 'hook.msg.' pour les cartes)
  // … mets à jour l'affichage
});
// à la fermeture de la vue / démontage du composant :
stop();
```
`onChange({ rev, initial })` : `initial` vaut `true` au tout premier appel (fais-y ta **charge initiale**), puis `false` à chaque changement. **Appelle toujours `stop()`** quand l'utilisateur quitte la vue (ou dans le cleanup d'un `useEffect`) pour libérer la connexion serveur. Le canal reste soumis aux droits (`can()`) et au verrou (la Nova doit avoir déclaré le canal).

### Drives — `window.drive`
```js
const { accounts } = await window.drive.providers();
if (!accounts.length) await window.drive.connect('google'); // ou 'onedrive' / 'dropbox'
const items = await window.drive.list(accountId, dossier);
const blob  = await window.drive.read(accountId, item.id);
await window.drive.write(accountId, cheminOuParent, blob, mime);
// + mkdir/move/copy/delete/search ; deposit('destination', blob, { suggestedName })
```
**Google Drive** = identifiants (pas de chemins) : `'root'` à la racine, on descend via `item.id`. Autres drives : `item.id` = chemin `/dossier/fichier`. Aucun secret n'est exposé.

#### Disque intégré (provider `local`, ex. « Mon Disque »)
Chaque utilisateur dispose d'un **disque intégré, hébergé par la plateforme et cloisonné par client (tenant)** — il n'y a **rien à connecter**, il est toujours présent dans `providers().accounts` avec `provider === 'local'`. Une application Drive (ou toute application) l'utilise **exactement comme les autres comptes** via `window.drive` (adressage par **chemin**, `item.id` = chemin complet).

Sa **racine `'/'` expose trois espaces racine** (selon les droits de l'utilisateur) :
- **`/Perso`** — privé à l'utilisateur (lecture/écriture). C'est ICI qu'on partage des éléments avec d'autres.
- **`/Partagé`** (« Partagé avec moi ») — **vue** des dossiers/fichiers qu'un membre de l'organisation **m'a partagés**. Les éléments y sont adressés par un id opaque (`shared:…`) ; navigue avec `item.id`, n'invente pas de chemin. Mes droits dépendent du partage reçu.
- **`/Entreprise`** — dossiers d'entreprise. Les administrateurs gèrent les droits ; un membre n'y voit que ce qui lui est accordé.

```js
const { accounts } = await window.drive.providers();
const disk = accounts.find((a) => a.provider === 'local');  // « Mon Disque »
const spaces = await window.drive.list(disk.id, '/');        // [Perso, Partagé avec moi, Entreprise]
await window.drive.mkdir(disk.id, '/Perso/Projets');
await window.drive.write(disk.id, '/Perso/notes/todo.md', blob, 'text/markdown');
```
Chaque `DriveItem` porte ses `capabilities` (`canRead`/`canWrite`/`canDelete`/`canShare`) : **respecte-les TOUJOURS** (la plateforme applique les droits ; n'en réimplémente aucun).

#### Partage natif — `window.drive.shares` et `window.drive.links`
Disponible sur le disque intégré, **uniquement si `item.capabilities.canShare === true`**.
```js
// Partager avec un utilisateur de l'organisation (par e-mail/identifiant, ou par id)
await window.drive.shares.grant(disk.id, item.id, { subject: { email: 'collegue@org' }, rights: ['read','write'] });
const grants = await window.drive.shares.list(disk.id, item.id);   // [{ id, name, email, rights:[...] }]
await window.drive.shares.revoke(disk.id, item.id, grants[0].id);
// Lien public (hors organisation) : jeton secret, droits + expiration + mot de passe optionnels
const link = await window.drive.links.create(disk.id, item.id, { rights:['read'], expiresAt:'2026-12-31', password:'' });
// link.url -> à copier/partager ; gère aussi links.list / links.revoke
```
Droits : `read`, `write`, `delete`, `share` (cumulables ; `read` implicite). On ne peut accorder que des droits que l'on possède soi-même. Le cloisonnement par tenant est garanti côté serveur — aucune donnée n'est jamais partagée entre clients. **Recommandation** : présenter le disque intégré en premier, exposer ses trois espaces racine dans le menu, et offrir une action « Partager » respectant `canShare`.

### Actions sur les fichiers — `window.actions`
```js
const intent = await window.actions.input();  // { action, payload } ou null
// payload = { accountId, itemId, name, mime } ; lis via window.drive.read(...)
const actions = await window.actions.list({ mime, name });
await window.actions.invoke(actions[0], { accountId, itemId, name, mime });
```

### S'intégrer à « Constellation » (Constellation) — actions & déclencheurs déclarés

Le module **Constellation** enchaîne des étapes (agents, connecteurs,
Novas…). Pour qu'un flux puisse **piloter** ton Nova ou **réagir** à ce
qu'il produit, déclare tes points d'intégration dans le META, champ
`constellation` :

```
"constellation": {
  "actions":  [
    { "id": "send_message", "label": "Envoyer un message",
      "inputs": [ {"name":"channel","label":"Groupe / destinataire"}, {"name":"text","label":"Message"} ] },
    { "id": "write_webhook", "label": "Écrire vers un webhook",
      "inputs": [ {"name":"url","label":"URL"}, {"name":"body","label":"Contenu"} ] }
  ],
  "triggers": [
    { "id": "file_dropped", "label": "Un document est déposé dans un dossier",
      "outputs": [ {"name":"fileId"}, {"name":"name"} ] }
  ]
}
```

- **Action** = une opération que le flux peut demander à la Nova (« Ouvrir un
  Nova » proposera alors ces actions et leurs champs). **Exécution automatique** :
  quand le flux atteint l'étape, l'hôte monte ton Nova dans un **iframe caché**,
  lui passe l'action + les champs via `window.actions.input()` (`{action, payload}`),
  ton Nova réalise l'opération puis **rend la main au flux** avec
  `await window.actions.complete(result)` — `result` (chaîne) devient la **sortie** de
  l'étape. Pendant l'exécution, tu disposes de tout le bac à sable (`window.channel`,
  `window.storage`, `window.claude`, `window.api`…). Si la Nova n'appelle jamais
  `complete()`, le flux bascule sur une reprise **manuelle** (l'utilisateur ouvre
  la Nova et reporte le résultat). Exemple complet : `examples/constellation-action-demo.tsx`.
- **Déclencheur** = un évènement émis par la Nova (ex. *dépôt de fichier dans
  un dossier surveillé*) qui **démarre un flux**. **Le plus simple** : quand
  l'évènement survient, appelle `await window.constellation.emit('file_dropped',
  { fileId, name })` — la plateforme démarre **tous les flux abonnés** (nœud
  « Démarrer sur évènement » pointant sur ton Nova + cet `id` d'évènement),
  avec `data` comme entrée (`{{input}}`). Ainsi : *fichier déposé →
  `emit('file_dropped', …)` → flux → « Envoyer un e-mail avec la pièce jointe »*.
  Sécurité : ton identité de Nova est prouvée par l'en-tête **non falsifiable
  `X-Artifact-Id`** (posé par le bac à sable) — un flux ne peut donc être démarré
  **qu'au nom de ton Nova réel**, jamais usurpé. Un flux ne réagit qu'aux
  évènements de la Nova **qu'elle a explicitement déclaré** (le nœud « Démarrer
  sur évènement » nomme la Nova + l'`id` d'évènement) : c'est ta liste blanche.
  (Alternative pour un système EXTERNE : le **déclencheur par URL sécurisée** du
  flux — clé secrète en en-tête `Authorization: Bearer`, signature HMAC en option —
  cf. « Déclencheurs à distance » dans le constructeur, ou le **webhook**.)

> Le champ `constellation` est **déclaratif** : il rend tes actions/déclencheurs
> visibles et sélectionnables dans le builder. Côté exécution, appuie-toi sur les
> briques existantes (`window.actions`, `window.channel`, webhooks). Le support
> natif complet dans le builder est déployé progressivement — déclare dès
> maintenant pour être prêt.

#### Répondre à un flux depuis le chat (boutons de décision)

Une étape **« Demander une validation »** d'un flux peut poser sa question **dans un
canal**, sous forme de **carte interactive à boutons** — exactement le format des
cartes **webhook** (clé `hook.msg.…` du canal, champ `card.actions`). Le flux se met
en pause (durable) ; **le clic d'un membre sur un bouton reprend le flux** avec le
choix comme sortie de l'étape (`{{prev}}` pour la suite).

Côté Nova « chat », il suffit donc de **rendre les cartes `hook.msg.`** (titre,
corps, `actions`) et, au clic d'un bouton, d'appeler :
```js
await window.channel(nomDuCanal).callback(carte.id, action.id);   // action.id = bouton cliqué
```
La plateforme reconnaît que la carte provient d'un flux et **reprend le run** (aucune
configuration côté Nova) ; pour une carte webhook classique, ce même appel rappelle
l'émetteur d'origine. Une carte déjà résolue passe en `status:"closed"` avec sa
`resolution` (libellé choisi, auteur) — affiche-la comme telle.

**Destinataires & validation successive.** La carte d'un flux peut porter un objet
`workflow` : `{ mode:"any"|"all", total, done, assignedTo:{id,name}|null, assignedNames:[…] }`.
- `assignedTo` = la personne dont c'est le tour (mode successif) ; compare `assignedTo.id`
  à `window.user.get().id` pour n'activer les boutons **que pour elle** (le serveur refuse
  de toute façon un tiers).
- `mode:"all"` = **validation successive** (chaque destinataire valide à son tour ; `done/total`
  indique la progression). `mode:"any"` = une seule réponse suffit ; `assignedNames` liste les
  personnes autorisées.
- Un membre non concerné voit la carte mais **ne doit pas** pouvoir cliquer (affiche « en
  attente de … »). Réf. complète : `examples/workflow-decision-viewer.tsx`.

### Menu de l'application (barre latérale de l'hôte) — `window.menu`
Si l'application a **plusieurs sections**, déclare son menu : il s'affiche dans la barre latérale gauche de Constellae, sous « Application active ».
```js
window.menu.set([
  { id: 'dashboard', label: 'Tableau de bord', icon: '▣' },
  { id: 'clients',   label: 'Clients',         icon: '☷' },
  { id: 'reports',   label: 'Rapports',        icon: '▤' },
], 'dashboard');
window.menu.onSelect((id) => afficherSection(id));  // l'utilisateur a cliqué une entrée
window.menu.active('clients');                       // refléter la section courante
```
Appelle `window.menu.set(...)` au démarrage. Sans menu déclaré, la zone reste vide (rien ne casse).

**Sous-menus (un niveau).** Une entrée peut contenir des `children` : elle devient un **groupe repliable** ; cliquer l'en-tête déplie/replie, cliquer un enfant déclenche `onSelect(idEnfant)`. Un groupe s'ouvre automatiquement si l'un de ses enfants est actif.
```js
window.menu.set([
  { id: 'dashboard', label: 'Tableau de bord', icon: '▣' },
  { id: 'sales', label: 'Ventes', icon: '☰', children: [
    { id: 'sales.leads',   label: 'Prospects' },
    { id: 'sales.quotes',  label: 'Devis', badge: 4 },
  ] },
], 'dashboard');
```

**Badge.** Tout item accepte `badge` (nombre) : une pastille s'affiche à droite (0 ou absent = pas de pastille).

**Pied de menu ÉPINGLÉ.** Un pied reste **collé en bas** du bloc de menu (au-dessus du Cosmos) et ne défile jamais avec la liste — idéal pour « Paramètres », « Intégrations »… Deux voies équivalentes :
```js
// via un 3e argument d'options sur set()
window.menu.set(items, activeId, {
  footer: [
    { id: '__integrations__', label: 'Intégrations', icon: '🔔', badge: 3 },
    { id: '__settings__',     label: 'Paramètres',   icon: '⚙' },
  ],
});
// ou via une méthode dédiée
window.menu.setFooter([{ id: '__settings__', label: 'Paramètres', icon: '⚙' }]);
window.menu.clearFooter();
```
Les entrées du pied sont des items **plats** `{id, label, icon, badge?}`. Leurs clics passent par le **même `onSelect(id)`** ; `active(id)` peut surligner un item du pied. `clear()` efface le menu **et** le pied. Rétrocompatibilité totale : sans `footer`, le comportement actuel est strictement inchangé.

> **Pas de DOUBLE navigation.** Si tu utilises `window.menu` pour la navigation principale (onglets dans la barre latérale de l'hôte), **ne remets PAS** d'onglets/bottom-nav équivalents dans le corps de la Nova — c'est redondant et déroutant. Choisis **l'un OU l'autre** : soit la navigation principale via `window.menu`, soit des onglets internes (souvent préférables en mobile-first) — et dans ce dernier cas, réserve `window.menu` au **pied** (Aide, Paramètres, Retour).

### Nova AUTONOME (PWA installable, hors application Constellae)
Une Nova peut être **partagée par une URL dédiée** et **installée sur l'écran d'accueil** d'un téléphone comme une **application autonome** (sa **propre icône**, lancement direct **plein écran**, sans la coquille Constellae). Constellae fournit automatiquement le **manifeste PWA** (nom = son titre, couleur de thème = `--brand-primary`) et le service worker. **Ta responsabilité : concevoir la Nova pour qu'elle se suffise à elle-même dans ce mode.**
- **Icône PWA dédiée (IMPORTANT).** Fournis une **icône CARRÉE** (idéalement 512×512 PNG, fond plein, marge de sécurité ~10 %) : c'est elle qui s'affiche sur l'écran d'accueil. À défaut, l'**image de fiche** (souvent une bannière large) sert d'icône — rendu médiocre. Trois voies pour la renseigner : (1) **fenêtre d'upload d'une Nova personnelle** → champ dédié « **Icône d'application (PWA)** » (glisser-déposer, aperçu carré) ; (2) **assistant de dépôt au Cosmos** → zone « **Icône carrée (écran d'accueil / PWA)** » — à l'installation, cette icône est reprise automatiquement comme icône PWA de l'application installée ; (3) **import MCP** → joins-la comme visuel `kind:"icon"` (carré). Elle est enregistrée **séparément** de l'image de carte et utilisée par le manifeste. La Fiche indique « Application autonome (PWA) : Oui » quand une icône dédiée est présente.
- **Navigation embarquée.** En autonome, la barre latérale de Constellae **n'est pas là**. Prévois **ta propre navigation dans le corps** de la Nova (onglets / bottom-nav / menu interne). Ne dépends pas de `window.menu` pour les écrans essentiels ; utilise-le seulement comme complément quand la Nova tourne **dans** Constellae (cf. « Pas de DOUBLE navigation »).
- **En-tête & identité.** Affiche **ton titre et ton icône** en tête (l'utilisateur autonome ne voit pas le logo Constellae). Prévois un bouton **Aide** et un accès **Paramètres/Compte** accessibles depuis l'interface.
- **Plein écran & zones sûres.** Vise le mobile plein écran : respecte les **safe-areas** iOS (`env(safe-area-inset-*)`), rien de collé au bord haut, cibles tactiles ≥ 44 px.
- **Mode invité.** En autonome, l'utilisateur est souvent un **invité** (compte Lite) : détecte `window.user` (`guest`/`lite`), **dégrade proprement** (pas d'IA, pas de données d'un autre membre), propose le parcours « rejoindre avec un code », et un **écran de bienvenue** clair.
- **Tolérance hors-ligne (best-effort).** Gère les erreurs réseau sans page blanche ; un état « hors connexion, réessayez » vaut mieux qu'un crash.

### Utilisateur courant — `window.user`
Récupère le profil de l'utilisateur **connecté** (lecture seule, jamais celui d'autrui) :
```js
const me = await window.user.get();
// { id, email, name, firstName, lastName, phone, avatarUrl, guest, lite }
if (await window.user.isGuest()) { /* invité (Lite) : masque IA / fonctions premium */ }
```
`phone` et `avatarUrl` peuvent être vides s'ils ne sont pas renseignés. **`guest`/`lite`** valent `true` pour un **invité** (compte Lite) : dans ce cas les ponts `window.claude` (IA), `window.db` partagé, `window.config`, `window.vault`, `window.store`, `window.drive`, `window.actions` et `window.constellation` **sont refusés** (côté hôte ET serveur) — dégrade proprement (utilise `window.user.isGuest()`), sans jamais exiger ces capacités. Un invité conserve : ses **canaux** (`window.channel`), son **stockage/fichiers privés** (`window.storage`, `window.files`) et son **profil**. **Annuaire / chat — motif « présence »** : comme tu n'accèdes qu'à TON profil, pour afficher les coordonnées des autres participants, chaque client **écrit son propre profil dans un canal partagé** (ex. clé `profil:<id>`) et **lit** ceux des autres ; combine avec `channel.subscribe` pour un annuaire en temps réel.

### Standards EXIGÉS pour toute application
Ces éléments sont **obligatoires** (contrôlés à la validation Cosmos) :
1. **Versionnement sémantique déclaré dans le META** — champ `version` en `MAJEUR.MINEUR.CORRECTIF` (ex. `"1.0.0"`). Incrémente : **MAJEUR** (V1→V2) pour une évolution majeure, **MINEUR** (V1.0→V1.1) pour un ajout, **CORRECTIF** (V1.0.0→V1.0.1) pour un bug. La version chargée au catalogue **est** celle du META (source de vérité).
2. **Historique des notes de version** — champ `releaseNotes` dans le META : liste `{ version, date, notes:[...] }` (comme le changelog de la plateforme). Il est **affiché dans la fiche du Cosmos**. Ajoute une entrée à **chaque** nouvelle version.
3. **Parcours découverte au premier lancement** — un « tour d'horizon » guidé qui présente les fonctionnalités (déclenché à la 1re utilisation, rejouable ; mémorise l'état vu via `window.storage`).
4. **Section « Aide »** — récapitulatif des fonctionnalités, astuces et raccourcis, accessible à tout moment (idéalement depuis le **pied de menu** `window.menu.setFooter`).

Ces champs vont dans le **bloc de métadonnées JSON** (`ARTIFACT-RUNNER-META … ARTIFACT-RUNNER-META-END`), lu automatiquement au dépôt/màj sur le Cosmos. La version affichée au catalogue **est** celle du META ; l'historique est rendu dans la fiche.

### Où pousser la Nova ? — QUESTION OBLIGATOIRE à l'utilisateur
Avant tout push MCP, **DEMANDE EXPLICITEMENT à l'utilisateur la destination** (ne devine JAMAIS) parmi ces trois choix :
1. **Nova Studio** *(par défaut, recommandé)* — pour **modifier / tester / itérer** la Nova dans Constellae avant toute installation. → outil **`nova_import`** (création), puis **`nova_import` avec `draftId`** pour chaque mise à jour.
2. **Ma Galaxie** — installation **pour l'utilisateur uniquement** (usage perso). Une Nova arrive dans Ma Galaxie **depuis le Nova Studio** (bouton « Enregistrer dans Ma Galaxie »). Via MCP, on ne met à jour une Nova personnelle **déjà enregistrée** qu'avec `update_artifact` `type:"personal"`.
3. **Cosmos (Store)** — publication pour toute l'organisation, **UNIQUEMENT si l'utilisateur est développeur ou super-admin**. Passe d'abord par le Nova Studio, puis publie ; une mise à jour d'une fiche **déjà publiée** se fait avec `update_artifact` `type:"appstore"`.

**Règle d'or :** à la **première génération**, pousse dans **Nova Studio** (`nova_import`) — c'est là que l'utilisateur teste, modifie, puis choisit lui-même d'installer dans Ma Galaxie ou de publier au Cosmos. **Ne pousse JAMAIS directement dans Ma Galaxie ou au Cosmos sans que l'utilisateur l'ait explicitement demandé.** Si tu n'es pas certain que l'utilisateur est développeur/super-admin, propose Nova Studio ou Ma Galaxie et signale que le Cosmos requiert un compte développeur.

### Publier une Nova via le MCP (`nova_import`) — EXIGENCES
Quand un assistant (Claude ou un agent) pousse une Nova vers le **Nova Studio** via l'outil MCP `nova_import`, il **DOIT** à **chaque** génération :
1. **Résumé structuré en tête de conversation** — fournir `originalRequest` (la demande initiale de l'utilisateur) **et** `buildSummary` : un résumé clair de **à quoi ça sert**, les **fonctionnalités**, **comment l'utiliser**, les **entités** (données) et les **capacités** `window.*` employées. Ils ouvrent le fil du Studio (le chat ne doit **jamais** être vide).
2. **Bloc « À propos » dans l'Aide de la Nova** — la **Section « Aide »** de la Nova (voir standard 4 ci-dessus) doit contenir un bloc « À propos » indiquant le **LLM utilisé**, le **fournisseur**, la **date d'import**, la **version** et le **draft ID**. `nova_import` renvoie un champ **`about`** (Markdown prêt à coller) et un objet **`provenance`** ; reprends-les dans le code.
3. **`conversation` systématiquement renseignée** — au minimum la demande originale puis le résumé technique de ce qui a été construit (rôles `user` / `assistant`). Ne jamais importer un code « nu » sans conversation.
4. **`releaseNotes` avec mention du LLM** — à chaque version (dépôt initial et `update_artifact`), ajoute une entrée `releaseNotes` citant le **LLM** qui a produit la version.
5. **Provenance = paramètres, pas des `{{variables}}`** — renseigne **`llm`** (ex. `"Claude Opus 4.5"`) et **`provider`** (ex. `"Anthropic"`). La **date d'import**, le **draftId** et la **version plateforme** sont ajoutés **par Constellae** (valeurs fiables, non falsifiables) et renvoyés dans `provenance` / `about`.

> **Repush / mise à jour d'un brouillon.** Pour repousser une Nova après modification, rappelle **`nova_import` avec le `draftId`** renvoyé au premier import : le brouillon est mis à jour **EN PLACE** (code remplacé, visuels ajoutés, fil de conversation complété par la nouvelle demande + résumé). **N'utilise PAS `update_artifact`** pour un brouillon Nova Studio — `update_artifact` ne concerne QUE les applications **déjà publiées** au Cosmos (ou les artefacts personnels **déjà enregistrés** dans Ma Galaxie), pas un brouillon en cours de développement.

> **Convention évolutive — reste à jour.** Cette Convention porte une `contractVersion` (contrat machine) qui augmente quand les règles/capacités changent. Elle est **toujours servie à jour** (`get_convention` / `get_contract` / `platform_version` en MCP, ou `/api/public/convention`). Si tu développes une Nova sur plusieurs sessions : `list_my_artifacts` marque chaque application d'un `conventionOutdated` (et d'un `contractVersion`) quand la Convention a évolué depuis sa dernière version, et `update_artifact` renvoie `conventionChanged` le cas échéant. Dans ces cas, **récupère à nouveau `get_convention`** et adapte le code aux nouvelles règles avant de poursuivre. (Dans le Nova Studio, l'utilisateur en est informé par un bandeau et peut mettre à jour sa Nova en un clic ; les générations suivantes tiennent déjà compte de la nouvelle Convention.)
```jsonc
/* ARTIFACT-RUNNER-META
{
  "title": "Chat d'équipe",
  "version": "1.2.0",
  "releaseNotes": [
    { "version": "1.2.0", "date": "2026-07-02", "notes": ["Annuaire des membres", "Parcours découverte"] },
    { "version": "1.1.0", "date": "2026-06-20", "notes": ["Cartes de webhook"] },
    { "version": "1.0.0", "date": "2026-06-01", "notes": ["Version initiale"] }
  ]
}
ARTIFACT-RUNNER-META-END */
```

### Internationalisation (i18n) — `window.i18n`
Ton application DOIT être **multilingue** (norme i18n). N'écris **jamais** de texte visible en dur : regroupe tes libellés et choisis la bonne langue avec `window.i18n`.
- `window.i18n.locale` → la langue de l'utilisateur : `'fr' | 'en' | 'es' | 'de' | 'it'`.
- `window.i18n.t(dict)` → renvoie la valeur de `dict` pour la locale (repli : locale exacte → langue 2 lettres → `fr` → `en` → 1re clé).
- `window.i18n.onChange(cb)` → appelé si l'utilisateur change de langue (re-rends ton UI).

Fournis au minimum `fr` et `en` (idéalement aussi `es`, `de`, `it`).
```js
const L = (fr, en, es, de, it) => window.i18n.t({ fr, en, es, de, it });
// Exemple inline :
<h1>{ window.i18n.t({ fr: 'Bonjour', en: 'Hello', es: 'Hola', de: 'Hallo', it: 'Ciao' }) }</h1>
// Ou un dictionnaire centralisé :
const T = {
  title:  { fr: 'Mes tâches', en: 'My tasks', es: 'Mis tareas', de: 'Meine Aufgaben', it: 'Le mie attività' },
  add:    { fr: 'Ajouter',    en: 'Add',      es: 'Añadir',     de: 'Hinzufügen',     it: 'Aggiungi' },
};
function tr(k) { return window.i18n.t(T[k]); }
// Réagir au changement de langue (React) :
useEffect(() => { window.i18n.onChange(() => forceUpdate()); }, []);
```
Pense aussi aux formats locaux (dates, nombres, monnaies) via `Intl` en passant `window.i18n.locale` :
`new Intl.DateTimeFormat(window.i18n.locale).format(date)`.

### Widgets (tuiles de la page d'accueil) — `export function Widget`
Une application peut proposer des **widgets** : de petites tuiles que l'utilisateur épingle sur sa page d'accueil (« Accueil ») pour composer son tableau de bord. **Tout vit dans le MÊME fichier que l'application** — aucun fichier séparé.

Deux choses à fournir :
1. **Déclare** les widgets dans le bloc de métadonnées, champ `widgets` (liste). Chaque widget : `id` (identifiant court, requis), `name` (titre affiché), `description` (facultatif), `size` (`small` | `medium` | `large`, défaut `medium`), `default` (`true` pour l'afficher d'office sur un accueil vierge).

**Tailles standardisées (grille d'unités).** La page d'accueil est une **grille d'unités carrées de référence** (1 unité ≈ **168 × 168 px**). Une tuile occupe **toujours un nombre ENTIER d'unités** — jamais de demi-unité. Trois tailles seulement, choisies via `size` :

| `size`   | Largeur × Hauteur (unités) | Pixels indicatifs | Usage |
|----------|----------------------------|-------------------|-------|
| `small`  | 1 × 1                      | 168 × 168         | Une donnée clé (un nombre, une jauge) |
| `medium` | 2 × 1                      | 352 × 168         | Donnée + petit visuel, ligne de stats |
| `large`  | 2 × 2                      | 352 × 352         | Liste courte, mini-graphique |

Conçois le contenu pour qu'il **remplisse exactement** la tuile à ces dimensions (hauteur comprise) : pas de hauteur fixe en dur, laisse le contenu s'étirer à 100 % de la tuile (`height: 100%`). L'en-tête (titre) est ajouté automatiquement par l'hôte au-dessus de ton rendu.
2. **Exporte** un composant `Widget` qui affiche le bon widget selon l'`id` reçu :
```jsx
export function Widget({ id }) {
  if (id === 'agenda')   return <AgendaWidget />;
  if (id === 'compteur') return <CompteurWidget />;
  return null;
}
export default function App() { /* l'application complète */ }
```
Règles widgets :
- Le composant `Widget` est monté **dans une tuile compacte** : conçois un rendu sobre, sans navigation lourde, qui se suffit en un coup d'œil. Adapte-toi à la taille (`small` ≈ une donnée clé, `large` ≈ un petit graphique ou une liste).
- Un widget **partage les données de l'application** (même `window.storage`, mêmes `window.channel`, mêmes permissions) : il lit/affiche, idéalement en lecture seule, ce que l'application produit.
- Le widget doit fonctionner **seul** (l'utilisateur peut l'épingler sans ouvrir l'application). Recharge tes données au montage.
- Utilise les variables `--brand-*` comme partout. Évite de fixer une hauteur : la tuile gère sa taille.
- Tout est **optionnel** : sans `widgets` dans les métadonnées ni export `Widget`, l'application fonctionne normalement (simplement aucun widget proposé).

### Services externes (API tierces) — `window.api`
```js
const r = await window.api('meteo', { method: 'GET', path: '/v1/forecast', query: { lat, lon } });
// r = { status, body }
```
Déclare le **besoin** (`'meteo'`, `'stripe'`, `'supabase'`…). La clé est injectée **côté serveur** (jamais exposée) ; tu ne manipules **jamais** de clé. Selon le réglage de l'admin d'org, la clé employée est celle de la plateforme, une clé unique d'organisation, ou **une clé propre à chaque utilisateur** (chacun la sienne) — de ton côté rien ne change : tu appelles seulement `window.api(besoin, …)`. Si un utilisateur n'a pas encore configuré sa clé personnelle, l'appel renvoie une erreur explicite ; prévois un message invitant à la configurer. Convient à toute API HTTP, dont **Supabase/PostgREST** (`path: '/rest/v1/…'`). Le SQL brut (Postgres/MySQL en direct) n'est pas accessible depuis le navigateur.

---

## 5) Palette de marque (à utiliser tel quel)

```css
:root {
  --brand-primary: #5B28F5;
  --brand-primary-contrast: #FFFFFF;
  --brand-accent: #6E3DFF;
  --brand-bg: #F0F0F4;
  --brand-surface: #FFFFFF;
  --brand-text: #27272B;
  --brand-text-dim: #4F4F56;
  --brand-border: #E2E2E9;
  --brand-success: #008800;
  --brand-danger: #E71616;
  --brand-warning: #CE4600;
  --brand-radius: 2px;
  --brand-font: system-ui, sans-serif;
}
```
Exemple d'usage :
```css
.card { background: var(--brand-surface, #fff); color: var(--brand-text, #27272b);
        border: 1px solid var(--brand-border, #e2e2e9); border-radius: var(--brand-radius, 2px);
        font-family: var(--brand-font, system-ui, sans-serif); }
.btn  { background: var(--brand-primary, #00f); color: var(--brand-primary-contrast, #fff); }
```

---

## 6) Manifeste à exposer

```json
{
  "key": "mon-artifact", "name": "Mon artifact", "version": "1.0.0",
  "description": "Ce que fait l'artifact en une phrase.",
  "capabilities": ["storage", "claude"],
  "requirements": { "drive": false, "apiNeeds": [] },
  "providesActions": []
}
```

---

## 7) Référence machine (API)

```json
{
    "contractVersion": "5",
    "platform": "Constellae",
    "platformVersion": "3.249.0",
    "sandboxApi": {
        "window.storage": "get(key)->{value}, set(key,value), delete(key), list() — clé/valeur par utilisateur (remplace localStorage). Passez true en dernier argument pour la portée PARTAGÉE (toute l'org).",
        "window.files": "upload(file)->{id,name,mime,size}, url(id), list(), delete(id), save(id) (télécharge).",
        "window.db": "await db=open(nom[,{shared:true}]) puis db.run(sql,params), db.query(sql,params)->lignes, db.exec(sql), await db.save() — vraie base SQLite portable (sql.js WASM) tournant DANS le bac à sable. Par défaut persistée PAR UTILISATEUR (via window.files). { shared:true } = base COMMUNE À L'ORG : versionnage optimiste côté serveur — si une autre écriture a eu lieu depuis open(), save() lève une erreur (err.code==='DB_CONFLICT') ; rouvrez et rejouez. Pour de simples clés/valeurs, préférez window.storage.",
        "window.download": "download(blob) ou download({name,mime,data}) — vrai téléchargement (le sandbox ne le peut pas seul).",
        "window.claude": "complete(prompt,{model?})->texte, messages({messages,model?}) — IA (clé côté serveur).",
        "window.channel": "channel(nom).can()->{read,write,admin,exists,guestAccess} (exists=false : canal pas encore créé par l'admin — affiche un message adapté), get/set/delete/list, .files.upload/list/url/download, .subscribe(onChange)->stop (temps réel, mises à jour sous la seconde), .callback(messageId,action,note?) (rappel d'un webhook), .shareCode({regenerate?})->{code} (ouvre le canal-groupe aux invités par un code GLOBAL de 8 car.), .invite({email?})->{url,token} (LIEN MAGIQUE d'invitation d'un externe, rejoint ce groupe auto ; accès invité SUR INVITATION), .renameGroup(label)/.deleteGroup() (créateur du groupe) ; window.channel.createGroup(label)->{name,label,code,guestOpen} (crée un GROUPE = sous-canal ISOLÉ pour une Nova multi-groupes, ex. SplitMates), window.channel.myGroups()->[{name,label,code,guestOpen,role,owner,members}] (groupes de cette Nova), window.channel.capabilities()->{groupChannels,guestAccess} (autorisations de l'org, SANS canal — à tester au démarrage pour afficher/masquer « Créer un groupe » et « Inviter ») et window.channel.joinByCode(code)->{name} (rejoindre un groupe par code) — base partagée entre membres d'une org (rôles Lecteur/Éditeur gérés par l'admin) ; sert aussi de boîte de réception des webhooks (clés « hook.msg. », voir « Recevoir des webhooks »).",
        "window.drive": "providers() (inclut le disque intégré provider:\"local\" : /Perso, /Partagé (= partagé avec moi), /Entreprise), connect(\"google\"|\"onedrive\"|\"dropbox\"), list/read/write/mkdir/move/copy/delete/search, shares.list/grant/revoke, links.create/list/revoke (partage natif).",
        "window.actions": "input()->{action,payload}, list({mime,name}), invoke(action,payload), complete(result) (rend la main à un flux Constellation).",
        "window.api": "api(besoin,{method,path,query,body,headers})->{status,body} — service externe (clé injectée côté serveur, jamais exposée). Selon le réglage de l'admin d'org, la clé est celle de la plateforme, une clé unique d'org (BYOK), ou une clé PROPRE À CHAQUE UTILISATEUR. Convient aux API REST/HTTP, dont Supabase/PostgREST (besoin « supabase », path « /rest/v1/… »). Le SQL brut (Postgres/MySQL TCP) n'est pas accessible depuis le navigateur.",
        "window.menu": "set([{id,label,icon,badge?,children?}], activeId?, {footer?}) , setFooter([...]), clearFooter(), active(id), clear(), onSelect(id=>...) — menu latéral de l'hôte : sous-menus (children, 1 niveau), badges, et PIED ÉPINGLÉ (footer) collé en bas au-dessus de l'Cosmos ; les clics du pied et des sous-menus passent par le même onSelect.",
        "window.i18n": "locale ('fr'|'en'|'es'|'de'|'it'), t({fr:'…',en:'…'})->texte selon la langue de l'utilisateur, onChange(cb) — internationalisation (i18n). Rends ton application MULTILANGUE : ne code jamais le texte en dur, fournis tes traductions et choisis avec window.i18n.",
        "window.user": "get()->{ id, email, name, firstName, lastName, phone, avatarUrl, guest, lite } et isGuest()->bool — profil de l'utilisateur COURANT uniquement (lecture seule). guest/lite=true pour un INVITÉ (compte Lite) : IA + window.db partagé/config/vault/store/drive/actions/constellation REFUSÉS ; dégrade via isGuest(). Pour un annuaire/chat, motif « présence » : chaque client écrit SON profil dans un canal partagé, et lit ceux des autres.",
        "window.channels": "list()->[{name,owner,purpose,schema,participants,…}] (registre public des canaux — « swagger »), mine()->[{name,label,dataClass}] (canaux dont l'utilisateur est membre) — découverte des canaux à l'exécution, pour le co-usage sans coder les noms en dur.",
        "window.constellation": "emit(triggerId, data) — émet un ÉVÈNEMENT qui démarre tous les flux Constellation abonnés à CET Nova (nœud « Démarrer sur évènement »). data devient l'entrée du flux ({{input}}). Émets avec parcimonie (un seul emit par évènement réellement nouveau, évite les boucles).",
        "window.config": "get()->{ org, user, effective } (config ORG + perso de l'utilisateur, fusionnées), setUser(patch) (préférences de l'utilisateur COURANT), setOrg(patch) (config pour TOUTE l'org — 403 si l'utilisateur n'est pas admin de l'org ni admin délégué de la Nova). Config PROFONDE et cloisonnée par Nova.",
        "window.vault": "list(prefix,{shared}), read(path,{shared})->Blob, write(path,blob,{shared}), delete(path,{shared}) — disque PRIVÉ de la Nova sur le stockage interne. shared:false = espace de la Nova PROPRE À CHAQUE UTILISATEUR ; shared:true = espace de la Nova COMMUN À L'ORG. Accessible à l'ARTEFACT uniquement : l'utilisateur ne peut PAS parcourir ces données depuis « Mon Disque » (anti-fuite).",
        "window.store": "owns(slug)->bool, get(slug)->données|null (charge utile SEULEMENT si l'org possède le Halo), list()->[{slug,kind,title}] — Halos (compléments) POSSÉDÉS par l'organisation courante. Sers-toi-en pour déverrouiller une fonction (owns) ou charger un contenu premium/thème/jeu de données (get). Cloisonné par organisation ; l'org acquiert les Halos dans le Cosmos."
    },
    "palette": {
        "primary": "#5B28F5",
        "primary-contrast": "#FFFFFF",
        "accent": "#6E3DFF",
        "bg": "#F0F0F4",
        "surface": "#FFFFFF",
        "text": "#27272B",
        "text-dim": "#4F4F56",
        "border": "#E2E2E9",
        "success": "#008800",
        "danger": "#E71616",
        "warning": "#CE4600",
        "radius": "2px",
        "font": "system-ui, sans-serif"
    },
    "cssVariables": {
        "--brand-primary": "#5B28F5",
        "--brand-primary-contrast": "#FFFFFF",
        "--brand-accent": "#6E3DFF",
        "--brand-bg": "#F0F0F4",
        "--brand-surface": "#FFFFFF",
        "--brand-text": "#27272B",
        "--brand-text-dim": "#4F4F56",
        "--brand-border": "#E2E2E9",
        "--brand-success": "#008800",
        "--brand-danger": "#E71616",
        "--brand-warning": "#CE4600",
        "--brand-radius": "2px",
        "--brand-font": "system-ui, sans-serif"
    }
}
```