English
Writing a node
A node is one declarative definition plus one function. The definition generates the form in the editor, the validation of the workflow, the palette entry and the node's page in this documentation. The function, execute, does the work when a step of a run reaches the node.
Every node of the catalog follows the same contract, declared with defineNode in packages/workflow/src/nodes/contract.ts, with parameters declared as described in packages/workflow/src/nodes/params.ts. Nodes live in packages/nodes/src/catalog/, one file per node or per family of nodes. This page uses the real Flag node (mail.flag, file packages/nodes/src/catalog/mail-flag.ts) as the running example.
If the node talks to a third-party service through an API key, read Writing an integration as well: integrations have their own recipe on top of this one.
The definition at a glance
defineNode(input) validates the declaration and returns a NodeDefinition. The input has these fields:
| Field | Required | Role |
|---|---|---|
type | yes | The catalog identifier, family.action (mail.flag, ai.categorize). |
version | yes | A positive integer. In the catalog it is always 1 (see below). |
meta | yes | Names, description, icon, group, palette category, search aliases, in French and English. |
params | yes | The list of parameter declarations (ParamSpec[]). |
ports | yes | Input ports, output ports (static list or function of the parameters), optional service ports, provider output, loop body. |
policy | yes | The effect class, plus optional replay, retry and timeout defaults. |
requires | no | 'email' when the node acts on the triggering email. |
execute(context) | yes | An async function that returns a NodeResult. |
An invalid declaration throws a NodeDefinitionError when the catalog is loaded, with the list of problems found. These are build-time errors, not run-time errors: a wrong type pattern, an empty name in one language, an unknown group or effect, an empty ports.in on a step, an invalid port name, a policy.retry with maxAttempts below 1, a non-positive timeoutMs, or an invalid parameter declaration.
The returned definition adds derived fields that the editor, the validator and the engine use: key (mail.flag@v1), category, servicePorts, isServiceProvider, bodyPort, and the functions outputPorts(params), defaults(context), initialParams() and validateParams(params, context).
Type and version
The type must match family.action: lowercase letters and digits, segments separated by dots, an underscore allowed inside a segment but not at its start or end. attachment.extract_text is valid; Mail.Flag, mail-flag and mail._flag are not. The registry key is type@v<version>, for example mail.flag@v1.
The catalog keeps exactly one version of each node, and it is version 1: a node is changed in place. The catalog test (packages/nodes/src/catalog/catalog.test.ts) fails if two definitions share a type or if a version is not 1. A workflow saved against a catalog that has changed since is refused with workflow.graph_outdated; it is never silently repaired.
Kinds of nodes: step, trigger, provider
There is no kind field. A node's nature follows from its declaration, and the generated documentation states it the same way.
| Kind | How it is declared | What it does |
|---|---|---|
| Step | Anything that is neither of the two others. ports.in is ['main']. | Runs as a step of a run: execute is called. |
| Trigger | meta.group: 'trigger', ports.in: [], one output main, policy.effect: 'none'. Trigger types are also listed in packages/workflow/src/graph/triggers.ts. | Starts runs. It is never executed as a step: its execute always rejects. |
| Provider | ports.provides is declared (for example { name: 'model', kind: 'llm.model' }); ports.in and ports.out are empty. | Never a step, never executed, publishes no data. It is wired to the service port of another node, such as the model port of an AI node. |
Most contributions are steps. Triggers depend on server machinery (mail dispatch, schedules, webhooks, polling); for a polling trigger of an integration, follow Writing an integration.
meta: names, icon, group, category, search
meta holds everything the palette and the node page show. All texts are { fr, en } objects.
| Field | Rule |
|---|---|
name | Required, non-empty in both languages. |
description | One or two sentences, both languages. The contract allows it to be absent, but the catalog test and the documentation generator require it. |
icon | Required. The name of a design-system icon, never an SVG. |
group | trigger, logic, ai, mail, data, integration or flow. The technical family, used by the organisation's node policy. |
category | declencheurs, ia, logique, donnees or actions. The palette section. Optional in the contract (it is derived from the group), but the catalog test requires it to be declared explicitly. |
searchAliases | { fr: string[], en: string[] }. The catalog test requires at least four aliases per language, lowercase, non-empty and without duplicates. |
Flag declares:
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'],
},
},Parameters
Parameters are declared in params as a list of ParamSpec. The declaration drives the form, the validation and the defaults; labels and descriptions live in the declaration itself, in both languages.
Fields common to every type:
| Field | Role |
|---|---|
name | The key in the node's params object. Unique at its level. |
label | { fr, en }, required. |
description | { fr, en }, shown as help. |
required | The value must be set. |
requiredFor | A pure function of the graph context that replaces required when the context is known (for example: a field required only when no trigger provides an email). |
templatable | Whether {{ }} is accepted. Text types (string, text, values of a keyValue) accept it by default; other types need templatable: true. templatable: false on a text field is an explicit opt-out. |
displayIf | Conditional display, from operators such as eq, neq, in, notIn, exists, empty, combined with all / any. A hidden parameter is neither required nor validated, and its value is kept. |
requiresEmailFor | For a top-level boolean, options or multiOptions parameter: the values that only make sense with a triggering email. Validation refuses to publish a graph without one. |
Types and their specific fields:
| Type | Value | Specific fields |
|---|---|---|
string, text | A string (text is multi-line). | default, defaultFor, placeholder, maxLength, suggestions |
number | A number. | default, defaultFor, min, max, integer |
boolean | true / false. | default, defaultFor |
options | One value among options. | options (value, label, description), default, defaultFor |
multiOptions | Several values among options. | options, default |
collection | A list of objects whose fields are themselves ParamSpec. | of, minItems, maxItems, default |
keyValue | A list of { key, value }. | default |
conditions | A tree of conditions on the message (the same as the email trigger's). | scope, default |
credential | The opaque identifier of a connection. Never templatable. | credentialType, capability, provider |
resourceLocator | A remote resource (folder, calendar, table) chosen from a list, by identifier or by URL. | resource, modes, credentialParam, parentParam |
default is the value when the graph context is unknown. defaultFor, a pure function of the graph context (the trigger types and whether runs carry an email), replaces it when the context is known. A contextual default is never written when the node is created, so it follows the graph if the trigger changes.
execute receives the parameters already resolved: expressions rendered and defaults applied. Read them with the helpers of packages/nodes/src/catalog/read-params.ts (for example readOptionalBoolean, which also accepts a boolean rendered as text by an expression) rather than trusting their types.
Flag has two optional booleans and, deliberately, no default: an absent value means "do not touch".
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 declares how the node connects to others on the canvas.
| Field | Role |
|---|---|
in | The input ports. ['main'] (the MAIN_PORT constant) for every step; [] for triggers and providers. |
out | The output ports: a fixed list ([MAIN_PORT], or ['item', 'done'] for the loop), or a pure function of the parameters. Categorize, for example, computes one output per category. The function is evaluated by the editor on every keystroke: it must be total, without clock, randomness or side effect; invalid names and duplicates are dropped. |
services | Service input ports, such as model of kind llm.model on AI nodes. Nothing flows through them at run time: the engine reads the wiring and injects the matching service. Not wired means "use the instance defaults". |
provides | Makes the node a provider (see above). |
body | The output port that opens a loop body. Only the loop node declares it. |
A port name must be non-empty, trimmed, printable, and at most 200 characters.
Flag has one input and one output: ports: { in: [MAIN_PORT], out: [MAIN_PORT] }.
Policy: effect, replay, retry, timeout
policy tells the engine how dangerous the node is. It drives test runs, the kill switch and the organisation's node policy.
| Field | Values | Meaning |
|---|---|---|
effect | none, external_write, send | Required and honest. none: pure (AI, transformation, condition). external_write: writes outside the product (move, label, HTTP write, create a record). send: sends an email. A node that can write declares it even if most of its actions only read. |
replaySafe | boolean | Whether replaying the node after an interrupted attempt is safe. Default: true for none, false otherwise. Declaring true on a node with an effect is a commitment that its writes are deduplicated by the product. Otherwise an interrupted attempt is concluded as uncertain and left to the member. |
retry | { maxAttempts, delayMs? } | The type's retry defaults. Absent: the engine's defaults. |
timeoutMs | number | The default timeout of one attempt, in milliseconds. Absent: the engine's default. |
The AI nodes share one policy (AI_NODE_POLICY in packages/nodes/src/catalog/ai-common.ts) as an example of retry and timeoutMs. Flag declares { effect: 'external_write', replaySafe: true }: its write goes through the product's outbound operations, keyed on the step.
A node whose effect is not none must pass context.idempotencyKey to every external call. That key is derived from the step and makes a replay after a crash harmless.
requires: the triggering email
requires: 'email' declares that the node acts on the triggering email (Move, Flag). Workflow validation then refuses to publish a graph in which a trigger starts runs without an email, before the first run. The check in execute stays as a safety net for the cases validation cannot decide (a workflow called by another one).
The execute function
execute(context) is an async function. It must always return a promise, even on hostile input: the catalog test calls every node with no service wired and absurd parameters, and fails if execute throws synchronously.
The context (NodeExecutionContext) contains:
| Field | Content |
|---|---|
email | The triggering email (CanonicalMessage), read-only, or undefined for a run started without one (schedule, webhook). |
data | The data accumulated by earlier steps. |
params | The resolved parameters. |
services | The injected services: context.services.get(TOKEN) returns the service or throws if it is not wired; has(TOKEN) tests it. |
idempotencyKey | The step's key, to pass to every external effect. |
abortSignal | Timeout and cancellation. Every network call must honour it. |
logger | A minimal logger (debug, info, warn, error). Never log email content or secrets. |
execution | Optional { id, workflowId } of the current run, nothing more. |
locale | Optional 'fr' or 'en': the language of the member, for texts a human reads (step summaries). |
simulated | true during a test run. |
A node never imports a connector, a provider SDK or the database. It only uses the services declared in packages/nodes/src/services.ts and obtained from the context: mail (MAIL_SERVICE), HTTP (HTTP_SERVICE), attachments, LLM (LLM_SERVICE), profiles, tables, integrations, Google and Microsoft services, and the flow services.
execute returns a NodeResult:
| Field | Meaning |
|---|---|
output | The output port taken, or null to end the branch without error. |
data | The patch the step publishes. Later nodes read it as {{ data.<step>.field }} (see Data and expressions). |
suspend | Optional: the step suspends instead of concluding (approval, wait, called workflow, loop). The node never blocks; it returns a description (kind, title, timeoutMs, outcomes, onTimeout) and the engine persists the wait. Only these four kinds exist. |
Errors
Nodes throw the errors of packages/nodes/src/errors.ts. The only question that matters to the engine is whether retrying can help:
PermanentNodeError: retrying changes nothing (invalid parameter, missing email, business 4xx). The step fails.RetryableNodeError: transient (network, 5xx, cancellation). The engine may retry the step.
Both take a stable code (lowercase, family_problem style), an English technical message, and options cause and details. details holds metadata only, never email content. Common codes are in NODE_ERROR_CODES: node_missing_email, node_invalid_param, node_nothing_to_do, node_aborted, node_service_failed. When you wrap the failure of a service call, relayCallFailure keeps the classification the service already made instead of turning a definitive refusal into a retry.
Call throwIfAborted(context.abortSignal, TYPE) before and after every wait on a service: a node that ignores cancellation keeps a step alive beyond its timeout.
How failed steps surface to members (Error output, retries, error workflow) is described in Errors and retries.
Test runs
During a test run, context.simulated is true and nodes with an effect must describe what they would do instead of doing it. In most cases the node has nothing to write: the engine injects services that simulate writes. The mail service, for example, records nothing at the provider and returns simulated: true; the integration service performs the real reads and describes the writes. Flag simply copies the simulated flag returned by the service into its data.
A node whose effect does not go through such a service checks context.simulated itself. The approval, wait and signal nodes do this.
If the data published in a test run differs from a live run, document both shapes in the node's documentation source.
Step summaries and i18n
The node's labels, descriptions and options are written in the definition, in French and English. Any other text a human reads at run time, such as a step summary, goes through the node dictionaries packages/nodes/src/i18n/fr.ts and en.ts, never as a sentence built in code.
Use summaryPatch(localeOf(context), 'key', params) from the nodes i18n module: it adds summary (the rendered sentence), summaryKey and summaryParams to the data, so the editor can re-render the summary in another language later. Message syntax: {name} for interpolation, {count|singular|plural} for agreement with the plural rule of each language. One key is one whole sentence.
Prompts sent to a language model are written in English in the code; the parameter's description tells members they may rewrite a prompt in their own language.
Two tests guard this: packages/nodes/src/i18n/hardcoded.test.ts fails on hard-coded French in the catalog, and packages/nodes/src/i18n/i18n.test.ts checks that both dictionaries have exactly the same keys. If your node introduces a new error code, give it a message under errors in packages/ui/src/i18n/locales/fr.json and en.json; the interface translates error codes from there.
Register the node in the catalog
The catalog is a static list in packages/nodes/src/catalog/index.ts:
- import the definition and add it to
CATALOG_NODES, in its family's place; - add it to the re-export block at the end of the file, with its parameter type;
- update
packages/nodes/src/catalog/catalog.test.ts, which lists every node key in order and the effect of every type: a new node fails this test until you add it, on purpose.
createCatalogRegistry() builds the registry from CATALOG_NODES; the server, the editor and the documentation all read the same list.
Test the node
Each node has its test file next to it (mail-flag.test.ts). The helpers of packages/nodes/src/testing/context.ts build a full context:
runNode(definition, { params, services, email, simulated, locale, abortSignal, … })applies the node's defaults like the engine, then callsexecute;bind(TOKEN, double)wires a service; doubles exist for the mail, HTTP, attachment, LLM and profile services (createMailService,createHttpService,createAttachmentService,createLlmService,createProfileService);emailFixture()is the triggering email provided by default; passemail: undefinedto test a run without one;createSignal()gives a cancellable signal; the default idempotency key isstep-42.
Cover at least the declaration (key, effect, defaults that validate) and the behaviour: data produced, idempotency key passed to the service, permanent and retryable errors, cancellation. The commands are in Testing.
ts
it('only sends the states that are set', 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' }]);
});Documentation source
Every node of the catalog must have a documentation source in two files: apps/docs/content/nodes/<type>.en.md and apps/docs/content/nodes/<type>.fr.md. The type keeps its dots: mail.flag.en.md, attachment.extract_text.fr.md. The generator writes everything else from the definition: names, description, parameter table, ports, effect, required connection.
The source has a YAML frontmatter with a closed schema, then free Markdown:
markdown
---
ports: # optional: the meaning of each OUTPUT port (key = exact port name)
main: "Taken when …"
data: # required, `data: []` if the step publishes nothing
- expression: "{{ data.<step>.opId }}"
type: string
description: "What this value is."
---
Introduction: what the node is for, when to choose it over a neighbour.
## Example
A realistic example with exact parameter names and the resulting data.
## Tips
Optional: real pitfalls, limits, error codes.The generator fails, and so does the documentation build, when:
- a node has no source in one of the two languages;
meta.descriptionis empty in one language;- the frontmatter has an unknown key or a malformed entry;
- a key of
portsis not an output port of the node (unless its outputs are computed from the parameters); - a
dataexpression has no{{ }}or does not parse (<step>is accepted as a placeholder); - a provider declares data (it must be
data: []); - any page contains the product's code name or a work-in-progress marker in capitals.
The generator runs before every documentation build and dev server. It reads the compiled packages, so build them first:
sh
pnpm build # or at least the packages the docs read
pnpm --filter @manko/docs generate # generate pages only
pnpm --filter @manko/docs dev # generate, then serve the site locally
pnpm test --project docs # tests of the generatorChecklist
[ ] packages/nodes/src/catalog/<name>.ts defineNode: type, version 1, meta, params, ports, policy
[ ] meta: fr + en name and description, icon, group, category, ≥ 4 aliases per language
[ ] policy.effect honest; idempotencyKey passed to every external effect
[ ] throwIfAborted around every service wait; errors classified permanent / retryable
[ ] test runs: writes go through a simulating service, or context.simulated is checked
[ ] step summaries via summaryPatch, keys in packages/nodes/src/i18n/fr.ts AND en.ts
[ ] new error codes under "errors" in packages/ui/src/i18n/locales/fr.json AND en.json
[ ] packages/nodes/src/catalog/index.ts: CATALOG_NODES + re-export
[ ] packages/nodes/src/catalog/catalog.test.ts: key and effect added
[ ] packages/nodes/src/catalog/<name>.test.ts
[ ] apps/docs/content/nodes/<type>.en.md AND <type>.fr.md
[ ] pnpm typecheck, pnpm lint, pnpm test --project nodes, pnpm --filter @manko/docs generate