Écrire son premier mod Claude Code : la charge d'équipe dans le terminal

Un mod Claude Code est un module JavaScript ou TypeScript, livré dans un plugin, qui tourne dans votre session, voit passer chaque événement (appel d'outil, prompt, fin de tour, rendu de l'interface) et peut dessiner dans le terminal. Ce tutoriel vous montre comment écrire votre premier mod, à partir d'un cas réel : la charge de l'équipe Hoko affichée directement dans le terminal.

Notre mod interne en action : grille /charge, bandeau d'alerte au-dessus du prompt et temps de la semaine dans la ligne de statut.
Pourquoi écrire un mod Claude Code ?
Les mods ont été présentés dans le guide officiel « Getting started with Claude Code mods », publié le 1er octobre 2026. Ils ouvrent une possibilité nouvelle : adapter Claude Code à vos règles d'équipe, avec du code exécutable plutôt qu'une consigne écrite.
Une règle dans un CLAUDE.md, l'agent peut l'ignorer. Un contrôle exécutable, dans l'outil, au moment où l'agent agit, ne se discute pas. C'est une pièce de plus pour le harnais qui encadre vos agents. Notre premier mod n'en est pas encore un : il rend visible l'information de pilotage. Mais il pose la plomberie qu'un garde-fou réutilisera.
Chez Hoko, collectif de freelances basé en France, nous utilisons Claude Code tous les jours. L'information de pilotage (temps déclaré, charge, avancement des missions) vit dans notre outil interne, le cockpit. Nous voulions qu'elle vienne là où l'on travaille déjà.
Dans ce tutoriel, vous allez :
- comprendre le principe d'un mod, d'après le guide officiel ;
- suivre dix étapes à partir du code de notre mod, à transposer à votre propre serveur MCP ;
- repérer les pièges à connaître avant d'écrire le vôtre.
Ce qu'est un mod : le principe en trois gestes
Un mod est un plugin Claude Code classique avec trois éléments : un manifeste .claude-plugin/plugin.json, un fichier hooks/hooks.json qui nomme les modules, et un module qui exporte register(on, options).
À l'intérieur, vous inscrivez vos réactions avec on("événement", { filtre }, async ($, e, next) => { … }). Chaque mod est un maillon d'une chaîne : l'événement traverse les mods, puis le comportement natif de Claude Code.

Chaque mod est un maillon : l'événement traverse la chaîne avant d'atteindre Claude Code.
Un mod dispose de trois gestes.
| Geste | Code | Effet |
|---|---|---|
| Observer | const r = await next(e) puis lire r | L'événement passe, vous regardez le résultat |
| Réécrire | return next({ ...e, input }) | Vous modifiez l'événement avant de le laisser passer |
| Répondre | return { deny: "raison" } | Vous répondez sans appeler next |
Un mod peut aussi dessiner. Quatre surfaces existent : un panneau (Pane), une bande au-dessus du prompt (ui.render avec component: 'AbovePrompt'), la ligne de statut ($.ui.status) et le toast ($.ui.toast). Les composants s'inspirent d'Ink : Box, Text, Button.

Les quatre surfaces de dessin : panneau, bande au-dessus du prompt, ligne de statut, toast.
Deux détails comptent pour la suite. L'état vit dans $.state (ou dans des atomes), jamais dans des variables de module : il survit ainsi au rechargement à chaud. Et chaque sauvegarde recharge le mod sans redémarrer la session.

