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

Weft The book

metadata.json

Every node has one, beside its mod.rs. It declares the node’s type, its ports, and everything the editor and the compiler need to know about it without reading your Rust.

If you write a key weft does not define, the node fails to load: it is left out of the catalog, with a warning naming the error, and a program that uses it fails to compile, saying the node failed to load and why. Places that hold someone else’s names, like authParams or an input’s default, take any key. Under accessApps, an entry takes label, client_id and the registration keys its service declares; for how that is checked, go and read accessApps. A port’s type is parsed when the file loads, so a misspelled type (Strng) fails then too.

Three keys are required: type, label and description.

Spelling

The node’s type and any types you declare under types are PascalCase (WordCount), and a type variable under typeParams is T, or T and a number (T1). Every key weft defines (requiresInfra) is camelCase, and so is every port, setting and record field you name. So are the fields under firesWith, and the names under each event’s fields in a service’s events (for what those are, go and read Events from a service). The names of the values a connection stores (imapHost) are camelCase too, both where a service declares them (the name of each entry in fields, captures and registrationFields) and where an input asks for them in requiresValues.

Two kinds of key are snake_case: a service’s event topics (drive_changes) and the keys under accessApps (google, the service’s name). They are snake_case because a node also writes them as values (a trigger picks a topic by name, and a node names its service as "service": "google"), and every value from a fixed list is snake_case. A name somebody else chose keeps their spelling, like an OAuth parameter under authParams (access_type), or a field of a request body you send to a provider. Inside an accessApps entry, client_id keeps OAuth’s spelling, and any other registration key is spelled exactly as the service names it in its registrationFields.

If you write a key weft renamed in its old snake_case spelling (requires_infra), the node fails to load, and the error names the new spelling: “requires_infra is spelled requiresInfra now … Rename it”. In a package root’s metadata.json (the defaults its members inherit), the error is reported against the root’s file, and the package’s members are left out. For how those defaults work, go and read Package defaults.

If any other name in a metadata.json breaks these rules, the node still loads and you get a catalog warning. Your project’s own nodes get this check too. It also covers the field names and port templates a portsFromConfig spec declares. The check never reads a node’s data, so it says nothing about a select’s options or an input’s default.

If you have the graph open in the editor, these warnings show up in the graph view’s problems list as “node spelling warning”. On the command line, weft validate < src/main.weft and weft describe-nodes --list print them on stderr, and the JSON from weft validate and from bare weft describe-nodes lists them under warnings, each as {"kind": "spelling", "message": ...}. On stderr a warning looks like this, and the JSON message is the same line without warning: :

warning: <NodeType> (<path to metadata.json>): <message>

If a name is off in a package root’s metadata.json, including a port it gives its members and that port’s record fields, the warning names the root, not each member, and starts package '<name>' defaults (<path>) instead of a node type. A declared type’s fields are checked once, where it is declared.

If you are changing a node that ships with weft, the catalog’s casing test fails on any such warning. For the command, go and read the Code style section of CONTRIBUTING.

The top level

KeyTypeDefaultWhat it does
typeStringrequiredThe node’s catalog id. Has to be a valid Rust identifier
labelStringrequiredThe name shown in the editor
descriptionStringrequiredOne line, shown to people and given to the AI builder
tagsList[String][]Search words for the node picker
iconStringnoneA lucide icon name
colorStringnoneHex, a CSS variable, or a palette token
inputsList[]Everything the node takes, wired data and written settings alike
outputsList[]The ports it emits on
metadataPortsList[]Ports whose value you write here, which no run computes. Go and read metadataPorts
requiresInfraBooleanfalseThe node provisions containers and must implement provision_infra
imagesList[String][]Directories under the package root, each holding a Dockerfile to build
publishesStringnoneThe service whose connection this node hands out itself. Needs an Access[<that service>] output too
featuresObjectall offSmall flags about what the node is
displayObjectnoneWhat to render on the node’s body after a firing
validateList[]Rules the compiler checks against the graph
portsFromConfigObjectnoneSays this node’s ports come from a list in its own config
typesMap{}Named types this node contributes to the project. Go and read custom types
typeParamsMap{}What each type variable of the ports may stand for. Go and read typeParams
placeholdersAreInputsObjectnoneWhich text setting’s placeholders name the node’s inputs. Go and read placeholdersAreInputs
firesWithMap{}For a trigger: the shape of the event it wakes with
claimsRouteObjectnoneWhich config fields hold the public address this node claims
accessAppsMap{}Public OAuth apps this node ships, by service
serviceObjectnoneThe connect recipe, on an access node. Go and read declaring a service

