Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

metadata.json

The node’s declared surface. Ports, config, presentation, and the handful of flags the engine reads.

It is data rather than code because the compiler has to know a node’s shape without compiling its Rust, which is what lets the editor draw a graph and the validator check every wire in milliseconds.

Unknown keys are rejected everywhere, so a typo is a load error rather than a setting that silently does nothing.

The top level

KeyWhat it is
typethe node type name. PascalCase. This is the node’s identity.
labelwhat the editor shows on the box
descriptionone paragraph, saying what the node emits
tagsstrings, for search and grouping
icona Lucide icon name in PascalCase
colora hex string for the node’s accent
inputsthe input ports
outputsthe output ports
typesnamed custom types this node declares. See Custom types.
requires_infratrue for infra nodes
imagescontainer image directories, for infra nodes
publishesthe service name this node hands out a connection to
servicethe whole service recipe, for access nodes
accessAppsa public, secretless OAuth app this project ships
portsFromConfigwhere a node’s ports come from, when they come from its own config
firesWithfor a trigger, what a firing has to carry to start one
featuresboolean-ish flags
displaywhat the editor renders inline on the node body
validatedeclarative validation rules

features holds flags. Anything with structure gets its own top-level key.

A name Lucide does not ship renders as a plain square, with the reason in the console.

Inputs

Every value a node takes from the graph is an input port, one value per port. A node never asks the author to pack several values into one list or dict first: PostgresExecuteQuery once took its parameters as a List, and every query with two parameters cost the program a Python node whose whole body was return {'params': [a, b]}. When the set of values is open-ended (a query’s parameters, a template’s holes, a script’s variables), the node declares canAddInputPorts in features and the author declares the ports inline: PostgresExecuteQuery(user_id: String) { query: "... WHERE id = $user_id" }. The body reads them with ctx.inputs.custom().

{
  "name": "method",
  "type": "String",
  "required": true,
  "widget": { "kind": "select", "options": ["GET", "POST"] },
  "default": "GET",
  "label": "Method",
  "placeholder": "...",
  "description": "The HTTP verb to use."
}

accepts

Which of the two drivers this input takes. A value written in the source is a literal (whatever its spelling: in the braces, on its own n.x = ... line, a @file or @asset marker); a value another node produces is a wire (an edge, a dotted value in the braces, an inline node). Leave the key out and the input takes both, which is what almost every port wants.

"accepts": ["wire"]

An input never adds a form; it only removes one, and only for a reason the node can name. ["wire"] refuses every written value, for a port that needs a real node rather than a value (an inference node’s provider, history, params). ["literal"] refuses wiring. Getting it wrong is input-accepts, and the message reads the list back: “params accepts: wire”.

Two kinds of port carry no list, because their drivers are not yours to pick. A Bus or Generator port is wire-only by nature (a live handle no human can write); the loader forces it, and a list naming literal there is an error. A port the compiler reads to build the node, the list named in portsFromConfig or the input carrying the access picker, takes an inline typed value only: no wire, no @file, no @asset, so the node’s shape is readable in the source without following anything. That is a fixed rule nobody writes and no accepts loosens.

An input has exactly one driver. Two is double-driven-port.

widget

Overrides the editor control. Absent, the control derives from the type through one central mapping: file types get a drop control, Boolean a checkbox, Number a number box, a List[String] the add-one-at-a-time list, a String a single-line box, and everything else a text area holding the value as JSON text. A String field that really holds prose (a prompt, a message body) declares "widget": { "kind": "textarea" }.

A port that holds SEVERAL files (List[Audio], or a Media | List[Media] that takes one or many) gets the same drop control keeping a list: it picks several at once, adds one at a time, and writes one @asset(...) per file.

KindFor
select / multiselecta small fixed vocabulary; takes options. A written value outside them is a compile error, a wired one fails the firing
textareaa multi-line box, for a String that holds prose
codea code editor; takes language
numbertakes min / max / step; the editor and the compiler hold a written value to them, and the run holds a wired one (a whole-number step refuses a fraction)
datetimea calendar-and-clock picker for a String holding one moment; stores ISO-8601 with the picker’s zone offset
passworda masked field
file_dropnarrows the file filter beyond the type; takes accept
text_lista list of short text values, added one at a time
entry_listbuild the list a node’s ports come from (see below)
accessthe connection picker. See Using a connection.
remote_selectpick a resource inside the connected account

A field whose value names something enumerable is never a bare text field. If the vocabulary is small and fixed, declare select. If the provider can list the choices (model ids, voices, channels, databases, repos), declare remote_select so the user searches instead of copying an id out of the provider’s docs. Add free_text: true when a pasted id is also valid, for example a model route the list has not caught up with. Plain text is for free-form values: a prompt, a URL, a message body.

remote_select gets its own treatment in Using a connection.

required

