Skip to content

Écrire un nœud ​

Un nœud, c’est une définition déclarative et une fonction. La définition génère le formulaire de l’éditeur, la validation du workflow, l’entrée de la palette et la page du nœud dans cette documentation. La fonction, execute, fait le travail quand une étape d’une exécution atteint le nœud.

Tous les nœuds du catalogue suivent le même contrat, déclaré avec defineNode dans packages/workflow/src/nodes/contract.ts, avec des paramètres déclarés comme le décrit packages/workflow/src/nodes/params.ts. Les nœuds vivent dans packages/nodes/src/catalog/, un fichier par nœud ou par famille de nœuds. Cette page prend pour fil conducteur le vrai nœud Marquer (mail.flag, fichier packages/nodes/src/catalog/mail-flag.ts).

Si le nœud parle à un service tiers au moyen d’une clé d’API, lisez aussi Écrire une intégration : les intégrations ont leur propre recette, qui s’ajoute à celle-ci.

La définition en un coup d’œil ​

defineNode(input) valide la déclaration et rend une NodeDefinition. L’entrée comporte ces champs :

ChampRequisRôle
typeouiL’identifiant au catalogue, famille.action (mail.flag, ai.categorize).
versionouiUn entier positif. Au catalogue, il vaut toujours 1 (voir plus bas).
metaouiNoms, description, icône, groupe, catégorie de palette, alias de recherche, en français et en anglais.
paramsouiLa liste des déclarations de paramètres (ParamSpec[]).
portsouiPorts d’entrée, ports de sortie (liste fixe ou fonction des paramètres), ports de service facultatifs, sortie de fournisseur, corps de boucle.
policyouiLa classe d’effet, plus des défauts facultatifs de rejeu, de nouvelles tentatives et de délai.
requiresnon'email' quand le nœud agit sur le mail déclencheur.
execute(context)ouiUne fonction async qui rend un NodeResult.

Une déclaration invalide lève une NodeDefinitionError au chargement du catalogue, avec la liste des problèmes trouvés. Ce sont des erreurs de build, pas d’exécution : un type mal formé, un nom vide dans une langue, un groupe ou un effet inconnu, un ports.in vide sur une étape, un nom de port invalide, un policy.retry dont maxAttempts est inférieur à 1, un timeoutMs non positif, ou une déclaration de paramètre invalide.

La définition rendue ajoute des champs dérivés dont se servent l’éditeur, le validateur et le moteur : key (mail.flag@v1), category, servicePorts, isServiceProvider, bodyPort, et les fonctions outputPorts(params), defaults(context), initialParams() et validateParams(params, context).

Type et version ​

Le type doit suivre la forme famille.action : lettres minuscules et chiffres, segments séparés par des points, un tiret bas permis à l’intérieur d’un segment mais pas à son début ni à sa fin. attachment.extract_text est valide ; Mail.Flag, mail-flag et mail._flag ne le sont pas. La clé de registre est type@v<version>, par exemple mail.flag@v1.

Le catalogue garde exactement une version de chaque nœud, et c’est la version 1 : un nœud se modifie sur place. Le test du catalogue (packages/nodes/src/catalog/catalog.test.ts) échoue si deux définitions partagent un type ou si une version ne vaut pas 1. Un workflow enregistré contre un catalogue qui a changé depuis est refusé avec workflow.graph_outdated ; il n’est jamais réparé en silence.

Natures de nœuds : étape, déclencheur, fournisseur ​

Il n’existe pas de champ kind. La nature d’un nœud découle de sa déclaration, et la documentation générée la dit de la même façon.

NatureComment elle se déclareCe qu’elle fait
ÉtapeTout ce qui n’est ni l’une ni l’autre des deux suivantes. ports.in vaut ['main'].S’exécute comme étape d’une exécution : execute est appelée.
Déclencheurmeta.group: 'trigger', ports.in: [], une seule sortie main, policy.effect: 'none'. Les types de déclencheurs sont aussi listés dans packages/workflow/src/graph/triggers.ts.Lance des exécutions. N’est jamais exécuté comme étape : son execute rejette toujours.
Fournisseurports.provides est déclaré (par exemple { name: 'model', kind: 'llm.model' }) ; ports.in et ports.out sont vides.Jamais une étape, jamais exécuté, ne publie aucune donnée. Il se branche sur le port de service d’un autre nœud, comme le port model d’un nœud IA.