Rechargement à chaud : on modifie const CAPACITE = 35, le statut change sans relancer la session.
Côté outillage, le guide officiel cite trois commandes : claude plugin validate ./chemin pour valider le manifeste et la source, claude plugin test ./chemin pour exécuter les fichiers *.test.ts avec des stubs du runtime, et claude --plugin-dir ./chemin pour charger un plugin en local.
Le guide présente trois exemples : Token Weather (la météo de la fenêtre de contexte au-dessus du prompt), Blast Radius (qui intercepte une commande destructrice) et Replay Theater (qui rejoue les diffs d'un tour).
Notre cas : le pilotage de l'agence dans le terminal
Notre cockpit est un outil interne : il expose le temps déclaré, la charge de l'équipe et l'avancement des missions par un serveur MCP. Le mod que nous avons écrit n'est pas distribué publiquement. Nous le montrons parce que son schéma se transpose à n'importe quel serveur MCP : remplacez nos outils par les vôtres.
Ce qu'il affiche :
- la ligne de statut : le temps déclaré de la semaine et les jours restés vides ;
- une bande au-dessus du prompt, seulement quand il y a une alerte : jours non déclarés, semaine saturée ;
/charge: un panneau avec la charge de chaque personne sur les semaines à venir ;- un toast : un rappel de déclaration après une longue session.
Tout est lu par le serveur MCP, sous l'identité de la personne. Le mod n'écrit jamais et n'embarque aucune donnée : il ne montre que ce que le serveur accepte de rendre à cette personne.
La prochaine étape est de passer de l'affichage au contrôle : retenir l'agent quand il s'apprête à assigner quelqu'un sur une semaine déjà pleine, et laisser l'humain trancher, comme le fait Blast Radius.
Étape 1 : déclarer le module et les types
Le fichier hooks/hooks.json nomme le module à charger :
{ "modules": ["./register.tsx"] }
Dans .claude-plugin/plugin.json, ajoutez "types": "./types/index.d.ts". Ce fichier étend l'interface PluginState de claude-code, pour que l'état soit typé :
declare module 'claude-code' {
interface PluginState {
'hk-cockpit': {
semaine: Semaine | null
charge: Charge | null
/** Pourquoi la dernière lecture a échoué : le statut le dit, plutôt qu'un zéro. */
erreur: string | null
bandeauMasque: boolean
/** Le début de la session : il survit aux rechargements du mod. */
debutSession: number | null
rappelFait: boolean
}
}
}
Notez le champ erreur : nous y reviendrons à l'étape 5.
Étape 2 : porter l'état avec des atomes
Les variables de module ne survivent pas au rechargement à chaud. Les atomes, si.
import { atom, read, update } from 'claude-code'
const semaine = atom({ plugin: 'hk-cockpit', key: 'semaine' } as const, null)
const erreur = atom({ plugin: 'hk-cockpit', key: 'erreur' } as const, null)
On lit ensuite avec read($, semaine) et on écrit avec update($, semaine, …).
Étape 3 : au démarrage, enregistrer les commandes et relire périodiquement
export const register: Register = (on) => {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'charge', description: 'Charge de l’équipe sur les semaines à venir (cockpit)' })
// Un rechargement du mod rejoue session.start : le début reste le premier.
const maintenant = await $.clock.now()
await update($, debutSession, (debut) => debut ?? maintenant)
void relireSemaine($)
$.clock.every(15 * 60 * 1000, () => void relireSemaine($))
return next(e)
})
Premier piège : session.start est rejoué à chaque rechargement à chaud. Sans précaution, le « début de session » serait réinitialisé à chaque sauvegarde du mod, et le rappel de l'étape 9 ne se déclencherait jamais. D'où le debut ?? maintenant : le premier début gagne.
Étape 4 : appeler un outil MCP depuis le mod
Le mod ne parle pas au serveur directement. Il appelle les outils MCP déjà configurés avec $.tool.call, donc avec les mêmes droits que l'agent.
async function appeler($, outil, args) {
const nom = await nomOutil($, outil) // le nom complet, retrouvé dans $.tool.list()
const fait = await $.tool.call({ tool: nom, ...args })
if (fait.deny !== undefined) throw new Error(fait.deny)
if (fait.isError === true) throw new Error((fait.text ?? '').split('\n')[0])
return fait.text ?? ''
}
Le nom complet d'un outil MCP dépend de la façon dont le serveur est déclaré : livré par un plugin, il porte le préfixe du plugin. Ne l'écrivez pas en dur, retrouvez-le dans $.tool.list().
Les deux formes d'échec (refus de permission, erreur de l'outil) lèvent une exception explicite au lieu de rendre un texte vide. Ce n'est pas du zèle : c'est ce qui permet l'étape suivante.
Étape 5 : la ligne de statut, et une panne qui se voit
$.ui.status(`⏱ ${lue.total} / 40 h` + (manque === 0 ? ' · à jour' : ` · ${manque} j non déclarés`))
// en cas d'échec :
$.ui.status(`⏱ cockpit · temps : serveur non connecté (/mcp)`)
Notre règle maison : une dégradation doit laisser une trace. Un statut à « 0 h » quand le serveur est injoignable passerait pour un fonctionnement normal, et quelqu'un croirait n'avoir rien déclaré. Mieux vaut dire « serveur non connecté » et renvoyer vers /mcp.
Étape 6 : un bandeau au-dessus du prompt, seulement s'il y a quelque chose à dire
Le bandeau ne s'affiche que sur une alerte. Sinon, il rend la main avec next(e) et ne prend aucune place.
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
if (e.props.hasSurvey || (await read($, bandeauMasque))) return next(e)
const lignes = alertes(await read($, semaine), await read($, charge), await dateDuJour($))
if (lignes.length === 0) return next(e)
const { Box, Button, Text } = $.ui.resolve(e)
return (
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
{lignes.map((ligne) => <Text color="yellow" wrap="truncate-end">{ligne}</Text>)}
<Box flexDirection="row">
<Button key="voir" label="Voir la charge" onPress={() => void $.ui.open({ id: 'cockpit-charge', title: 'Charge · cockpit' })} />
<Text> </Text>
<Button key="masquer" label="Masquer" onPress={() => update($, bandeauMasque, () => true)} />
</Box>
</Box>
)
})
Deux détails : on s'efface devant une enquête en cours (hasSurvey), et la personne peut masquer le bandeau. Un bandeau qu'on ne peut pas faire taire finit ignoré.
Étape 7 : une commande qui ouvre un panneau
La commande /charge relit les données, ouvre un panneau et répond en une phrase :
on('command.run', { command: 'charge' }, async ($) => {
const vue = await relireCharge($)
await $.ui.open({ id: 'cockpit-charge', title: 'Charge · cockpit' })
return { text: `Charge ouverte : ${vue.personnes.length} personne(s), ${vue.besoins.length} besoin(s) sans personne.` }
})
on('ui.render', { component: 'Pane', requestId: 'cockpit-charge' }, async ($, e) => {
const { Box, Button, Text } = $.ui.resolve(e)
const colonnes = e.props.bodyColumns // la grille s'adapte à la largeur du panneau
// … une ligne par personne, une case par semaine, « 12! » en rouge si saturée
})
Le panneau est un rendu à part, relié à la commande par son requestId. La grille s'adapte à la largeur disponible grâce à bodyColumns.
Étape 8 : observer, le statut suit une déclaration de temps
Premier geste de la chaîne : on laisse passer l'appel, puis on regarde.
on('tool.call', async ($, e, next) => {
const fait = await next(e) // observer : on laisse passer, puis on regarde
const outil = OUTIL_COCKPIT.exec(String(e.tool))?.[1]
if (outil === 'log_time' || outil === 'update_time') await relireSemaine($)
return fait
})
Quand quelqu'un déclare une heure (log_time) ou la corrige (update_time), la ligne de statut se met à jour aussitôt. Le await devant relireSemaine compte : sans lui, le rappel de l'étape suivante pourrait réclamer une déclaration qui vient d'être faite.
Étape 9 : le rappel en toast, testé comme une fonction pure
on('turn.complete', async ($, e, next) => {
const fait = await next(e)
if (e.agentId !== undefined || (await read($, rappelFait))) return fait // pas pour un sous-agent
const texte = rappel({ semaine: await read($, semaine), dureeMs: (await $.clock.now()) - debut, portee })
if (texte !== null) { await update($, rappelFait, () => true); $.ui.toast(texte, { timeoutMs: 15000 }) }
return fait
})
Le rendu : « ⏱ 1 h 15 de session sur project:demo, rien de déclaré aujourd'hui. Dites par exemple : « déclare 1 h 15 sur project:demo » ».
Le rappel ne déclare rien. C'est la personne qui demande la déclaration, avec ses mots. Le mod n'écrit jamais.
La logique est isolée dans rappel.ts et testée avec Vitest : le rappel se montre après une longue session un jour vide, se tait si le jour est déclaré, se tait sur une session courte, et l'arrondi au quart d'heure ne tombe jamais à zéro. Le principe : un rappel qui tombe à tort s'apprend vite à ignorer, et le bon rappel avec lui.
Étape 10 : répondre, autoriser ses propres lectures en mode auto, et rien d'autre
Deuxième piège : en mode auto, le classifieur juge un appel d'outil au regard de la demande qui l'a produit. Un minuteur ou une commande tapée n'ont pas de telle demande : l'appel est refusé. Notre mod s'est donc retrouvé bloqué sur ses propres lectures.
La solution est le geste « Répondre » : le mod s'autorise ses trois lectures, et seulement pendant ses propres appels.
const LECTURES = new Set(['get_timesheet', 'get_workload', 'list_steps'])
on('classic.PreToolUse', ($, e, next) => {
const nom = String(e.tool)
const outil = OUTIL_COCKPIT.exec(nom)?.[1]
// Seulement pendant un appel du mod : un appel du modèle garde ses règles.
if (outil === undefined || !LECTURES.has(outil) || (appelsDuMod.get(nom) ?? 0) === 0) return next(e)
return { allow: true }
})
Un compteur appelsDuMod est incrémenté avant $.tool.call et décrémenté dans un finally. Le passe-droit ne survit donc pas à l'appel, même en cas d'échec.
Le même geste sert à bloquer. Dans les exemples du guide officiel, Blast Radius intercepte un rm -rf ou une migration sur l'outil Bash, mesure ce qu'il toucherait et demande Proceed ou Cancel.