Write "required": true on an input the node cannot run without. Leave the key off everywhere else: absent already means optional, so "required": false says nothing and reads as though somebody meant something by it. Outputs never carry it at all (metadata load refuses one that does).

default

The value the runtime supplies when nothing else drives the input. Consulted at run time, rendered by the editor as the effective value, and never written into source. required plus default is satisfiable with no driver at all.

requiresScopes and requiresValues

Only on Access inputs. They state what this node needs from a connection. See Using a connection.

Outputs

{ "name": "ts", "type": "String",
  "description": "The posted message's timestamp." }

Simpler than inputs: no widget, no default, and no required. An output has no optionality to declare: a port not present in a firing’s output emits no pulse, which closes it and skips what is downstream, whatever the metadata could have said. That is the closure rule. A required key on an output is refused at load, naming the removal.

firesWith

A trigger starts an execution from outside: a listener wakes it, or somebody types weft run --fire. Whatever they hand it is the fire payload, and it is neither the node’s inputs nor its outputs: it is the one thing that has to exist before the node’s own logic can even begin.

firesWith writes down the shape of that payload, as a flat object from field name to a weft type. It goes right before features.

"firesWith": {
  "scheduledTime": "String",
  "actualTime": "String"
},

A ? on the end of a field NAME, not the type, marks it optional:

"firesWith": {
  "method": "String",
  "caller?": "JsonDict"
},

Only a node with features.isTrigger may declare it, and every trigger should. The engine checks a real firing against this shape before the node’s body runs, and weft run --fire checks a hand-typed payload against it before building or starting anything. Either way, a listener whose fields moved, or a typo in a payload you typed yourself, is refused by naming the exact field that is wrong or missing, instead of failing somewhere inside the node.

It is the complete list, not a highlight of the useful bits

A payload carrying a field you did not name is refused, exactly like one missing a field you did. So name every field that can arrive, not only the ones you put on ports, and put a ? on the ones that only sometimes come.

This is strict on purpose. A trigger’s payload is the one value a node did not compute and cannot check for itself, and this is where it gets checked, once, before anything runs. Let an unnamed field through and what the node actually receives quietly stops being what the node says it receives, and the day that matters is the day some code reads a field nobody wrote down.

The cost is that a provider adding a field stops that trigger until somebody adds the field here. That is one line in a JSON file, and the refusal names the field, so the fix is obvious and takes a minute. The alternative is a node whose declared shape and real shape drift apart silently, which is not one line and not a minute.

If your trigger is fed by a connection’s events, you do not have to guess the list: the service’s recipe declares it, per topic, under events.<topic>.fields (writing a service). A firing carries those names and no others, minus any the provider’s event did not have, so that map is exactly what belongs here. weft-compiler/tests/fires_with.rs holds every shipped trigger to its topic’s list, in both directions, so a field you forget fails there rather than in front of somebody’s users.

The type strings are ordinary weft types, and a field can be a record nested to any depth:

"item": "List[{ id: String, tags: List[String] }]"

Two triggers in the catalog declare nothing, both for reasons no declaration could fix: ReceiveEmail opens its own IMAP session and reads the mail itself, so there is no payload field to name. HumanTrigger’s fields are the form fields somebody typed into that instance, which differ per node, so no static declaration could name them either.

features

The complete set, hidden aside, which only catalog nodes use:

FlagMeaning
isTriggerthis node starts executions from outside
optionalCustomInputsports created by a wire on this node are optional by default
customInputTypethe type every WIRED created port takes; a shared variable (T) makes them one type. A port created by a config literal takes the literal’s own inferred type instead, so a non-string literal on a String-typed node is caught at run time by the node, loudly
liveEndpointnames the endpoint serving /live for an infra node
canAddInputPortsthe .weft author may add input ports to this node, by declaring them or by wiring a config key that names no declared port. Without it, an extra port is a compile error. This is how a node takes an open-ended set of values (ExecPython, Format, PostgresExecuteQuery); never a List input the author has to assemble.
canAddOutputPortsthe same for outputs
showDebugPreviewthe editor renders the node’s latest output inline on its body
oneOfRequiredgroups of ports where at least one of each group has to arrive, or the node is skipped. [["message", "attachment"]] means a send needs one or the other.
castPortsthis node converts a named input into a named output’s declared type, checked against the conversion table at compile time

display

For a node whose firing produces or receives a file worth seeing.

"display": { "kind": "media", "output": "image" }

kind is media, which renders the file by its own mime type (an image inline, audio and video with a real player, anything unplayable as a file card with a save button), or link, which renders the metadata and download card only. There is no flag per media type: a newly playable format is a renderer change.

Name the port with its side, exactly one of output (a generator showing what it made) or input (a display sink showing what was wired in), which is what keeps a node with a same-named input and output unambiguous. Declaring both, neither, or a port that does not exist is refused when the catalog loads.

Package defaults