inputs

KeyTypeDefaultWhat it does
nameStringrequiredThe port name, unique among inputs
typetype stringrequiredWhat it takes
requiredBooleanfalseWrite it only when true. true: the node cannot run without it, and a program cannot make it optional. Left out: unwired, the node runs without it; wired, it waits for the wire and skips if the wire closes, and a program can change this where it uses the node. For how, go and read optional and required inputs
acceptsListboth["wire"] refuses a written value, ["literal"] refuses an arrow. A Bus or Generator port is wire-only whatever you say
widgetObjectfrom the typeThe editor control
defaultanynoneWhat the runtime supplies when nothing drives the input. Never written into source
labelStringthe nameThe field’s name on the form
placeholderStringnoneHint text in the empty field
descriptionStringnoneA sentence next to the field
requiresScopesList[String]nonePermissions this consumer needs on the wired connection. Connection inputs only
requiresValuesList[String]noneStored values it needs. Connection inputs only
keepsBlankBooleanfalseOn an optional input that takes text, a blank text is a value your node uses (a separator, a preview of what arrived). Without it, a blank counts as no value; see below

A blank text counts as no value

If you take an optional input that takes text (String, or a union with String in it, like String | List[String]), a blank text on it (empty, or only spaces) reaches your node as nothing at all, whether it was written in the program or arrived on a wire. ctx.inputs.opt::<String>("title")? gives None, a default fills it as it would fill any missing input, oneOfRequired counts it as not given, and the rules below see it as not set. So read it plainly, with no filter of your own:

let title = ctx.inputs.opt::<String>("title")?;

This holds for the inputs your metadata.json declares. A port the program adds to your node keeps whatever arrives on it, a blank included, because it is the program’s data, and so does a required input. If a blank is a real value for your node, set "keepsBlank": true on the input; on a required input or one that does not take text, the node fails to load. A list is still a value, even an empty one: only a blank text counts as nothing.

outputs

KeyTypeDefaultWhat it does
nameStringrequiredThe port name
typetype stringrequiredWhat it emits
descriptionStringnoneA sentence next to the port
narrowabletrue or a type stringnoneFor an output whose shape only the program knows (a model’s JSON answer, a request’s body, a query’s rows): type it broadly and set this, and a program declares what it holds (-> (response: Verdict)). true lets a program declare any type that fits inside type. A type string lets it declare anything that fits inside that type instead, which may be wider than type. Each value is checked against the declared type at run time, and one that does not fit fails the node, a failure the program can catch on _error. Without it, a program can only restate the output’s own type
bakedBooleanfalseOn an infrastructure node: the value is worked out when the node’s infra is applied, and saved, so a run that reads nothing else from the node uses the saved value instead of running the node. For how, go and read baking

An output has no required. A firing that emits nothing on a port closes it, which is what downstream reads.

Do not declare _error: weft gives every node that output for its failures, and the loader refuses a metadata.json that declares it. For what comes out on it, go and read which failures a program can handle.

metadataPorts

A metadata port holds a value written here, by hand, and the node never computes it. A wire a program draws from it is a metadata wire: before the program runs, the compiler writes the value into the input the wire points at. For what that is for, go and read metadata ports.