La plupart des contributions sont des étapes. Les déclencheurs dépendent de mécanismes du serveur (distribution des mails, planifications, webhooks, sondages) ; pour un déclencheur par sondage d’une intégration, suivez Écrire une intégration.

meta : noms, icône, groupe, catégorie, recherche ​

meta porte tout ce que montrent la palette et la page du nœud. Tous les textes sont des objets { fr, en }.

ChampRègle
nameRequis, non vide dans les deux langues.
descriptionUne ou deux phrases, dans les deux langues. Le contrat permet de l’omettre, mais le test du catalogue et le générateur de documentation l’exigent.
iconRequis. Le nom d’une icône du design system, jamais un SVG.
grouptrigger, logic, ai, mail, data, integration ou flow. La famille technique, utilisée par la politique de nœuds de l’organisation.
categorydeclencheurs, ia, logique, donnees ou actions. La section de palette. Facultative dans le contrat (elle se déduit du groupe), mais le test du catalogue exige qu’elle soit déclarée explicitement.
searchAliases{ fr: string[], en: string[] }. Le test du catalogue exige au moins quatre alias par langue, en minuscules, non vides et sans doublon.

Marquer déclare :

ts
meta: {
  name: { fr: 'Marquer', en: 'Flag' },
  description: {
    fr: 'Change l’état du mail déclencheur : lu/non-lu, épinglé/non épinglé. Seuls les états renseignés sont modifiés.',
    en: 'Changes the state of the triggering email: read/unread, flagged/unflagged. Only the states you set are changed.',
  },
  icon: 'flag',
  group: 'mail',
  category: 'actions',
  searchAliases: {
    fr: ['marquer', 'lu', 'non lu', 'épingler', 'important', 'suivi', 'drapeau', 'étoile'],
    en: ['flag', 'mark', 'read', 'unread', 'important', 'star', 'pin', 'follow up'],
  },
},

Paramètres ​

Les paramètres se déclarent dans params, sous forme de liste de ParamSpec. La déclaration pilote le formulaire, la validation et les défauts ; libellés et descriptions vivent dans la déclaration elle-même, dans les deux langues.

Champs communs à tous les types :

ChampRôle
nameLa clé dans l’objet params du nœud. Unique à son niveau.
label{ fr, en }, requis.
description{ fr, en }, affichée en aide.
requiredLa valeur doit être renseignée.
requiredForUne fonction pure du contexte du graphe qui remplace required quand le contexte est connu (par exemple : un champ requis seulement quand aucun déclencheur n’apporte de mail).
templatable{{ }} est-il accepté ? Les types texte (string, text, valeurs d’un keyValue) l’acceptent par défaut ; les autres types exigent templatable: true. templatable: false sur un champ texte est un refus explicite.
displayIfAffichage conditionnel, à partir d’opérateurs comme eq, neq, in, notIn, exists, empty, combinés par all / any. Un paramètre masqué n’est ni requis ni validé, et sa valeur est conservée.
requiresEmailForPour un paramètre de premier niveau de type boolean, options ou multiOptions : les valeurs qui n’ont de sens qu’avec un mail déclencheur. La validation refuse de publier un graphe qui n’en a pas.

Types et champs propres :

TypeValeurChamps propres
string, textUne chaîne (text est multiligne).default, defaultFor, placeholder, maxLength, suggestions
numberUn nombre.default, defaultFor, min, max, integer
booleantrue / false.default, defaultFor
optionsUne valeur parmi options.options (value, label, description), default, defaultFor
multiOptionsPlusieurs valeurs parmi options.options, default
collectionUne liste d’objets dont les champs sont eux-mêmes des ParamSpec.of, minItems, maxItems, default
keyValueUne liste de { key, value }.default
conditionsUn arbre de conditions sur le message (le même que celui du déclencheur mail).scope, default
credentialL’identifiant opaque d’une connexion. Jamais templatable.credentialType, capability, provider
resourceLocatorUne ressource distante (dossier, agenda, table) choisie dans une liste, par identifiant ou par URL.resource, modes, credentialParam, parentParam

