Le contexte serveur
La partie serveur d'un plugin exporte un objet ServerPlugin : ses routes, sa base, ses tâches et ses hooks. Chacun reçoit ctx, le contexte que Hodi lui donne. C'est par ctx, et par lui seul, que le plugin lit sa base, ses réglages, un site ou l'IA du foyer.
Un exemple complet
Ce plugin compte les bonjours de chaque foyer. Il crée une table, et sa route POST /hello y ajoute une ligne à chaque appel, puis répond le message et le nombre de bonjours du foyer.
import type { ServerPlugin } from '@hodi-app/plugin-sdk/server'
const plugin: ServerPlugin = {
migrations: [
'CREATE TABLE said (household INTEGER NOT NULL, at TEXT NOT NULL)'
],
routes: {
'POST /hello': async (request, ctx) => {
const { greeting } = ctx.settings()
const household = request.who.householdId
ctx.db.run('INSERT INTO said VALUES (?, ?)', [household, new Date().toISOString()])
const row = ctx.db.get('SELECT COUNT(*) AS count FROM said WHERE household = ?', [household])
return { body: { text: greeting, count: row?.count } }
}
}
}
export default pluginLe reste de cette page explique chaque morceau : migrations, routes, request, ctx et sa réponse.
Ce que le plugin exporte
ServerPlugin a ces champs, tous facultatifs. Un plugin n'écrit que ceux dont il a besoin.
migrationsLes changements de sa base, dans l'ordre.
routesLes routes appelées par la partie web, pour les profils du foyer.
serverRoutesLes routes de l'espace Serveur, pour l'admin du serveur.
publicRoutesLes routes ouvertes à tous, même sans être connecté.
aiActionsCe que fait chaque bouton IA du plugin.
tasksLes travaux qui reviennent tout seuls, comme un ménage.
feedsLes articles de ses flux.
onArticleAppelé pour chaque nouvel article que Hodi lit.
checkSignInAppelé à chaque connexion, pour l'accepter ou la refuser.
onProfileDeletedAppelé quand un profil est supprimé, pour effacer ce que le plugin garde de lui.
onAccountDeletedAppelé quand un compte est supprimé, pour effacer ce que le plugin garde de lui.
onHouseholdRemovedAppelé quand un foyer est supprimé, pour effacer ce que le plugin garde de lui.
exportDataCe que le plugin garde d'une personne, pour sa copie de données.
Sa base de données
Une base rien qu'à lui
Chaque plugin a son propre fichier SQLite, à part de la base de Hodi. Il y crée les tables qu'il veut, avec les noms qu'il veut : aucun autre plugin ne les voit, et le plugin ne voit pas celles de Hodi.
Les migrations
Une migration est une instruction SQL qui change la structure de la base : créer une table, ajouter une colonne… migrations les liste dans l'ordre :
migrations: [
'CREATE TABLE said (household INTEGER NOT NULL, at TEXT NOT NULL)',
'ALTER TABLE said ADD COLUMN profile INTEGER'
]À chaque démarrage, Hodi regarde combien de migrations il a déjà appliquées pour ce plugin, et applique les suivantes. Ici, une installation neuve applique les deux. Une installation qui avait déjà la première n'applique que la seconde.
Hodi repère les migrations par leur place dans la liste. Une fois une version du plugin publiée, ses migrations ne changent donc plus :
- Ne jamais modifier une migration déjà publiée.
- Ne jamais en supprimer, ni en déplacer.
- Ajouter toujours la suivante au bout de la liste.
Pour corriger une erreur dans une migration publiée, il faut en ajouter une nouvelle qui la corrige.
Lire et écrire
ctx.db a trois fonctions. Chacune prend une requête SQL et ses paramètres, écrits ? dans la requête :
ctx.db.query(sql, params)Pour un SELECT qui renvoie plusieurs lignes. Elle renvoie toutes les lignes, un objet par ligne.
ctx.db.get(sql, params)Pour un SELECT qui renvoie une seule ligne. Elle renvoie la première ligne, ou null.
ctx.db.run(sql, params)Pour un INSERT, un UPDATE ou un DELETE. Elle renvoie { changes, lastInsertRowid }.
const rows = ctx.db.query('SELECT at FROM said WHERE household = ? ORDER BY at DESC', [household])
// rows = [{ at: '2026-11-13T09:12:00.000Z' }, { at: '2026-11-12T18:40:00.000Z' }]Les valeurs passent toujours par des ?, jamais collées dans le texte de la requête. SQLite les reçoit alors à part, et une valeur tapée par quelqu'un ne peut pas changer la requête.
Une base pour tous les foyers
Un même serveur Hodi peut accueillir plusieurs foyers, et le plugin n'a qu'une base pour tous. C'est au plugin de séparer leurs données :
- chaque table qui contient des données d'un foyer a une colonne pour lui, comme
household; - chaque requête filtre sur cette colonne, avec
request.who.householdId.
Sans ce filtre, un foyer verrait les données des autres.
Les routes
Déclarer une route
Une route est une fonction que la partie web peut appeler. Elle a une clé, 'MÉTHODE /chemin', et une fonction qui reçoit la requête et ctx :
routes: {
'GET /notes': async (request, ctx) => {
const notes = ctx.db.query('SELECT id, text FROM notes WHERE household = ?', [request.who.householdId])
return { body: notes }
},
'DELETE /notes/:id': async (request, ctx) => {
ctx.db.run('DELETE FROM notes WHERE id = ? AND household = ?', [request.params.id, request.who.householdId])
return { status: 204 }
}
}La méthode est GET, POST, PUT, PATCH ou DELETE. Une partie du chemin qui commence par : est variable : pour /notes/12, request.params.id vaut '12'.
Trois sortes de routes
Hodi sert les routes à trois endroits, chacun pour d'autres personnes :
routesLes profils du foyer prévus par audience. La partie web les appelle avec usePluginApi().
serverRoutesL'admin du serveur seulement. La partie web les appelle avec usePluginServerApi().
publicRoutesTout le monde, même sans être connecté. La partie web les appelle avec usePluginPublicApi(), et il faut la permission publicRoutes.
Une route publique ne sait pas qui l'appelle : request.who y vaut null. Hodi y limite chaque adresse à 30 requêtes par minute, et refuse une requête venue d'un autre site.
Ce que la route reçoit
methodstringLa méthode, comme 'POST'.
pathstringLe chemin sous celui du plugin, comme /notes/12.
paramsRecord<string, string>Les parties variables du chemin.
queryRecord<string, string>Les paramètres après le ? de l'adresse.
bodyunknownLe JSON envoyé, ou null.
whoPluginCallerQui demande, tel que Hodi l'a vérifié.
Qui demande
request.who décrit la personne qui appelle. Hodi l'a déjà vérifié : la route peut s'y fier sans rien contrôler elle-même.
profileIdnumber | nullLe profil ouvert sur l'appareil.
householdIdnumber | nullLe foyer pour lequel la personne demande.
kind'adult' | 'teen' | 'child'Adulte, ado ou enfant.
householdAdminbooleanLa personne est l'admin du foyer.
serverAdminbooleanLa personne est l'admin du serveur.
accountIdnumber | nullLe compte connecté sur l'appareil.
Aucun cookie et aucun en-tête de la requête n'arrive jusqu'au plugin. Il ne peut donc pas réutiliser la session de quelqu'un.
Ce que la route répond
Une route répond un objet avec deux champs, tous deux facultatifs :
status: le code HTTP, 200 par défaut ;body: n'importe quelle valeur JSON, que la partie web lit avecanswer.json().
return { status: 400, body: { error: 'too_long' } }Hodi ne laisse passer que les codes 2xx et 4xx. Si la route plante, ou répond un autre code, la partie web reçoit 500, et l'erreur va dans le journal de Hodi.
Deux autres réponses existent : un zip que Hodi construit et envoie en téléchargement, et une route raw qui reçoit des octets, comme une image, jusqu'à 10 Mo. Types les décrit.
Une route sensible
Une route qui efface des données ou change un compte doit demander une preuve que c'est bien la personne. Avec proof: true, Hodi ne lance la route qu'après que la personne a retapé son mot de passe :
routes: {
'DELETE /history': {
proof: true,
handler: async (request, ctx) => {
ctx.db.run('DELETE FROM said WHERE household = ?', [request.who.householdId])
return { status: 204 }
}
}
}Côté web, askToConfirm() ouvre la fenêtre de Hodi qui demande le mot de passe. Sans lui, la route répond 403 sans avoir tourné. Le plugin ne voit jamais le mot de passe.
Ce que le contexte donne
À chaque plugin
ctx.dbSa base, avec query, get et run.
ctx.settings()Ses réglages, avec leur valeur actuelle.
ctx.log(message)Une ligne dans le journal de Hodi, que hodi-plugin dev affiche aussi.
ctx.serverSettings()Combien de jours Hodi garde un article, une vidéo et une copie de sauvegarde.
Avec une permission
Chacune de ces fonctions ne marche que si le plugin a la permission indiquée. Sinon, Hodi refuse l'appel.
ctx.fetchText(url)Lit une page d'un site listé, et renvoie son texte. Il faut la permission network.
ctx.http(url, options)Envoie une requête à un site listé, avec un corps, comme un échange OAuth. Il faut la permission network.
ctx.articlesRetrouve un article listé par Hodi, et sa page. Il faut la permission articles.
ctx.ai.ask(question)Pose une question à l'IA du foyer, une fois par clic. Il faut la permission ai.
ctx.notify(notice)Envoie une notification à une personne, 10 par jour au plus. Il faut la permission notify.
ctx.mail.send(message)Envoie un e-mail, 50 par heure au plus. Il faut la permission mail.
ctx.accountsRetrouve, crée et vérifie des comptes. Il faut la permission accounts.
ctx.householdsListe et crée des foyers. Il faut la permission households.
ctx.fetchText() et ctx.http() ne joignent que les sites listés dans network. Ils refusent une redirection vers un autre site, et n'envoient un corps ou un en-tête Authorization qu'en https.
const { text } = await ctx.fetchText('https://api.example.com/weather?city=Brussels')
const weather = JSON.parse(text)L'IA, seulement sur un clic
Une question à l'IA coûte de l'argent au foyer. Hodi ne la pose donc jamais sans que quelqu'un ait cliqué. Voici comment ça marche :
- La partie web affiche un
AiButton, avec le nom d'une action, commesummarize. - La personne clique sur ce bouton.
- Hodi appelle l'action
summarizedeaiActions, côté serveur. - L'action peut appeler
ctx.ai.ask()une seule fois. - Ce qu'elle répond revient au bouton.
aiActions: {
summarize: async ({ input, who }, ctx) => {
const text = await ctx.ai.ask({ prompt: `Résume : ${input.text}`, maxTokens: 300 })
return { text }
}
}ctx.ai.ask() ne répond que dans une action de aiActions. Ailleurs, dans une route ou une tâche, il refuse.
Une question fait 8000 caractères au plus. Elle compte dans le plafond quotidien du foyer, et dans une limite de 10 par heure pour le plugin. Le plugin ne voit jamais la clé de l'IA, ni le fournisseur, ni le modèle.
Ce qui tourne tout seul
Les tâches
Une tâche est un travail que Hodi lance tout seul, à intervalle régulier, comme effacer chaque nuit ce qui a expiré :
tasks: {
'clean-up': {
every: 24 * 60,
run: async ctx => {
ctx.db.run("DELETE FROM said WHERE at < datetime('now', '-30 days')")
}
}
}every: le nombre de minutes entre deux lancements, de 15 à une semaine (10 080).run: le travail, qui reçoitctx.
Hodi retient quand chaque tâche a démarré. Un redémarrage ne la relance donc pas trop tôt.
Une tâche ne tourne pour aucun foyer ni aucune personne. ctx y refuse donc ce qui en demande un, comme l'IA ou une notification.
Les autres hooks
onArticle: appelé pour chaque nouvel article que Hodi lit sur un site, avec cet article.feeds: fournit les articles des flux du plugin, lus toutes les quelques minutes.checkSignIn: appelé à chaque connexion (voirsignInExtras), il peut la refuser.
Les données des gens
Effacer
Quand quelqu'un quitte Hodi, le plugin doit effacer ce qu'il gardait de cette personne. Hodi appelle pour ça :
onProfileDeletedquand un profil est supprimé ;onAccountDeletedquand un compte est supprimé ;onHouseholdRemovedquand un foyer entier disparaît.
onHouseholdRemoved: async ({ householdId }, ctx) => {
ctx.db.run('DELETE FROM said WHERE household = ?', [householdId])
}Si le hook échoue, Hodi réessaie chaque minute jusqu'à ce qu'il réussisse. Il peut donc tourner deux fois pour la même personne : il doit marcher même si les données sont déjà effacées.
Copier
Dans Réglages › Compte et données, chaque personne peut télécharger une copie de ses données. exportData y ajoute ce que le plugin garde d'elle, en JSON de 512 Ko au plus :
exportData: async ({ profileId }, ctx) => ({
notes: ctx.db.query('SELECT text, at FROM notes WHERE profile = ?', [profileId])
})