"metadataPorts": [
  { "name": "tool", "type": "{ name: String }", "description": "What this node is, for a model", "value": { "name": "pick" } }
]
KeyTypeDefaultWhat it does
nameStringrequiredThe port name. It may not be the name of another of the node’s ports: an input, an output, another metadata port, _error, _should_flow or _should_not_flow
typetype stringrequiredWhat the value is. No type variable: nothing a program wires can tell what one would stand for
descriptionStringnoneWhat the port holds, for people and for the AI builder; weft describe-nodes shows it
valueanyrequiredThe value, which has to fit type

If one of these rules is broken, the node fails to load, and the message names the port.

features

KeyTypeDefaultWhat it does
isTriggerBooleanfalseThis node starts runs from outside instead of running inside one. Weft gives it callsPerMinute, callsAtOnce, durable, recorded, keepRunsFor, holdSecs and pauseAfterSecs inputs (and outlivesCaller too when it sets liveConnection), so do not declare those
liveConnection"http" or "websocket"noneOn a trigger whose run answers a caller holding the connection open: "http" for a request waiting on its response (a route), "websocket" for a socket. It also gets callsPerMinutePerCaller, journalEphemeral and journalWindowMs
journalSettingsBooleanfalseSet it on a node that opens a bus and wants its users to choose how the run’s record keeps it: the node gets the journalEphemeral and journalWindowMs inputs, and reads them with ctx.journal_policy()?. Weft refuses it together with liveConnection (which gives the same two inputs) and refuses a node that declares either input itself. For what they do, go and read two modes
checksCallersBooleanfalseOn a liveConnection trigger with an auth setting: weft checks every caller against it, with a connection of the node’s own picked on each install (weft connect <node>), before a run starts. Route and Socket set it. For the schemes, go and read checking a signed request
answersCaller"whole", "stream" or "end"noneThis node answers its run’s live caller. "whole": the response in one go (a Reply). "stream": the response head, then the body piece by piece (a Stream). "end": the end of the exchange (a Close). Set it on your own node too if it answers through the ctx, so a route’s “never answers its caller” warning counts it
calledFromOutsideBooleanfalseOn a trigger somebody outside calls whose call ends there (a form somebody submits). It also gets callsPerMinutePerCaller. A liveConnection trigger is called from outside already, so it never sets this; a trigger that picks its events up itself (a schedule, a feed, a provider’s push) has no caller and does not either
pureBooleanfalseSet it when the node’s body does nothing outside its run that weft does not put on record first: no network, no connection, no wait on a signal (state.wait), no tag, no storage beyond the run’s own files. It may answer its caller (a durable run’s answer leaves only once its record is written), read stored files, store files for this run alone, hand its caller links to them, use a bus to another node of the run, and keep a state (ctx.keep), which goes to the run’s own record. A durable run then starts it without waiting for the database, and may run it again after a crash (for when, go and read surviving a restart). A pure node that reaches outside through the ctx anyway fails at that call, naming this flag; weft cannot see a body that opens a socket or a file on its own, so if you mark one of those pure, a durable run may do what it does twice after a crash. Text, JsonObject, Switch, Route and Reply are pure; HttpRequest and PostgresExecuteQuery are not, because each one reaches outside the run on its own
machineInputs{cpu, memory}noneOn an infrastructure node whose users pick its machine size: gives it cpu, memory and machineType inputs, cpu and memory defaulting to the sizes you write. Needs requiresInfra. For how to read them, go and read the spec
oneOfRequiredList[List[String]][]Each inner list is a group where at least one port must arrive, or the node skips. A blank text on an optional input that takes text counts as not arrived (a blank text counts as no value)
canAddInputPortsBooleanfalseSource may declare extra inputs on it
canAddOutputPortsBooleanfalseSource may declare extra outputs
addedPortsAreDataKeysBooleanfalseSet it when the ports a program adds are named after data from outside weft: a path’s captures, a query’s parameters, a template’s ids, the keys of a JSON object it builds. The compiler’s not-camel-case warning then leaves every port a program adds to the node alone, and still checks the name the program gives the node. The nodes that ship with weft and set it are Route, JsonObject, ElevenLabsRunTemplate, PostgresExecuteQuery, PostgresWatchQuery and PostgresUpdateRows
joinBooleanfalseSet it when the node meets several branches and goes on with whichever arrived, like FirstInOrder. The inputs a program adds are optional (a closure on one does not skip the node), it runs once everything it waits on arrived or closed, and when nothing arrived it closes its output itself. The compiler’s run-undecided check skips it, so it needs no _should_flow even when every wired input is optional
customInputTypetype stringnoneThe type every added input takes. Naming one variable makes them all one type
customOutputTypetype stringnoneThe type every output a program adds takes, when the program does not give it one. If you name a type variable that one of the node’s own inputs uses (T on Dispatch’s value), each added output carries whatever that input received: a router’s branches carry what it routes. Needs canAddOutputPorts
pairsAddedPortsBooleanfalseSet it when each input a program adds should come with an output of the same name: the compiler makes that output, so the program does not have to declare it. An added output with no input of its name, an added output nothing reads, and an added input named after one of the node’s own outputs are each the unpaired-port error. Dispatch sets it: route.search = search.tool adds a branch, and route.search is where it sends what that branch claims. Needs canAddInputPorts and canAddOutputPorts
showDebugPreviewBooleanfalseRender the last output as JSON on the node body
liveEndpointStringnoneThe declared endpoint the dispatcher proxies /live to
castPorts{input, output}noneDeclares the node a checked cast between those two ports
hiddenBooleanfalseKeep it out of the picker and out of describe-nodes