Blast Radius, exemple du guide officiel : le mod répond avant que la commande ne parte.
Pour démarrer
- Commencez petit : une ligne de statut, puis un bandeau conditionnel.
- Chargez en local avec
claude --plugin-dir ./mon-plugin, puis sauvegardez pour recharger à chaud. - Validez et testez avec
claude plugin validateetclaude plugin test. - Gardez la logique dans des fonctions pures testables, comme
rappel.ts. Le moduleregisterne fait que brancher. - Faites parler les pannes : un état d'erreur visible vaut mieux qu'un zéro.
Pour un collectif comme le nôtre, l'intérêt est concret : l'information de pilotage vient là où l'on travaille déjà, avec les mêmes droits que l'agent, sans rien embarquer. Si vous voulez structurer l'usage de Claude Code dans votre équipe, découvrez notre formation Agentic Coding, et si votre projet vibe codé doit passer en production, nos équipes l'accompagnent avec Vibe Code Ready.
FAQ - Questions fréquentes
Qu'est-ce qu'un mod Claude Code ?
C'est un module JavaScript ou TypeScript livré dans un plugin Claude Code. Il exporte une fonction register(on, options), reçoit les événements de la session (appel d'outil, prompt, fin de tour, rendu) et peut les observer, les réécrire ou y répondre. Il peut aussi dessiner dans le terminal.
Faut-il redémarrer Claude Code après chaque modification d'un mod ?
Non. Chaque sauvegarde recharge le mod à chaud, sans redémarrer la session. Pour que votre état survive, stockez-le dans $.state ou dans des atomes, pas dans des variables de module. Attention : session.start est rejoué à chaque rechargement.
Comment tester et valider un mod ?
Chargez-le en local avec claude --plugin-dir ./chemin. Validez le manifeste et la source avec claude plugin validate ./chemin, puis lancez vos fichiers *.test.ts avec claude plugin test ./chemin. Isolez la logique métier dans des fonctions pures, plus faciles à tester.
Un mod peut-il faire fonctionner les outils en mode auto ?
Oui, avec précaution. En mode auto, un appel d'outil lancé par un minuteur ou une commande est jugé sans la demande qui l'aurait produit, et peut être refusé. Un hook classic.PreToolUse qui renvoie { allow: true } règle le problème. Limitez-le aux appels du mod lui-même, sinon vous contournez les règles du modèle.
Conclusion
À retenir :
- Un mod est un plugin avec un module
register, qui observe, réécrit ou répond aux événements de la session. - Il peut dessiner dans quatre surfaces : panneau, bande au-dessus du prompt, ligne de statut, toast.
- L'état se garde dans des atomes ou
$.state, pour survivre au rechargement à chaud. - Les pièges à connaître :
session.startrejoué, mode auto, fuseau horaire, coût des lectures. - Un contrôle exécutable vaut plus qu'une consigne dans un
CLAUDE.md.
Vous voulez adapter Claude Code à votre équipe, ou fiabiliser un projet vibe codé ? Discutons de votre projet ou écrivez-nous à hello@hoko.team. Audit gratuit, sans engagement, réponse sous 24h : découvrez votre score avec notre audit de code.
Le premier mod que vous écririez, ce serait lequel ?
Vous voulez industrialiser le développement assisté par IA dans votre équipe ?
Trente minutes pour cadrer votre contexte et voir ce que l'agentic engineering changerait chez vous.