A package (a directory with package.toml) may hold a partial metadata.json at its root: defaults every member node inherits.

The merge is top-level and key by key: a member gets each key unless its own metadata.json carries that key, in which case the member’s value wins wholesale. There is no deep merge.

type, label, and description can never be defaults, because they are one node’s identity.

If a package’s nodes share a portsFromConfig vocabulary, a types block, or a provider name, this is where it goes.

catalog/human/
  package.toml
  metadata.json        PARTIAL: { "portsFromConfig": {...} }, shared by both
  form_helpers.rs      shared code
  trigger/  metadata.json    HumanTrigger
  query/    metadata.json    HumanQuery

Ports that come from a node’s own config

A node can grow its ports from a LIST in its own config: HumanQuery from the form fields somebody configured, Switch from its cases. It declares which config key holds that list, and the entry kinds the list may use, under portsFromConfig. The real ports are derived at compile time from what the graph author wrote.

{
  "portsFromConfig": {
    "field": "fields",
    "specs": [
      {
        "kind": "approve_reject",
        "label": "Approve / Reject",
        "render": { "component": "buttons", "source": "static" },
        "fields": [
          "label",
          { "key": "approveLabel", "label": "Approve button text", "shape": "typed", "valueType": "String" },
          { "key": "rejectLabel", "label": "Reject button text", "shape": "typed", "valueType": "String" }
        ],
        "addsOutputs": [
          { "nameTemplate": "{key}_approved", "portType": "Boolean" },
          { "nameTemplate": "{key}_rejected", "portType": "Boolean" }
        ]
      },
      {
        "kind": "text_input",
        "label": "Text input",
        "render": { "component": "text" },
        "addsOutputs": [ { "nameTemplate": "{key}", "portType": "String" } ]
      }
    ]
  }
}

Every entry names its kind, and the port it adds under the key its spec asks for: keyField defaults to "key" (a form field), and Switch sets it to "port" so a case reads in its own vocabulary. {key} is substituted with that name. T_Auto as a port type requests a per-entry type variable.

Derivation reads only each entry’s kind and its port name. It does not read an entry’s render from the graph source; that is inherited from the spec. An entry may override render, but it need not, and the editor emits the minimal entry so the source stays lean.

A port name has to be a legal identifier.

What a kind asks the author for

A kind lists under fields the values an entry of that kind has to carry: a select’s options, a case’s value. Each one declares the SHAPE its value must have, which is what the compiler holds it to, and the editor draws a control for it from its widget. Nothing between the metadata and the graph learns what any key means.

{ "key": "options", "label": "Options", "required": true,
  "shape": "typed", "valueType": "List[String]" }

An entry may also be a bare NAME, which is the same declaration with everything obvious filled in: a one-line String box, keyed and labelled after the name, optional.

"fields": ["label", "placeholder"]

is exactly

"fields": [
  { "key": "label", "label": "Label", "shape": "typed", "valueType": "String" },
  { "key": "placeholder", "label": "Placeholder", "shape": "typed", "valueType": "String" }
]

A name of several words reads as words, so minLength and min_length both label as “Min Length”. Write the whole declaration when the label is not the key (approveLabel wants “Approve button text”), when the value is not a String, or when it is required.

shape: "typed" names a plain weft type in valueType. The other shapes are measured against the input the entries are matched on, so they only make sense on a list whose entries compete (below): value (something that input could hold), valueList, number, element (one item of a list, or a piece of the text), and regex, which has to compile.

The control comes from the shape unless the field names a widget, using the same widget vocabulary an input port uses. A List[String] gets text_list, a number shape gets a number box, everything else a text box.

Entries that compete

When the entries are TRIED IN ORDER and only one wins (a switch’s cases, never a form’s fields), the KIND is the test: one kind per way of matching, each asking for the value it compares against. matchInput names the input they are all matched against, and the kind that takes anything says catchAll.

{
  "field": "cases",
  "matchInput": "value",
  "specs": [
    { "kind": "equals", "keyField": "port", "label": "is exactly this value",
      "fields": [{ "key": "value", "label": "Value", "required": true, "shape": "value" }],
      "addsOutputs": [{ "nameTemplate": "{key}", "portType": "Boolean" }] },
    { "kind": "between", "keyField": "port", "label": "between these two numbers",
      "fields": [
        { "key": "min", "label": "Lowest", "required": true, "shape": "number" },
        { "key": "max", "label": "Highest", "required": true, "shape": "number" }
      ],
      "addsOutputs": [{ "nameTemplate": "{key}", "portType": "Boolean" }] },
    { "kind": "otherwise", "keyField": "port", "label": "anything else",
      "catchAll": true,
      "addsOutputs": [{ "nameTemplate": "{key}", "portType": "Boolean" }] }
  ]
}

The compiler holds a catchAll entry to being unique and last, since anything after it can never be reached. An entry carrying a key its kind never declared is a compile error.