display

KeyTypeWhat it does
kind"media" or "link"media renders by type: images inline, a real player for audio and video, a file card otherwise. link is always a file card with a download button
inputStringShow this input’s value
outputStringShow this output’s value

Exactly one of input and output, never both and never neither.

widget

Every widget, by its kind:

kindOther keysWhat it draws
textOne line of text. The default for String
textareaA wrapping box. The default for anything JSON-shaped
codelanguageA code box with highlighting. language is one of python, javascript, sql, json, and any other word fails to load. json also fits a structured input (JsonDict, a list), edited as JSON text; the others edit a String
numbermin, max, stepA number box. min and max are real rules the runtime enforces, and a whole-number step ("step": 1) means the input takes whole numbers: a written value is checked when the program compiles, a wired one when it arrives. A fractional step is only the arrow keys’ increment
checkboxA tick box. The default for Boolean
datetimeA date and time picker. It is a real rule too: the value must be a moment written with its zone (RFC 3339, like 2026-09-03T11:00:00+02:00 or 2026-09-03T09:00:00Z), checked when the program compiles for a written value (literal-out-of-range) and when it arrives for a wired one. Your node reads it as a date, ctx.inputs.get::<chrono::DateTime<chrono::Utc>>("at")?, once it declares chrono = { version = "0.4", features = ["serde"] } in its deps.toml (weft does not hand chrono on)
selectoptions, freeTextA dropdown. options cannot be empty, and they are a real rule: a written, wired or instance-provided value outside them is refused, and so is a default. If the list is only suggestions (model ids, voices, anything the provider adds to), set "freeText": true: the editor still offers the list, lets the user type anything else, and nothing is refused
multiselectoptions, freeTextMultiple choice over a List[String], each item held to options the same way, unless freeText is set
text_listShort strings added one at a time. The default for List[String]
passwordA masked box
file_dropaccept, type, multipleA file picker that writes an @asset(...) reference
entry_listBuilds the config list this node’s ports come from. Only legal on the input portsFromConfig names
accessThe connection picker. Write just {"kind": "access"}: the service and whether it is optional are filled in from your service recipe
remote_selectaccess, sources, dependsOn, freeTextPick a resource on the connected service

Leave widget out and weft picks one from the type: a file type gets file_drop, Boolean gets checkbox, Number gets number, List[String] gets text_list, String gets text, and everything else gets textarea.

remote_select sources

kindKeysWhere the options come from
grantedfrom, label, valueThe connection row itself, recorded at sign-in. Costs no call
lista Lookup, plus requiresCalling the service and enumerating
pickerscript, code, grants, mimeTypesThe provider’s own chooser, opened on a weft page
from_urlpatternThe person pastes a link and the first capture group is the id

