# Le runtime — defineApp

> Comment votre code hébergé s'exécute : `defineApp({ pages, widgets, panels, forms, actions, events, schedules, http })`, le contexte `ctx`, le protocole d'interaction (rendu → Kit, action → effets), les limites et le journal.
> Catégorie : Référence · mis à jour le 2026-09-04 · plateforme v1

## Le principe

Une app Capibara fonctionne **comme un bot Discord** : la plateforme lui
envoie des interactions (ouverture d'une page, clic, soumission d'un
formulaire, événement métier, planification, requête HTTP d'un tiers) ; votre
code répond par une **description d'écran** (le [Kit](/dev/docs/kit)) ou par
des **effets** (toast, navigation, rafraîchissement…). Vous n'hébergez rien :
le code déclaré par `main` est bundlé à la publication et tourne dans la
sandbox Capibara (workerd sous gVisor), sans Node.js, sans réseau libre, sans
secret. Sa seule porte vers le monde est le `ctx` reçu par chaque handler.

```ts
import { defineApp, ui, toast } from '@capibara-dev/sdk';

export default defineApp({
  pages: {
    home: {
      async render({ ctx, viewer }) {
        const row = await ctx.kv.get('greeting');
        return ui.doc(ui.stack({ gap: 'md' }, [
          ui.pageHeader({ title: 'Bonjour ' + (viewer?.name ?? '') }),
          ui.text({ markdown: String(row?.value ?? 'Aucun message.') }),
          ui.form({ name: 'greet', submit: ui.button({ label: 'Enregistrer', action: 'save', variant: 'primary' }) }, [
            ui.field({ kind: 'text', name: 'value', label: 'Message', required: true }),
          ]),
        ]), { title: 'Accueil' });
      },
    },
  },
  actions: {
    save: async ({ ctx, form }) => {
      await ctx.kv.put('greeting', form?.value);
      return { ...toast('Enregistré.', 'success'), refresh: true };
    },
  },
});
```

Le module `@capibara-dev/sdk` est **fourni par la plateforme** dans la
sandbox (il n'est jamais lu dans votre dépôt) ; le paquet npm du même nom
expose les mêmes signatures pour le typage et vos tests locaux.

## Les familles de handlers

| Clé de `defineApp` | Déclaration dans le manifeste | Ce que reçoit le handler | Ce qu'il renvoie |
|---|---|---|---|
| `pages` | `contributes.menuEntries[].view` | `{ ctx, params, viewer, state }` | un nœud du Kit ou `ui.doc(…)` |
| `widgets` | `contributes.widgets[]` avec `kind: "kit"` | idem (config du widget dans `ctx.install.config`) | idem (surface `widget`, ≤ 200 composants) |
| `panels` | `contributes.panels[]` (`subject: "party"`) | idem + `subject: { type, id }` = la fiche affichée | idem (surface `panel`) |
| `forms` | aucune (ouverts par une action `openForm`) | idem + `params` de l'ouverture | idem (surface `form`, rendu en modal) |
| `siteBlocks` | `contributes.siteBlocks[]` + capability `ui.block` | `{ ctx, params, visitor, state }` — `params` = champs du bloc remplis par l'auteur du site, `visitor` = membre du site ou `null`, `viewer` = `null` | idem (surface `siteBlock`, ≤ 200 composants) |
| `sitePages` | `contributes.sitePages[]` + capability `ui.site` | idem — `params.path` (sous-chemin), `params.query` (paramètres d'URL) | idem (surface `sitePage`, ≤ 500 composants) |
| `actions` | aucune (les boutons du Kit les nomment) | `{ ctx, action, params, form, formName, state, viewer }` | des **effets** (ci-dessous) |
| `events` | `events: ["invoice.paid", …]` + capability `events.subscribe` | `{ ctx, event: { id, type, occurredAt, data } }` | rien (idempotent : un même événement peut être rejoué) |
| `schedules` | `schedules: [{ key, cron }]` | `{ ctx, key }` | rien |
| `http` | `http: ["/stripe-hook"]` | `{ ctx, method, path, query, headers, body, json() }` | `{ status?, json? }` ou `{ status?, body? }` |

Un handler de surface peut être une fonction ou un objet `{ render }`. Une
page, un widget ou un panneau **doit être déclaré dans le manifeste** pour
être rendu (c'est ce que le tenant voit au consentement) ; les formulaires
sont libres.

## Le contexte `ctx`

| `ctx.…` | Contenu |
|---|---|
| `tenant` | `{ id, slug, name, plan, locale: 'fr' }` |
| `viewer` | `{ userId, name, permissions[] }` — **absent** (`null`) en contexte app (événement, planification, HTTP) et sur les surfaces du site public. `permissions` = droits accordés à l'installation **∩** droits RBAC de la personne. |
| `visitor` | surfaces du site public (`siteBlock`/`sitePage`) : `{ memberId, name }` du membre du site connecté (jamais son e-mail), sinon `null`. `null` aussi hors du site. |
| `install` | `{ id, version, config }` — `config` = [réglages déclarés](/dev/docs/donnees) (`settings`, secrets déchiffrés pour votre code seul) + champs de config des widgets. |
| `subject` | `{ type: 'party', id }` pour un panneau, sinon `null`. |
| `state` | l'état opaque renvoyé par votre précédent rendu/action (≤ 8 Ko, signé par la plateforme). |
| `kv` (alias `storage`) | `get / put / delete / list` — clé/valeur cloisonné (votre app × l'organisation), capabilities `storage.tenant.ro/rw`. |
| `data.<collection>` | `get / set / query / count / delete` sur les [collections déclarées](/dev/docs/donnees) au manifeste (typées, validées, quotas par formule) — capability `data.collections`. |
| `files` | `put / get / url / list / delete` — [fichiers privés](/dev/docs/donnees) de l'app (≤ 5 Mo, route gardée) — capability `files.private`. |
| `mail.send` | e-mail transactionnel en blocs à des personnes LIÉES à l'organisation (≤ 5, quota par jour) — capability `mail.send`, cf. [Moteurs](/dev/docs/moteurs). |
| `notify.user / permission / admins` | notification dans Capibara (cloche + push, quota par jour) — capability `notify.send`. |
| `calendar.project / remove / clear` | dates de l'app dans le calendrier de l'organisation (source `app:<clé>`) — capability `calendar.project`. |
| `search.index / remove / clear` | fiches de l'app dans la recherche ⌘K — capability `search.index`. |
| `approvals.request / get / cancel` | demande de validation à un humain, décision par l'événement `approval.decided` — capability `approvals.request`. |
| `fetch(url, init?)` | HTTP sortant vers les hosts déclarés `http.fetch:<host>` **uniquement** ; réponse `{ status, headers, bodyBase64, truncated }`. |
| `log.info / warn / error` | journal d'app (200 lignes × 500 caractères par interaction), visible du développeur et de l'admin du tenant. |
| `followup(result)` | après une réponse `{ ack: true }` : pousse le complément (≤ 15 minutes). |

## Les façades des modules — `ctx.capibara`

Lire et écrire dans les modules de l'organisation passe par les
[façades](/dev/docs/facades) : `await ctx.capibara.crm.contacts.list({ q })`,
`ctx.capibara.billing.documents.createDraft({ … })`, etc. Chaque façade exige
un [scope feuille](/dev/docs/permissions) déclaré ET accordé, et s'exécute sous
la personne qui interagit (pages, actions) ou sous le compte de service de
votre app (événements, planifications, HTTP) — droits, journal et notifications
de Capibara compris. Pas de requête libre : le registre est le contrat.

## Le protocole d'interaction

**Rendu** — votre handler renvoie un nœud (`ui.stack(…)`) ou un document
`{ kit: 1, ui, title?, data?, components?, state? }`. Les gabarits
(`{{ chemin }}`, `each`, `$if`, `$ref`) sont remplis avec `data`, puis le
document est **validé** (schéma strict, bornes de la surface) avant d'être
rendu avec les composants Capibara. Un document invalide n'est jamais
affiché : l'écran dit « L'app n'a pas répondu » et le journal donne la raison.

**Action** — un bouton `{ action: 'save', params }` ou la soumission d'un
`form` appelle `actions.save`. La réponse combine librement :

| Effet | Signification |
|---|---|
| `ui` (+ `data`, `components`, `title`) | remplace la surface par ce document |
| `patch: { path, ui }` | remplace le nœud au chemin (`children.1.children.0`) |
| `toast: { tone, text }` | message éphémère |
| `navigate: '/crm/contacts/…'` | navigation interne (chemin re-sanitisé par la plateforme) |
| `openForm: { key, params?, title? }` | ouvre `forms[key]` en modal — jamais sur le site public (dessinez le formulaire dans la surface elle-même) |
| `close: true` | ferme le formulaire modal |
| `refresh: true` | re-rend la surface |
| `ack: true` | « bien reçu » — puis `ctx.followup(…)` dans les 15 min |
| `state` | nouvel état opaque (≤ 8 Ko) |

Un handler qui lève une exception renvoie une erreur propre (`{ ok: false,
error }`) : l'utilisateur voit un message honnête, le journal garde le détail.

## Le site public : blocs et pages d'app

Une app installée peut vivre sur le **site public** de l'organisation : des
**blocs** que l'auteur du site pose dans ses pages depuis l'éditeur
(« Ajouter » › « Vos apps »), et des **pages entières** à l'adresse choisie par
l'organisation (Mon site › Apps). Mêmes handlers, même Kit, même validation —
avec trois différences :

- **Jamais de `viewer`** : personne du personnel n'est connecté sur le site.
  Le visiteur arrive dans `visitor` — `{ memberId, name }` s'il est connecté à
  son compte membre du site, `null` sinon. Ne montrez rien de privé, et
  traitez `params` (champs du bloc, sous-chemin, paramètres d'URL) comme des
  entrées non fiables.
- **Compte de service** : votre code s'exécute sous le compte de service de
  l'app (`ctx.capibara.*` borné aux scopes consentis) — un formulaire public
  peut créer un contact CRM si le scope `crm.contacts:write` a été accordé.
- **Actions** : boutons et formulaires du Kit fonctionnent (route publique,
  rate-limitée) ; `ui`, `patch`, `toast`, `refresh`, `navigate` et `ack`
  s'appliquent ; `openForm` n'existe pas sur le site (dessinez le formulaire
  dans la surface). Le rendu d'un visiteur anonyme est mis en cache **60 s**
  par (installation, surface, paramètres) ; un membre connecté est toujours
  rendu à la demande.

Dans l'éditeur de site, l'aperçu est le vrai rendu de votre bloc (visiteur
anonyme) ; ses actions y sont simulées. Le harnais de test rend ces surfaces
comme la plateforme : `renderSurface(app, 'siteBlock', 'promo', t, { params,
visitor: { memberId: 'm1', name: 'Zoé' } })`.

## Événements, planifications, HTTP entrant

- **Événements** : déclarez `events: ["invoice.paid"]` (catalogue fermé,
  cf. [Webhooks sortants](/dev/docs/webhooks) pour la liste) et la capability
  `events.subscribe`. La plateforme livre chaque événement du tenant à votre
  handler par son ledger idempotent — sans webhook, sans serveur. Livraison
  **au moins une fois** : rendez vos handlers idempotents.
- **Planifications** : `schedules: [{ key: "nightly", cron: "0 3 * * *" }]`
  (5 champs, heure de Paris). Intervalle minimum selon le plan du tenant :
  **Gratuit 1 h, Pro 15 min, Business 5 min**. Une seule exécution en vol ;
  une occurrence manquée n'en déclenche qu'une.
- **HTTP entrant** : `http: ["/stripe-hook"]` expose une URL publique
  `/api/apps/<clé>/<jeton>/<chemin>` par installation (visible de l'admin du
  tenant dans Modules › Apps › Réglages, carte « Adresses entrantes » ;
  « Régénérer l'adresse » invalide l'ancienne, et une désinstallation aussi).
  Sans session, rate-limitée,
  corps ≤ 256 Ko, réponse JSON ou texte — jamais du Kit ni du HTML.

## Limites et journal

- 15 s par interaction, corps ≤ 256 Ko, réponse ≤ 1 Mo, ≤ 100 appels broker
  par interaction ; un isolate par app, ≤ 8 interactions simultanées,
  **disjoncteur** (10 échecs en 5 min à ≥ 50 % → 5 min de suspension, visible
  dans le journal). La table complète des bornes et des codes d'erreur vit
  sur [Capabilities, bornes et erreurs](/dev/docs/capabilities).
- Chaque interaction laisse une ligne dans le **journal de l'app** (kind, clé,
  statut, latence, erreur, vos `ctx.log`), consultable par l'admin de
  l'organisation (Modules › Apps) et conservée 7 jours.
- Un isolate **pas encore monté** (configuration du runtime pas régénérée ou
  pas rechargée) est dit tel quel dans le journal — « L'isolate de test
  « clé@version » n'est pas monté dans le runtime… » — et la plateforme
  redemande sa configuration toute seule : ce n'est jamais une erreur de
  votre code. `capibara status` nomme l'étape qui bloque (cf. [La CLI
  capibara](/dev/docs/cli), « Si l'isolate ne se monte pas »).
- Compatibilité : un module `{ fetch(request, env) }` (contrat v1) reste
  exécuté tel quel, mais ne peut plus contribuer de page — passez à
  `defineApp` + `view`.

## Backend externe — le même code, chez vous

Vous préférez héberger votre logique ? Déclarez au manifeste `backend: { url }`
(HTTPS, hôte public) **à la place** de `main` — les deux sont exclusifs.
Capibara POSTe alors à cette URL les requêtes du protocole ci-dessus,
signées et horodatées, et rend le Kit que vous renvoyez avec les mêmes bornes
et le même disjoncteur ; avec le SDK, `createBackend` sert le MÊME
`defineApp`. `ctx.capibara` passe par l'API publique ; KV, collections,
fichiers, moteurs et `followup` restent réservés au code hébergé ; les
organisations consentent à l'envoi de leurs interactions vers votre hôte et
la review humaine est obligatoire. Tout est détaillé sur la page dédiée
[Backend externe](/dev/docs/backend-externe).

## Tester

- **En local, sans réseau** : le harnais `@capibara-dev/sdk/testing`
  exécute vos handlers avec un `ctx` simulé (KV, collections, fichiers,
  `fetch` et façades par faux déclarés, journal) —
  `createTestContext()`, `renderSurface()`, `runAction()`,
  `runEvent()`, `runSchedule()`, `runHttp()` + assertions sur le Kit
  rendu. Cf. [SDK](/dev/docs/sdk) et le test de l'exemple « Météo ».
- **Kit Studio** (`/dev/kit-studio`) : vos documents rendus en direct dans
  chaque surface, avec des données d'exemple ; `capibara kit check
  <fichier.json>` applique le même validateur depuis le terminal.
- **Sur une organisation de test** : `capibara dev --org <slug>` bundle et
  déploie à chaque sauvegarde sur une installation de test (isolate dédié à
  votre version de test, journal en direct) — cf. [La CLI capibara](/dev/docs/cli).
  Depuis la fiche de l'app du portail, l'installation de test d'une version
  exécute son code hébergé dès qu'un bundle existe (déployé par la CLI, ou
  publié).
