Syntax
The whole surface, in one page. The language is written mostly by models, so the surface is small and strict: there is one way to say each thing, and the compiler refuses everything else.
Declaring a node
name = NodeType
name = NodeType { config_field: value, ... }
name = NodeType {}
name is the node’s id and must be unique within its scope. NodeType must
exist in the project’s nodes/ catalog.
Connecting
target.input_port = source.output_port
Read it right to left: the value flows from source.output_port into
target.input_port, and the types have to be compatible. Every required input
must be wired; an optional one (port?) may be left alone.
If you only want one key of the value, keep going with dots:
speed.wpm = reader.profile.stats.wpm
That is still one wire, from reader.profile, and it delivers stats.wpm
read off the value. For what it needs from the type, go and read
reading a key off a wire.
Config values
Config fields are typed JSON-ish literals.
t = Text { value: "a string" }
n = Range { to: 10, step: 2 }
flag = SomeNode { enabled: true }
arr = SomeNode { items: [1, 2, 3] }
obj = SomeNode { opts: { "k": "v" } }
multi = SomeNode {
fields: [
{ "kind": "text_input", "key": "name" }
]
}
Multi-line arrays and objects are fine, and the comma between two fields is optional, so a field per line with no commas reads the same to the compiler.
Wires in the braces
A field whose value is source.port is a wire, not a config value. It is the
same edge as the connection line, written inside the node it feeds.
reply = TelegramSendMedia {
kind: "photo"
chatId: ask.chatId # identical to `reply.chatId = ask.chatId`
file: picture.image
}
Both forms compile to one edge, and the editor can rewrite either. Which one to write is taste: a node with several wires reads better with them in its braces, next to its settings, instead of a stack of lines each starting with the same name.
A Group is the exception. Its braces hold its children, so the only field
it reads there is _should_flow. Its interface ports are driven from
outside, on their own lines, by a wire or by a value.
A key that CREATES a port
On a node type that accepts extra inputs (ExecPython, FirstInOrder,
TagRun, StopTagged), a key
naming no declared port creates one. A wire gives it the type of whatever feeds
it, a literal gives it the literal’s own type, and a null literal is an error
because it says nothing about the type.
step = ExecPython -> (out: String) {
code: @file("assets/scripts/step.py")
text: draft.answer # a String port, from the wire
limit: 3 # a Number port, from the literal
notes?: review.notes # optional: a closure here does not skip the node
}
Created ports keep the order they are written in, which is what
FirstInOrder reads: its first input that carried a value is the one it
emits. Reordering two of its lines changes which branch wins, and it is the
only place in weft where the order of lines means anything.
The ? goes on the key, because it describes the port being created rather
than the wire’s source, and it is refused on a key that creates no port (say
so on the port itself instead: notes?: String in the signature).
Literals on a connection line
When the target is a node’s own port and the right side is a literal, the line fills that node’s config instead of creating an edge.
post = SlackSendMessage { channel: "#alerts" } # in the braces
post.text = "deploy finished" # or on its own line
Both spellings are the same thing, a constant written for the port, and no
port takes one spelling and refuses the other. What a port can refuse is a
whole family: a value written in the source (literal) or a value another
node produces (wire). Every port takes both unless its node says otherwise
with accepts in its metadata, and getting it wrong is input-accepts, with
the message reading the list back (“params accepts: wire”). A port the
compiler reads to build the node (a form’s fields, the access picker) is the
one exception: it takes an inline typed value only, never a wire and never a
@file or @asset.
The same line works on a Group, a Loop, or an @include alias: its
ports are the ones in its signature, and a value on one reaches everything
inside that reads it.
escalation.tone = "formal"
An output never takes a value: a firing emits on it, and you read it as
node.port. Writing one (step.out = "lit", or out: "lit" in the braces of
a node whose signature declares -> (out: String)) is refused, and so is a
group’s own output written from inside (self.result = "lit"). Drive it from
a node.
Multi-line strings
Triple-backtick blocks carry code, templates, anything with newlines in it.
step = ExecPython() -> (out: Number) {
code: ```
return {'out': 42}
```
}
Reserved keys
Keys starting with _ are reserved, and there are exactly four.
| Key | What it does |
|---|---|
_label: "..." | sets the node’s display label. A quoted string, settable once, never by wire. |
_tags: ["a", "b"] | attaches tags, used by signal scoping. |
_should_flow: <wire or false> | decides whether this node runs at all. |
_should_not_flow: <wire or true> | the same decision read the other way round: it runs when the thing wired here did NOT happen. |
Any other leading-underscore key is a compile error, so the namespace stays available.
_should_flow is how a branch turns off. Leave it out and the node runs.
Wire it and the node runs only when what arrives is not false; a false, or
a closure (whatever decides never spoke), skips the node, which closes its
outputs, which skips everything behind it. Writing _should_flow: false
straight into the braces turns one node off without touching anything else.
reply = SlackSendMessage {
_should_flow: review.approved
text: draft.answer
}
A Group or a Loop takes it too, written inside its braces alongside
everything else, or from outside on the container’s name
(escalation._should_flow = false), the same way you would set any of its
interface ports. A group that does not flow takes everything inside it with
it, however deeply nested.
escalation = Group(question: String) -> (answer: String) {
_should_flow: route.needs_a_person
...
}
The node itself never sees this port: it is the language deciding whether to call the node, not data the node reads.
Running on the thing that did not happen
If you want a node to run when something did NOT arrive, wire that something
into _should_not_flow instead. Every answer flips: a value arriving means
the node stays off, and a closure, the structural “nothing is coming”, is
what runs it.
route = Route -> (photo: File) { path: "cards", method: "POST" }
# A card sent without a picture: `photo` closes, so this runs.
default_art = FetchToStorage { url: "https://example.com/blank.png" }
default_art._should_not_flow = route.photo
This is the one port in the language that starts a node on a closure. Everything else skips when its inputs close, which is why “act on the thing that is not there” needs its own spelling: there would otherwise be nothing left alive to notice.
Reach for it when the absence is DATA, like a key the caller did not send or an optional input nobody filled. When the absence is a DECISION your own node made, it is usually clearer to have that node say so on a second output port and gate on that, because the wire then reads forwards.
A node has one gate. Wiring both spellings is a compile error (two-gates)
rather than some rule about which wins.
Inline port signatures
Some node types let you declare their ports in the declaration itself, with an
arrow. ExecPython is the canonical one.
calc = ExecPython(a: Number, b: Number) -> (sum: Number, diff: Number) {
code: "return {'sum': a + b, 'diff': a - b}"
}
Inputs arrive in the code as variables named after each port, and the code
returns a dict keyed by output port name. A key set to None, or missing
entirely, emits no pulse on that port, which closes it. The compiler
type-checks these ports exactly like declared ones.
You only write the ports the node leaves open. Anything its metadata already types keeps that type, so a signature can be inputs only, outputs only, or a single port, and a node whose ports are all pinned needs no signature at all.
answer = LlmInference -> (response: String) # the rest of its ports are typed
ok = Cast -> (value: Boolean) # the whole point of Cast
An empty body is the same as no body, so Cast -> (value: Boolean) {} and
Cast() -> (value: Boolean) are the line above with more typing.
Inline expressions
A node literal can appear directly as a value, with a mandatory trailing
.port naming which of its outputs feeds the target.
out.data = Text { value: "hi" }.value
That synthesizes an anonymous child node (id {host}__{field}, here
out__data) plus the edge into out.data. The same form works as a config
field’s value inside a node body, and carries full node syntax including its
own inline signature and nesting. With a signature, the .port reads one of
the outputs the signature declares:
summary.text = ExecPython(m: List[JsonDict]) -> (text: String) {
code: "return {'text': ' '.join(x['body'] for x in m)}"
}.text
Omitting the trailing .port is a compile error, because a node with several
outputs would otherwise be silently ambiguous.
Comments and descriptions
# starts a line comment.
One position is special: if the first line inside a group or loop body is a plain comment, that line becomes the group’s description and tooling shows it when the group is collapsed.
preprocessor = Group(raw: String) -> (result: String) {
# Cleans and transforms text
...
}
The same rule applies inside an included file’s top-level group body. Comments outside any group have no special meaning.
The project’s name and id live in weft.toml, not in the source, so there is
no header comment to keep in sync.
Directives
@require_one_of(a, b) states that at least one of the named inputs must be
satisfied, either wired or set to a non-null literal. It goes on its own line
inside a node body, or inside an inline port signature. A group or loop
refuses it: a group’s inputs are all optional at its boundary, so the
directive belongs on the node inside that needs one of them.
lookup = SlackFindUser {
@require_one_of(email, phone)
}
It is a compile error when unmet (require-one-of-unmet), and it also governs
runtime skipping: the node is skipped when every port in the group arrives
closed.
Catalog nodes declare the same thing in their metadata as oneOfRequired, so
a node author can build the requirement in rather than relying on every caller
to write the directive.
Whitespace and formatting
The parser keeps every byte, including whitespace and comments, in a lossless tree. That is why the editor can rewrite one config field through a GUI gesture without reformatting your file, and why a round trip through the compiler is byte-exact when nothing changed.
There is no formatter, and no formatting rules are enforced.
The full grammar, informally
file := decl*
decl := IDENT '=' node_expr
| IDENT '=' '@include' '(' STRING ')'
| connection
node_expr := TYPE port_sig? body?
port_sig := port_sig_in? port_sig_out?
port_sig_in := '(' port_decl,* ')'
port_sig_out:= '->' '(' port_decl,* ')'
port_decl := IDENT '?'? ':' type # `?` (inputs only) = may be absent
body := '{' body_item* '}'
body_item := IDENT '?'? ':' value # config field (`?` = the port it
| # creates is optional)
| IDENT '?'? ':' path # a wire into this node's port
| connection # inside a group or loop
| directive
| COMMENT
connection := path '=' ( path | value | node_expr '.' IDENT )
path := IDENT '.' IDENT | 'self' '.' IDENT
Group and Loop are node types with bodies containing connections, covered
in Groups and Loops.