default est la valeur quand le contexte du graphe est inconnu. defaultFor, fonction pure du contexte du graphe (les types de déclencheurs, et le fait que les exécutions portent ou non un mail), la remplace quand le contexte est connu. Un défaut contextuel n’est jamais écrit à la création du nœud : il suit donc le graphe si le déclencheur change.

execute reçoit des paramètres déjà résolus : expressions rendues, défauts appliqués. Lisez-les avec les fonctions de packages/nodes/src/catalog/read-params.ts (par exemple readOptionalBoolean, qui accepte aussi un booléen rendu en texte par une expression) plutôt que de vous fier à leur type.

Marquer a deux booléens facultatifs et, délibérément, aucun défaut : une valeur absente signifie « ne pas y toucher ».

ts
const PARAMS: readonly ParamSpec[] = [
  {
    name: 'seen',
    type: 'boolean',
    label: { fr: 'Marquer comme lu', en: 'Mark as read' },
    description: {
      fr: 'Coché : le mail passe en lu. Décoché : il repasse en non-lu. Non renseigné : inchangé.',
      en: 'Checked: mark as read. Unchecked: mark as unread. Left empty: unchanged.',
    },
  },
  {
    name: 'flagged',
    type: 'boolean',
    label: { fr: 'Épingler', en: 'Flag' },
    description: { fr: '…', en: '…' },
  },
];

Ports ​

ports déclare comment le nœud se relie aux autres sur le canvas.

ChampRôle
inLes ports d’entrée. ['main'] (la constante MAIN_PORT) pour toute étape ; [] pour les déclencheurs et les fournisseurs.
outLes ports de sortie : une liste fixe ([MAIN_PORT], ou ['item', 'done'] pour la boucle), ou une fonction pure des paramètres. Catégoriser, par exemple, calcule une sortie par catégorie. La fonction est évaluée par l’éditeur à chaque frappe : elle doit être totale, sans horloge, sans aléa ni effet de bord ; les noms invalides et les doublons sont écartés.
servicesLes ports d’entrée de service, comme model de nature llm.model sur les nœuds IA. Rien n’y transite pendant l’exécution : le moteur lit le câblage et injecte le service correspondant. Non branché signifie « utiliser les défauts de l’instance ».
providesFait du nœud un fournisseur (voir plus haut).
bodyLe port de sortie qui ouvre un corps de boucle. Seul le nœud de boucle le déclare.

Un nom de port doit être non vide, sans espace au début ni à la fin, imprimable, et faire au plus 200 caractères.

Marquer a une entrée et une sortie : ports: { in: [MAIN_PORT], out: [MAIN_PORT] }.

Politique : effet, rejeu, nouvelles tentatives, délai ​

policy dit au moteur à quel point le nœud est dangereux. Elle pilote les essais, le coupe-circuit et la politique de nœuds de l’organisation.

ChampValeursSens
effectnone, external_write, sendRequis et honnête. none : pur (IA, transformation, condition). external_write : écrit hors du produit (classer, étiqueter, écriture HTTP, création d’un enregistrement). send : envoie un mail. Un nœud qui peut écrire le déclare, même si la plupart de ses actions ne font que lire.
replaySafebooléenRejouer le nœud après une tentative interrompue est-il sans danger ? Défaut : true pour none, false sinon. Déclarer true sur un nœud à effet engage : ses écritures sont dédupliquées par le produit. Sinon, une tentative interrompue est conclue comme incertaine et laissée à la décision du membre.
retry{ maxAttempts, delayMs? }Les défauts de nouvelles tentatives du type. Absents : ceux du moteur.
timeoutMsnombreLe délai par défaut d’une tentative, en millisecondes. Absent : celui du moteur.