A Lookup takes get, items, label and value, and optionally page (an object holding cursorParam and cursorPath), public, where, errorPath and okPath. get is the URL to call. items is the dotted path to the array in the answer (data.channels for {"data": {"channels": [...]}}), or "" when the answer is the array itself. label and value are dotted paths inside one item: the text the person sees and the id that gets saved.

If you want the service to do the searching, put {query} in get, and it becomes what the person typed in the search box. If get has no {query}, the service never sees the search text, and the list only shows the items whose label or id contains it, ignoring case. If you want another input’s value in the URL, list that input in the widget’s dependsOn and write its name in braces: {team} becomes whatever is set on team. Weft percent-encodes every value it puts into the path or the query. A value before the path, like a {base} holding https://..., goes in as it is. If a brace in get is never closed, the node fails to load and says so.

If the endpoint is https and needs no credential, add "public": true. The list then works before anyone picks a connection, and the call never carries the connection’s credential, even after they pick one.

If you only want some of the items the service sends back, add where. Each key is a dotted path inside one item, and its value is what the item must hold there. An item is kept only if every path matches, type included: true does not match "true". An item with nothing at a path is dropped, so null only matches a field the service sends as null.

ElevenLabs’ models endpoint answers with a bare array of every model, each carrying flags like can_do_text_to_speech, so a text-to-speech node sets "items": "" and keeps the ones that can speak:

{ "kind": "list", "get": "https://api.elevenlabs.io/v1/models", "items": "",
  "label": "name", "value": "model_id", "where": { "can_do_text_to_speech": true } }

If the lookup also has a page, where filters each page as it arrives: a page where nothing matches adds nothing to the list, and the editor still shows “More…” for the next one.

If the service answers a failed lookup with a 200 and says so in the body, like Slack’s {"ok": false, "error": "missing_scope"}, add errorPath and okPath so the lookup fails with the service’s own words. For what they read, go and read refusals answered with a 200.

validate

Each rule is { "when": <condition>, "then": <diagnostic> }.

The diagnostic takes message (with {id}, {port} and {field} filled in, plus the ones the conditions below name), level (structural, the default, or runtime), severity (error by default, or warning, info, hint), and optionally port or field to say what the finding is about.

A structural rule is checked every time the project is parsed or built. Pick runtime for a rule that only matters once the project runs, like a provider with no model named yet. The build skips it; for when it is checked instead, go and read What the compiler refuses.

The conditions are a closed list:

kindKeysWhat it asks
input_satisfiedportDoes this input have a value, from a wire or written in
input_wiredportDoes it have a wire specifically
output_wiredportDoes anybody downstream read this output
valuefield, and the checks belowIs this field set to anything non-null and, when checks are given, does it pass every one
custom_inputs_declaredDid the source add inputs beyond the metadata’s own. {customInputs} in the message names them
custom_outputs_declaredDid the source add outputs beyond the metadata’s own. {customOutputs} in the message names them
output_type_fitsport, withinDoes this output, as the program declared it (or as your metadata types it, if the program declared nothing), fit in the type within. Use it when the types that make sense on a narrowable output depend on a setting: a route whose dataType is text takes only a String body. False when the node has no such output
custom_inputs_fitoneOfDoes every input the source added beyond the metadata’s own fit in one of the types oneOf lists. Each input is held to one of them, never to their union, so your node can count on each added input being one of the shapes it handles. Use it when your node sends its added inputs somewhere that takes only some shapes: a model that reads a JSON value or a file lists `[“Json”, “File
run_reachesdirection, withDoes this node’s run hold a node with a feature. downstream: every run this node starts holds one. upstream: this node is in the run of one
downstream_ofwithIs a node with a feature wired upstream of this one, so it runs first. {with} in the message names those nodes
per_instanceDoes this node exist once per instance: marked @per_instance, with an @instance_filled field, or reading one of those (a group that receives a per-instance value on any port counts for everything reading its ports). {perInstanceReason} in the message says why, path included: “it reads ‘blender’”, or “it sits inside group ‘work’, which receives ‘blender’”
input_namesport, namesDoes every name written on this input name a node or a field of this program. If the input holds a String, that is the name; if a list, each String in it; if an object, each of its keys. names says what they name: {"node": {}} is a node spelled the way the program writes it (bridge, one.bridge), narrowed with role (infra or trigger) and perInstance (true or false); {"field": {}} is a node’s field written node.field (send.model), narrowed with instanceFilled (true or false). Nothing written is fine. A wired or @instance_filled input holds names only known at run time, so it is not checked. {names} in the message lists the ones that fail
all / anyofEvery one, or at least one
notofThe opposite

A value condition takes any of these checks, and all the ones you write must hold. In each { "min", "max" }, both bounds are optional and both included.

CheckHolds when
"nonempty": trueThe value is not blank or empty
"equals": <value>It equals this exact value
"oneOf": [...]It is one of these values, compared as JSON (a string against strings)
"matches": "<regex>"It is text and matches
"count": { "min", "max" }It holds that many items. A list counts its items, a dict its entries, any other value one
"range": { "min", "max" }It is a number in that range
"length": { "min", "max" }It is text of that many characters
"unique": trueThe items of the list are all different
"unique": "<key>"The records of the list all hold a different <key>
"each": { <checks> }Every item of the list passes the checks inside, a single value that is not a list counting as one item. With "key" inside, the checks read that key of each record. An item of the wrong kind for a check passes, since the type check owns kinds, and so does a record without the key. each may hold another each
{ "kind": "value", "field": "voice", "oneOf": ["alloy", "verse"] }
{ "kind": "value", "field": "schedule", "each": { "range": { "min": 50, "max": 500 } } }
{ "kind": "value", "field": "messages", "each": { "key": "role", "oneOf": ["user", "assistant"] } }

A check meeting the wrong kind of value is false (matches on a number, range on text), and a field that is absent or null fails every check. So a rule that refuses a bad value with not around a check refuses a missing one too. If the field is optional, put the check inside all with a bare value. Here an optional list that must hold at least two items when it is given:

{ "when": { "kind": "all", "of": [
    { "kind": "value", "field": "options" },
    { "kind": "not", "of": { "kind": "value", "field": "options", "count": { "min": 2 } } } ] },
  "then": { "message": "Pick '{id}' needs at least two `options` to choose between.", "field": "options" } }

On a required input, the not alone does it.

with picks nodes by one feature they declare, never by type, so your own node counts the same as a catalog one: {"answersCaller": true} is any node that answers its caller, {"answersCaller": ["stream"]} only a Stream-like one, {"liveConnection": ["http"]} a trigger with an HTTP caller.

Two examples from the catalog. A route warns when nothing in its run answers its caller, and refuses to become per instance, since its public address is one for everybody:

{ "when": { "kind": "not", "of": { "kind": "run_reaches", "direction": "downstream", "with": { "answersCaller": true } } },
  "then": { "message": "Route '{id}' starts a run that never answers its caller...", "severity": "warning" } }
{ "when": { "kind": "per_instance" },
  "then": { "message": "Route '{id}' would exist once per instance, because {perInstanceReason}..." } }

A node that starts an instance’s copy of an infra node, when it is given an instance, refuses a name that is no such node, and SetInstanceValues refuses a key that is no field an instance fills:

{ "when": { "kind": "all", "of": [{ "kind": "input_satisfied", "port": "instance" }, { "kind": "not", "of": { "kind": "input_names", "port": "node", "names": { "node": { "role": "infra", "perInstance": true } } } }] },
  "then": { "message": "StartInfra '{id}' names {names} in `node`...", "port": "node" } }
{ "when": { "kind": "not", "of": { "kind": "input_names", "port": "values", "names": { "field": { "instanceFilled": true } } } },
  "then": { "message": "SetInstanceValues '{id}' gives {names} in `values`...", "port": "values" } }

Rules on a value that arrives on a wire

If a rule’s answer depends on a value a wire brings, the compiler cannot decide it. It records the rule on the node in the compiled program, and the rule is checked again with the values that actually arrived, before your node’s body runs. If it fires, the node fails with the rule’s own message. That failure is an input error, so a wired _error does not catch it: the fix is in the program. A rule asking whether an optional wired input is set (input_satisfied) is one of these too, because the wire may close empty.

So a rule you write here covers every value, written or wired, and your node’s Rust never checks it again. A node with no undecided rule pays nothing. Only rules with severity: error are recorded; a warning the compiler could not decide is dropped, because it could not fail a firing anyway.

Which connection a node takes

If your node takes a connection, its input’s type names the service: "type": "Access[slack]". The compiler then refuses any other service’s connection on that wire, so a program cannot wire a Mistral connection into your node and send the Mistral key to your provider. If the node works with several services, write a union, Access[openai] | Access[anthropic]. If your node declares its own service, the input it takes that connection on (account, or connection) is typed with that service, Access[google] on GoogleAccess, and the catalog refuses the node when it names another one. A bare Access takes any service, so write it only where the service is not known until the program is written (a Route’s connection, whose service follows its auth). When the node opens the connection, weft checks the service again and fails the node naming both if they differ.

portsFromConfig

For a node whose ports come from a list the author writes, like a form’s fields or a switch’s cases.

KeyDefaultWhat it does
fieldrequiredThe config key holding the list. Must be a declared input
matchInputnoneThe input the entries are matched against, when only one entry wins
specsrequiredThe entry kinds you accept, and the ports each one adds

Each spec takes kind (required), keyField (default "key", the entry key holding the port name), label, render, fields, catchAll (at most one, and it goes last), addsInputs and addsOutputs (at least one of them).

A port template is { "nameTemplate": "...", "portType": "..." }, with {key} replaced by the entry’s key.

A spec field is { "key", "label", "required", "shape", "valueType", "widget" }, where shape is one of typed (needs valueType), value, value_list, number, element or regex. Every shape but typed needs matchInput set. You can also write a field as a bare string, such as "email": that is a typed String field with a text widget, labelled from its key.

types

A flat map from a name to a type, usable by every node in the project:

"types": { "ChatMessage": "{ role: String, content: String }" }

For how the names are shared and compared, go and read custom types.

typeParams

If a port is typed with a type variable (T, or T and a number), it takes whatever type is wired to it. If you want to limit what that can be, give the variable a bound here:

"typeParams": { "T": "File" }

KeepFile does this with "T": "File". Its file input and its file output are both T, so a Video in gives a Video out. Wire a String into it and the build stops with type-bound. A bound never picks the type: the compiler works the variable out from what is connected, then checks the result against the bound. A bound may name another variable that one of your ports uses ("T": "List[T2]"). The file fails to load if a variable here is used by no port, or if two bounds name each other in a cycle.

placeholdersAreInputs

If your node takes a text setting whose placeholders name inputs the program adds to it (a query’s $userId, a template’s {{name}}), say so here, and the compiler checks that the placeholders and those inputs match. The node needs features.canAddInputPorts, or the file fails to load.

"placeholdersAreInputs": { "in": "template", "pattern": "\\{\\{\\s*(?<name>[A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}" }

in is the input holding the text, and it must hold a String. pattern is a regular expression finding one placeholder, whose group named name is the input it stands for. If a match leaves the name group unmatched, weft ignores it, which is how a pattern skips text that only looks like a placeholder: the pattern '[^']*'|\$(?<name>[A-Za-z_]\w*) skips '$userId' inside a string literal and finds the bare $userId. Back-references are allowed (\k<tag>), so a pattern can skip everything up to the delimiter that matches its opening one, like SQL’s $tag$ ... $tag$.

When the program writes the text itself, in the node’s settings or through @file (files and reuse), every placeholder in it must be an input the program added (placeholder-not-an-input), and every input it added must appear in it (input-not-a-placeholder). Text that arrives on a wire or through @asset is only known when the node runs, so your node checks it then: find the placeholders in the text yourself and pass their names to ctx.inputs.for_holes (it calls them holes), which matches them to the inputs and fails on either mismatch. For its arguments, go and read reading inputs.

firesWith

firesWith says exactly what a trigger’s event carries: a flat map from field name to type. Only a trigger may have one; on any other node, the file is refused when it loads. Each type is parsed when the file loads, so a misspelled type fails then too. A ? goes on the name, not the type:

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

The payload has to match it exactly. Whenever the trigger fires, whether a listener sends the event or you fire it by hand with weft run --fire, the payload is checked before the trigger’s body runs. A payload missing a field you declared without ? is refused, and so is one carrying a field you never declared, whether at the top level or inside a field whose type is a record. The refusal names the field and ends with It wants { ... }, the shape you declared. If a trigger has no firesWith, its payloads go through unchecked.

claimsRoute

pathField names the config field holding the route pattern, and the optional methodField names the one holding the HTTP method. Leave the method out, or leave it empty on the node in your program, and the node claims every method.

If your node puts the path’s captures on outputs a program adds (-> (id: String) on cards/{id}), set capturesAreOutputs: true; the node also needs features.canAddOutputPorts. The compiler then refuses an added output the path does not capture, or one not typed String, with route-output-not-a-capture, and a capture named like one of the node’s own outputs with route-capture-shadows-output. Filling each port is your node’s code: emit the capture under the port’s name.

accessApps

If you want people to connect your node’s service without registering an OAuth application of their own, ship a public one here, keyed by service: when somebody connects through the own door (the “Your own” page, where people paste their own app) and fills in none of the app fields, it signs in with this one. For what the doors are, go and read How connections work.

Each app needs a label (the name shown in the connection list) and a client_id, plus every registrationFields entry the service does not mark optional:

"accessApps": { "google": { "label": "Acme", "client_id": "1234.apps.googleusercontent.com" } }

Every check on an app here runs when the catalog loads, and an app that fails one leaves its node out of the catalog, with a message naming what is wrong:

  • the key under accessApps names no service any node of the project declares (no node in this project declares a service named 'slak');
  • the service connects without an app, because it does not sign in through OAuth;
  • label or client_id is missing or empty, a required registration field is missing or empty, or the app has a key the service does not declare (the message lists the keys it knows).

An app a package root gives its members is reported once, against the root’s file, unless it leaves out label or client_id entirely: then every member that inherits it fails to load, each against its own file. A project’s catalog holds its copy of the standard nodes (nodes/base_catalog, which weft new copies in), so the standard services count as declared.

An app here is a public client, one that signs in with no secret (OAuth’s PKCE flow). A metadata.json ships with the node to everyone who uses it, so a secret written here would go to all of them: a client_secret is refused, and so is any registration field the service marks secret (a field is secret unless the service marks it "secret": false). A service that needs a secret field in every app cannot ship a public app at all. An app with a secret goes in the install’s apps file (access-apps.json), or is pasted on the “Your own” page when somebody connects.

Package defaults

A package root can hold a partial metadata.json whose keys every member inherits. Members win, key by key, at the top level only, with no deep merge: a member’s types replaces the package’s rather than adding to it.

Three keys can never be defaults, because they are one node’s identity:

package-level metadata.json must not set `type`: it is one node's identity, not a package default

Keys that no longer exist

If you are reading older node code, the loader names these rather than saying “unknown field”:

What you wroteWhat to do now
inputs[].exposureUse accepts. ["wire"] refuses written values, ["literal"] refuses arrows, and leaving it out allows both
inputs[].requiredWhenWiredRemove it. An input without required already waits for its wire once it is wired, and runs without it when it is not
outputs[].requiredRemove it. An output has no optionality
features.optionalCustomInputsWrite features.join instead: a node whose added inputs are optional is a join, which goes on with whichever of several branches arrived
features.isOutputDefaultRemove it. Every node runs when it is reached, and weft run --target <node> narrows a run
features.catchErrorsRemove it. Every node has the _error output, and a failure goes there when a program wires it