Declare a node's configuration and ports
Use this recipe when a node's configuration has structure — a list of fields to extract, questions with their possible answers — and its ports depend on it. The manifest carries everything the core needs: it validates the configuration, computes the ports in the editor, the static validation and the interpreter, and draws the editor's form, without any code of your plugin running there. The full grammar is in Declared configuration and ports.
1. Describe each configuration field
Give each structured field a schema, a subset of JSON Schema (draft-07). Mark
it typeHint: true when it is a contract rather than a business parameter —
then it can be neither exposed as a port nor wired — and required: true when
it must be set.
config:
- name: questions
type: { kind: any }
typeHint: true
required: true
schema:
type: array
title: Questions
minItems: 1
x-uniqueBy: name # no two questions with the same name
items:
type: object
required: [name, type, instructions]
discriminator: { propertyName: type }
oneOf:
- title: Oui / non
properties:
name: { type: string, title: Nom, x-widget: identifier }
type: { const: yesno, title: Type }
instructions: { type: string, title: Énoncé, minLength: 1, x-widget: textarea }
additionalProperties: false
- title: Choix
properties:
name: { type: string, title: Nom, x-widget: identifier }
type: { const: choice, title: Type }
instructions: { type: string, title: Énoncé, minLength: 1, x-widget: textarea }
options:
type: object
title: Options
minProperties: 2
additionalProperties: { type: [string, "null"] }
required: [options]
additionalProperties: false
This excerpt comes from the decision.ask node of @posos/decision
(external-plugins/decision/plugin.yaml), a complete example with a score
variant and derived inputs.
The editor renders it: a card per question, a selector for its variant, a table
for the options, a text area for the wording. title and description label and
explain each field; x-widget: typeName gives a type selector and
x-widget: identifier restricts the input to a port-name alphabet.
When a config field is itself one object chosen among variants (discriminator +
oneOf at the field's top level, as for the operation of a node that offers
several), the node's card in the graph shows the title of the selected variant
under the node label. Give each variant a short title naming the operation
(Filtrer, Trier): without it, every card of that node type reads the same.
2. Derive the ports
Declare derivedPorts instead of inputs/outputs. One rule gives one port, or
one port per item of a list with forEach:
derivedPorts:
outputs:
- forEach: questions
name: { from: name }
description: { from: instructions }
type:
byValue: { from: type }
cases:
yesno: Decimal
choice: { kind: record, name: DecisionChoice, fields: { choice: { type: { kind: primitive, name: String }, required: true } } }
- { name: model, type: String, description: Modèle qui a répondu. }
A name that is invalid, duplicated or reserved (after, done) gives no port;
static validation names it. For a record whose fields come from a list — the
HL7 and PN13 mapping nodes — use { listOf: { record: { name, forEach, field } } }.
A family of operations can use one discriminated oneOf config schema and
conditional ports. Declare sdk: "^0.1.5" and add when to each relevant rule:
derivedPorts:
inputs:
- name: items
type: { listOf: { fromConfig: type } }
- name: other
type: { listOf: { fromConfig: type } }
when: { value: { fromConfig: operation.kind }, in: [concat] }
Here other exists only for concatenation. The editor, static checker, execution,
simulation and workflow assistant consume the same rule. Keep operation marked
typeHint: true: the port contract must be known before execution.
3. Add host-side assistance (optional)
When the configuration needs code to be checked or tested, point assist at a
module exporting a NodeAssist:
import type { NodeAssist } from "@meridian/shared/plugin/assist";
export const assist: NodeAssist = {
// Rules beyond the schema: run at save, at publication and in the editor.
validateConfig: (config) => (config.questions as unknown[]).length > 50 ? ["50 questions au plus."] : [],
// Values for the fields marked `x-suggest: <key>`, taken from the sample.
suggest: (key, sample) => (key === "sourcePath" ? sourcePaths(sample).map((value) => ({ value })) : []),
// The result shown by the editor's workshop.
preview: (sample, config) => apply(sample, config),
};
With preview, the editor opens a workshop: sample on the left, configuration in
the middle, result on the right. assist.example pre-fills it, and
requirePreview: true applies a configuration only after a successful test.
Nothing about the sample is stored.
4. Check it
node --import tsx libs/plugin-cli/src/cli.ts validate <workflow.yaml> --plugin <dir>checks a workflow against the schemas and the port rules (the plugin's own checks run in the host only).- In a unit test,
validateSpec(@meridian/engine-core/engine/validate) andnodePorts(@meridian/shared/engine/dynamic-ports) give the errors and the ports the console will show. meridian-plugin i18n extract --locale enadds the keys of the schema titles, descriptions and port descriptions to the English bundle.
Related
Reuse the expression language
For a plugin calculation, import compiledExpression from
@meridian/shared/engine/expression and expressionProblems from
@meridian/shared/engine/expression-check. These are the same JEXL evaluator,
transforms and checker used by guards, selectors and conversions. Pass the
allowed roots to the checker (for example ["inputs"]), then provide only the
explicit input scope to evalSync. Validate the result against the declared port
type with validateValue; undefined is an absent output, not JSON data. Do not
add a JavaScript evaluator or a separate expression grammar.