Les nœuds IA partagent une même politique (AI_NODE_POLICY dans packages/nodes/src/catalog/ai-common.ts), exemple de retry et de timeoutMs. Marquer déclare { effect: 'external_write', replaySafe: true } : son écriture passe par les opérations sortantes du produit, indexées sur l’étape.

Un nœud dont l’effet n’est pas none doit transmettre context.idempotencyKey à chaque appel externe. Cette clé, dérivée de l’étape, rend inoffensif un rejeu après un crash.

requires : le mail déclencheur ​

requires: 'email' déclare que le nœud agit sur le mail déclencheur (Classer, Marquer). La validation du workflow refuse alors de publier un graphe dont un déclencheur lance des exécutions sans mail, avant même la première exécution. La vérification dans execute reste un filet pour les cas que la validation ne peut pas trancher (un workflow appelé par un autre).

La fonction execute ​

execute(context) est une fonction async. Elle doit toujours rendre une promesse, même sur des entrées hostiles : le test du catalogue appelle chaque nœud sans aucun service branché et avec des paramètres absurdes, et échoue si execute lève une exception de façon synchrone.

Le contexte (NodeExecutionContext) contient :

ChampContenu
emailLe mail déclencheur (CanonicalMessage), en lecture seule, ou undefined pour une exécution lancée sans mail (planification, webhook).
dataLes données accumulées par les étapes précédentes.
paramsLes paramètres résolus.
servicesLes services injectés : context.services.get(JETON) rend le service ou lève une erreur s’il n’est pas branché ; has(JETON) le teste.
idempotencyKeyLa clé de l’étape, à transmettre à tout effet externe.
abortSignalDélai et annulation. Tout appel réseau doit le respecter.
loggerUn journal minimal (debug, info, warn, error). Ne journalisez jamais le contenu d’un mail ni un secret.
executionFacultatif : { id, workflowId } de l’exécution courante, rien de plus.
localeFacultatif : 'fr' ou 'en', la langue du membre, pour les textes qu’un humain lit (résumés d’étape).
simulatedtrue pendant un essai.

Un nœud n’importe jamais un connecteur, le SDK d’un fournisseur ni la base de données. Il n’utilise que les services déclarés dans packages/nodes/src/services.ts et obtenus par le contexte : mail (MAIL_SERVICE), HTTP (HTTP_SERVICE), pièces jointes, LLM (LLM_SERVICE), profils, tables, intégrations, services Google et Microsoft, et les services de flux.

execute rend un NodeResult :

ChampSens
outputLe port de sortie emprunté, ou null pour terminer la branche sans erreur.
dataLe patch que publie l’étape. Les nœuds suivants le lisent en {{ data.<step>.champ }} (voir Données et expressions).
suspendFacultatif : l’étape se suspend au lieu de conclure (approbation, attente, workflow appelé, boucle). Le nœud ne bloque jamais ; il rend une description (kind, title, timeoutMs, outcomes, onTimeout) et le moteur persiste l’attente. Seules ces quatre natures existent.

Erreurs ​

Les nœuds lèvent les erreurs de packages/nodes/src/errors.ts. La seule question qui compte pour le moteur : une nouvelle tentative peut-elle aider ?

  • PermanentNodeError : réessayer ne changera rien (paramètre invalide, mail absent, 4xx métier). L’étape échoue.
  • RetryableNodeError : transitoire (réseau, 5xx, annulation). Le moteur peut faire une nouvelle tentative de l’étape.

Toutes deux prennent un code stable (minuscules, de la forme famille_probleme), un message technique en anglais, et les options cause et details. details ne porte que des métadonnées, jamais le contenu d’un mail. Les codes courants sont dans NODE_ERROR_CODES : node_missing_email, node_invalid_param, node_nothing_to_do, node_aborted, node_service_failed. Pour relayer l’échec d’un appel de service, relayCallFailure garde la classification déjà faite par le service, au lieu de transformer un refus définitif en nouvelle tentative.

