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
| Key | What it is |
|---|---|
type | the node type name. PascalCase. This is the node’s identity. |
label | what the editor shows on the box |
description | one paragraph, saying what the node emits |
tags | strings, for search and grouping |
icon | a Lucide icon name in PascalCase |
color | a hex string for the node’s accent |
inputs | the input ports |
outputs | the output ports |
types | named custom types this node declares. See Custom types. |
requires_infra | true for infra nodes |
images | container image directories, for infra nodes |
publishes | the service name this node hands out a connection to |
service | the whole service recipe, for access nodes |
accessApps | a public, secretless OAuth app this project ships |
portsFromConfig | where a node’s ports come from, when they come from its own config |
firesWith | for a trigger, what a firing has to carry to start one |
features | boolean-ish flags |
display | what the editor renders inline on the node body |
validate | declarative 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.
| Kind | For |
|---|---|
select / multiselect | a small fixed vocabulary; takes options. A written value outside them is a compile error, a wired one fails the firing |
textarea | a multi-line box, for a String that holds prose |
code | a code editor; takes language |
number | takes 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) |
datetime | a calendar-and-clock picker for a String holding one moment; stores ISO-8601 with the picker’s zone offset |
password | a masked field |
file_drop | narrows the file filter beyond the type; takes accept |
text_list | a list of short text values, added one at a time |
entry_list | build the list a node’s ports come from (see below) |
access | the connection picker. See Using a connection. |
remote_select | pick 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), declareremote_selectso the user searches instead of copying an id out of the provider’s docs. Addfree_text: truewhen 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:
| Flag | Meaning |
|---|---|
isTrigger | this node starts executions from outside |
optionalCustomInputs | ports created by a wire on this node are optional by default |
customInputType | the 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 |
liveEndpoint | names the endpoint serving /live for an infra node |
canAddInputPorts | the .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. |
canAddOutputPorts | the same for outputs |
showDebugPreview | the editor renders the node’s latest output inline on its body |
oneOfRequired | groups 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. |
castPorts | this 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.