Appelez throwIfAborted(context.abortSignal, TYPE) avant et après chaque attente d’un service : un nœud qui ignore l’annulation fait vivre une étape au-delà de son délai.

La façon dont les étapes en échec apparaissent aux membres (sortie Erreur, nouvelles tentatives, workflow d’erreur) est décrite dans Erreurs et nouvelles tentatives.

Essais ​

Pendant un essai, context.simulated vaut true et les nœuds à effet doivent décrire ce qu’ils feraient au lieu de le faire. Le plus souvent, le nœud n’a rien à écrire : le moteur injecte des services qui simulent les écritures. Le service de mail, par exemple, n’enregistre rien chez le fournisseur et rend simulated: true ; le service d’intégration fait les lectures réelles et décrit les écritures. Marquer se contente de recopier dans ses données l’indicateur simulated rendu par le service.

Un nœud dont l’effet ne passe pas par un tel service teste lui-même context.simulated. C’est le cas des nœuds d’approbation, d’attente et de signal.

Si les données publiées pendant un essai diffèrent de celles d’une exécution réelle, documentez les deux formes dans la source de documentation du nœud.

Résumés d’étape et i18n ​

Libellés, descriptions et options du nœud s’écrivent dans la définition, en français et en anglais. Tout autre texte qu’un humain lit pendant l’exécution, comme un résumé d’étape, passe par les dictionnaires des nœuds packages/nodes/src/i18n/fr.ts et en.ts, jamais par une phrase construite dans le code.

Utilisez summaryPatch(localeOf(context), 'clé', paramètres) du module i18n des nœuds : il ajoute aux données summary (la phrase rendue), summaryKey et summaryParams, pour que l’éditeur puisse rendre le résumé plus tard dans une autre langue. Syntaxe des messages : {nom} pour interpoler, {compteur|singulier|pluriel} pour accorder selon la règle de pluriel de chaque langue. Une clé correspond à une phrase entière.

Les prompts envoyés à un modèle de langage s’écrivent en anglais dans le code ; la description du paramètre indique aux membres qu’ils peuvent réécrire un prompt dans leur langue.

Deux tests y veillent : packages/nodes/src/i18n/hardcoded.test.ts échoue sur du français en dur dans le catalogue, et packages/nodes/src/i18n/i18n.test.ts vérifie que les deux dictionnaires ont exactement les mêmes clés. Si votre nœud introduit un nouveau code d’erreur, donnez-lui un message sous errors dans packages/ui/src/i18n/locales/fr.json et en.json : c’est là que l’interface traduit les codes d’erreur.

Enregistrer le nœud au catalogue ​

Le catalogue est une liste statique dans packages/nodes/src/catalog/index.ts :

  1. importez la définition et ajoutez-la à CATALOG_NODES, à la place de sa famille ;
  2. ajoutez-la au bloc de réexports en fin de fichier, avec son type de paramètres ;
  3. mettez à jour packages/nodes/src/catalog/catalog.test.ts, qui liste dans l’ordre toutes les clés de nœuds et l’effet de chaque type : un nouveau nœud fait échouer ce test tant que vous ne l’y avez pas ajouté, et c’est voulu.

createCatalogRegistry() construit le registre à partir de CATALOG_NODES ; le serveur, l’éditeur et la documentation lisent tous la même liste.

Tester le nœud ​

Chaque nœud a son fichier de test à côté de lui (mail-flag.test.ts). Les fonctions de packages/nodes/src/testing/context.ts construisent un contexte complet :

  • runNode(definition, { params, services, email, simulated, locale, abortSignal, … }) applique les défauts du nœud comme le moteur, puis appelle execute ;
  • bind(JETON, doublure) branche un service ; des doublures existent pour les services de mail, HTTP, pièces jointes, LLM et profils (createMailService, createHttpService, createAttachmentService, createLlmService, createProfileService) ;
  • emailFixture() est le mail déclencheur fourni par défaut ; passez email: undefined pour tester une exécution sans mail ;
  • createSignal() donne un signal annulable ; la clé d’idempotence par défaut est step-42.

Couvrez au moins la déclaration (clé, effet, défauts qui passent la validation) et le comportement : données produites, clé d’idempotence transmise au service, erreurs définitives et transitoires, annulation. Les commandes sont dans Tests.

ts
it('n’envoie que les états renseignés', async () => {
  const mail = createMailService();
  const result = runNode(mailFlagNode, {
    params: { seen: true },
    services: [bind(MAIL_SERVICE, mail)],
  });
  await expect(result).resolves.toEqual({
    output: 'main',
    data: { opId: 'op-1', simulated: false, seen: true },
  });
  expect(mail.flags).toEqual([{ input: { seen: true }, idempotencyKey: 'step-42' }]);
});

Source de documentation ​

Chaque nœud du catalogue doit avoir une source de documentation en deux fichiers : apps/docs/content/nodes/<type>.en.md et apps/docs/content/nodes/<type>.fr.md. Le type garde ses points : mail.flag.en.md, attachment.extract_text.fr.md. Le générateur écrit tout le reste à partir de la définition : noms, description, tableau des paramètres, ports, effet, connexion exigée.

La source commence par un frontmatter YAML au schéma fermé, suivi de Markdown libre :

markdown
---
ports:                    # facultatif : le sens de chaque port de SORTIE (clé = nom exact du port)
  main: "Emprunté quand …"
data:                     # obligatoire, `data: []` si l’étape ne publie rien
  - expression: "{{ data.<step>.opId }}"
    type: string
    description: "Ce que vaut cette donnée."
---
Introduction : à quoi sert le nœud, quand le préférer à un voisin.

## Exemple
Un exemple réaliste, avec les noms exacts des paramètres et les données produites.

## Conseils
Facultatif : pièges réels, limites, codes d’erreur.

Le générateur échoue, et le build de la documentation avec lui, quand :

  • un nœud n’a pas de source dans l’une des deux langues ;
  • meta.description est vide dans une langue ;
  • le frontmatter contient une clé inconnue ou une entrée mal formée ;
  • une clé de ports n’est pas un port de sortie du nœud (sauf si ses sorties sont calculées à partir des paramètres) ;
  • une expression de data ne contient pas de {{ }} ou ne se lit pas (<step> est accepté comme gabarit) ;
  • un fournisseur déclare des données (il doit avoir data: []) ;
  • une page contient le nom de code du produit ou une marque de travail en cours écrite en capitales.

Le générateur tourne avant chaque build et chaque serveur de développement de la documentation. Il lit les paquets compilés : construisez-les d’abord.

sh
pnpm build                              # ou au moins les paquets que lit la doc
pnpm --filter @manko/docs generate      # génère seulement les pages
pnpm --filter @manko/docs dev           # génère, puis sert le site en local
pnpm test --project docs                # tests du générateur

Liste de vérification ​

[ ] packages/nodes/src/catalog/<nom>.ts         defineNode : type, version 1, meta, params, ports, policy
[ ] meta : nom et description fr + en, icône, groupe, catégorie, ≥ 4 alias par langue
[ ] policy.effect honnête ; idempotencyKey transmise à tout effet externe
[ ] throwIfAborted autour de chaque attente de service ; erreurs classées définitives / transitoires
[ ] essais : écritures par un service qui simule, ou context.simulated testé
[ ] résumés d’étape par summaryPatch, clés dans packages/nodes/src/i18n/fr.ts ET en.ts
[ ] nouveaux codes d’erreur sous "errors" dans packages/ui/src/i18n/locales/fr.json ET en.json
[ ] packages/nodes/src/catalog/index.ts : CATALOG_NODES + réexport
[ ] packages/nodes/src/catalog/catalog.test.ts : clé et effet ajoutés
[ ] packages/nodes/src/catalog/<nom>.test.ts
[ ] apps/docs/content/nodes/<type>.en.md ET <type>.fr.md
[ ] pnpm typecheck, pnpm lint, pnpm test --project nodes, pnpm --filter @manko/docs generate