weft-language
Read before writing or editing any .weft source: declarations, wiring, config literals, reserved keys, inline signatures and expressions, types, groups and the level rule, included files, loops, the p
Files1
View on GitHubSKILL.mdtangle/cline/.cline/skills/weft-language/SKILL.md
GitHubThe weft language
One way to say each thing, and the compiler refuses everything else. This page is the whole surface as of this template; when it disagrees with your memory, this page wins.
Two words the whole page leans on. A [pulse] is one value emitted on one output port and addressed to one input port of one node; nothing walks the graph, a node fires when every required input holds a [pulse]. A [closed] pulse carries no value and means "nothing will ever arrive here"; a port that never receives anything is closed the same way.
Declaring a node
name = NodeType
name = NodeType { configField: value }
name = NodeType {}
name is the node id, unique in its scope, camelCase. NodeType must exist
in the project's nodes/ catalog; you read its real ports with
weft describe-nodes --node <Type> --compact, never from memory.
Spelling a name
Every name you choose in a program is camelCase: a node (sendReply), a
group, a loop, an @include, and every port you declare on a node, a group,
a loop or an included file's header (maxRetries: Number). On a node that takes added
inputs, a key in the braces that creates a port counts too (userId: 3 on
ExecPython). A node type is spelled the way
the catalog spells it, which is PascalCase (ExecPython). A type you
declare is PascalCase too (type Card = ...), though the compiler does not
check it. Settings are spelled the way the node's interface names them,
camelCase too (systemPrompt).
If a name you chose is spelled any other way, the program still runs, and
the compiler warns not-camel-case, with the fix when it can work one out:
node 'send_reply' is not camelCase: write 'sendReply'
A few names keep another spelling. The gates, _should_flow and
_should_not_flow, are the language's own names, so the warning skips them;
the markers keep theirs too (@per_instance, @instance_filled,
@require_one_of). A value you pick from a fixed list is written exactly as
the list spells it (kind: "photo"). And on a node whose added ports
are named after data from outside weft, a port keeps that data's spelling:
Route for a path's captures, JsonObject for the keys of the object it
builds, the Postgres query nodes for a query's $ holes,
among others.
body = JsonObject { user_id: ask.id } draws no warning for user_id; the name you give the node, body, is still checked.
Such a node shows addedPortsAreDataKeys in the features block of its
wiring view. The fields of a record type are never checked at all, so a
request body's user_id keeps its spelling in the record you declare for it.
Connecting
target.inputPort = source.outputPort
Right to left: the value flows from source.outputPort into
target.inputPort, and the types must be compatible. Every required input
gets a wire or a literal; an optional one (port?) can stay unwired. This
line is the longhand; [the shorthand] (next section) is the same wire inside
the target's braces, and the one you write (answer = Reply { body: work.answer }, never answer = Reply with answer.body = work.answer under
it). The longhand survives only where the language
leaves no choice: a [boundary port], one of the ports a group, a loop, or an
@include alias declares in its signature.
If you want one key of a record value, keep going with dots
(wpm: reader.profile.stats.wpm in the braces, or
speed.wpm = reader.profile.stats.wpm). Still one wire; the compiler checks
every key against the record type at that level and the wire carries the
last key's type. Only a record type has keys to read: a source typed
JsonDict, a Dict[String, String] (a route's query or headers) or a
scalar has none (deref-path), and nothing reads one for you. Narrow the
source port to the shape it carries (next), or cast it to a record type
first. At run time a ? key found absent (or null) closes that
wire alone; a required key absent fails the firing.
Narrowing a port to the shape it carries
A port's type is a contract, and on the arrow where you use the node you
may write a TIGHTER one: on any input, and on an output the node marks
narrowable in its metadata (a query's rows, a model's JSON response, a
request's body), where only the program knows what shape comes out. Every
other output takes no type but its own. The compiler checks yours and refuses
one that does not fit, naming the port (node 'ask': output port 'query' is declared Dict[String, Number], but Route always gives it Dict[String, String]: only an output its node marks narrowable takes the type the program declares), so a narrowing that compiles is one the node can honour. This is
how a loose container becomes a shape you can read keys off, and it costs
nothing at run time: the value is already that shape, the port now says so.
A port the node already declares takes the tighter type the same way one you
add on the arrow does. Here the node's own rows port is List[JsonDict],
which has no keys to read, and naming the row's shape makes .title legal:
type Card = { title: String, seen: Number }
rows = PostgresExecuteQuery -> (rows: List[Card]) {
account: store.access
query: "select title, seen from cards"
}
When you want one row, its row port (the first row) narrows the same way,
and you read the pieces straight off it, look.row.title:
look = PostgresExecuteQuery(id: String) -> (row: Card) {
account: db.access
query: "select title, seen from cards where id = $id"
}
A column that can be NULL is declared so in the shape: { title: String, extra: JsonDict | Null }. The declared type judges every row at run time,
and a JsonDict alone refuses a null, so a day when every row has the
column empty fails the node with emitted a value on port 'rows' that its declared type 'List[Card]' does not accept (got List[Dict[String, String | Null]]); the fix is the | Null on that field, never a looser row type.
A cast (a node carrying features.castPorts, the catalog's Cast being
one) is for a value that has to CHANGE type while it travels. A value that
already fits needs no node at all, only a port that names the type. If you
catch yourself adding a cast to put a shape on a value that already has
it, stop and write: "Wait. Narrow the port." Then write the type on the
arrow and delete the cast.
What the outside world sends is narrowed only where the node lets you.
A route's query and headers are not narrowable: they stay Dict[String, String], and you read a key off one with a Cast to an open record:
utm = Cast -> (value: { utm: String, .. }) { value: ask.query }, then
utm.value.utm. A request's body
is, and a record declared there judges each request as it arrives: a body
that does not fit is answered 400 naming the field, and nothing past the
route runs. A record refuses a key it does not declare, so either declare
every key the caller may send, optional ones with ?, or end the record
with .. ({ email: String, .. }), which drops the keys it does not
declare. A cast to a record is just as strict: an undeclared key fails the
cast.
Wherever you narrow, the value is checked against the type when it arrives, so a shape that fits flows and one that does not fails loudly, naming the field. Prefer a NAMED type over an inline one once more than one node speaks it: the name is the contract, and it is written once.
Config values
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" } }
Commas between fields are optional; a field per line with no commas reads
the same. null is never a literal: omit the field instead
(config-null-literal).
A field with a fixed list of choices (a dropdown in the editor) takes one of
them and nothing else: any other written value is literal-not-an-option,
and the message lists the choices. A list the node marks freeText (model
ids, voices) only suggests: any value is accepted there.
A list or object literal holds plain values only, so params: [self.chatId, self.pushName] is refused. When a node needs several values from the graph,
each one is its own input port. When a node accepts however many values you
give it, declare them in its inline signature:
PostgresExecuteQuery(chat_id: String, push_name: String) { ... } and the
SQL reads $chat_id; Format(user: String) { template: "Hi {{user}}" }.
How a node reads those ports (a query's parameters, a template's slots) is
in that node's description.
A Python node whose code has no branch, no loop and no call is moving values, not deciding anything, and the language moves values. Read the code you are about to write and apply that test to it. Pulling a field out of a dict is the case you will meet most; the test is what catches the rest.
If you catch yourself writing one, stop and write: "Wait. That is
plumbing." Then read the key with dots, narrow the port to the shape it
carries, declare the ports on the consumer, or wire _should_flow (under
Reserved keys).
Calling a library, parsing what no type describes, reshaping a list in
memory: that is processing, and Python is the right answer for it. A branch
or a loop that decides what the program does next (whether a node runs,
which service is called, a call made once per item) is coordination, and it
belongs in the graph, where each node is visible and journaled: a Boolean
on _should_flow for the branch, a Loop for the repetition, one node per
call.
Building an object out of values you already hold (a reply body, a payload) is the case you will meet most, and it has its own node: wire a value onto each key you want and the object comes out keyed by those names. An object LITERAL cannot hold a wire, which is what used to force a script here.
Wires in the braces
A field whose value is source.port is a wire, written inside the node it
feeds. This is [the shorthand]: a node's wires sit next to its settings:
reply = TelegramSendMedia {
account: telegram.access
kind: "photo"
chatId: ask.chatId # same as `reply.chatId = ask.chatId`
file: picture.image
}
A Group is the exception: its braces hold its children, so the only field it
reads there is _should_flow, and its [boundary port]s are wired from
outside on their own lines.
A key that CREATES a port
Some node types accept extra inputs beyond the ones they declare, and on
those a key naming no declared port creates one. A wire types it from the
source, a literal from its own type, null is an error. Written key?:, the
node runs without the created port even when it is wired and the wire closes;
written without ?, the port is required.
Which types accept them is the node's to say, not this page's: its wiring
view carries a features block, and canAddInputPorts there means you may
add inputs, canAddOutputPorts that you may name outputs on the arrow. A
node with neither key, or no features block at all, takes only the ports
it declares.
score = ExecPython -> (out: String) {
code: @file("assets/scripts/score.py")
text: draft.answer # a String port, from the wire
limit: 3 # a Number port, from the literal
notes?: review.notes # optional: a closed pulse here does not skip the node
}
Created ports keep written order, and whether a node reads that order is the
node's own business, stated in its description. Two catalog examples:
FirstInOrder emits its first input that carried a value, so reordering its
lines changes which branch wins, and List puts its inputs into one list, in port order, and you declare the list's
type on its output. An optional port (name?:) that got nothing is left out.
If you want several wired values as one list (several LlmTools into
LlmInference's tools), that is List, never a Python node that returns
[a, b]:
tools = List(search: JsonDict, fetch: JsonDict) -> (list: List[JsonDict]) {
search: searchTool.tool
fetch: fetchTool.tool
}
Those two are the only ways line order changes what a program does.
Literals on a connection line
post = SlackSendMessage { account: slack.access, channel: "#alerts" }
post.text = "deploy finished"
Both spellings are one constant, and no port takes one spelling and refuses
the other. What a port can refuse is a family:
literal (a value written in the source, markers included) or wire (a
value another node produces), through accepts in its metadata; absent means
both. Wrong family is input-accepts, and the message reads the list back
("params accepts: wire"). A compiler-read port (a form's fields, a
switch's cases, the access picker) takes an inline typed value only: no
wire, no @file, no @asset. The line also works on a [boundary port]. An
output port never takes a value, on any node: score.out = "lit" and
out: "lit" beside a -> (out: String) signature are refused, and so is a
group's own output written from inside (self.result = "lit"). A firing
emits on an output; you read it as node.port.
Multi-line strings
fortyTwo = ExecPython() -> (out: Number) {
code: ```
return {'out': 42}
```
}
The opening fence goes on the key's line, content starts on the next line, the closing fence on its own line.
Reserved keys
Exactly four, all starting with _ (any other _ key is an error):
| Key | Effect |
|---|---|
_label: "..." |
display label. A string, set once, never by wire |
_tags: ["a", "b"] |
tags, used by signal scoping |
_should_flow: <wire or false> |
decides whether this node runs at all |
_should_not_flow: <wire> |
the same decision read backwards: runs when what is wired here did NOT arrive |
_should_flow: leave it out and the node runs. Wire it and the node runs
only when what arrives is not false; a false or a [closed] pulse
(whatever decides never spoke) skips the node, closing its outputs, skipping
everything behind it. _should_flow: false in the braces turns one node off.
Groups and Loops take it too, inside the braces or on the container's name
from outside.
_should_not_flow is the mirror image: wire something into it and the node
runs when that thing closes instead of when it arrives. It is the only port
in the language that can start a node on a closure; on every other port a
closed input either skips its node or, written ?, is left out, which is why
running on an absence needs a dedicated spelling.
Reach for it when the absence is data: a key the caller never sent, an
optional input nobody filled in. When the absence is a decision a node of
yours already made, put that decision on a second output port and gate on it
with _should_flow instead; the wire then reads forwards, cause before
effect.
A FAILURE is not an absence. When the node it watches fails, its ports close
too, but that closure carries the error, and the gate reads it: the node
stays off (skipped with what its _should_not_flow watches did not finish ('<node>' failed: <error>), naming the node that broke) and the run reports
the failure. So a
route's "no rows, answer 404" branch never fires over a database that is
down; the caller gets the failure instead. A group's or a loop's outputs
close the same way when something inside failed, so the rule holds one scope
up, and a node that SKIPPED because its input closed on a failure closes its
own ports with that failure too (its skip reason names where it started:
the required input 'value' closed because 'query' failed: ...), so the rule
holds any number of skips down the line, and every skip in that chain still
names query, the node that broke.
The one exception is a failing node whose _error is wired: it handled its
failure, so its ports close as plain absences, and a _should_not_flow on
them runs.
Handling a failure: the _error output
If you want the program to do something when a node fails (mark a row
error with the reason, answer the caller with a message, try another
branch), wire that node's _error output. Every node has one, triggers
included: weft adds it, a String holding why the node failed. Never declare
_error among a node's outputs yourself: that is a compile error.
- Wired: a failure becomes its message on
_error, the run goes on, and the node's other outputs close, so the success branch simply gets nothing. To run a branch only then, gate it:fallback._should_flow = ask._error. - Unwired: the failure fails the run.
A bad setting, a bad input or a type error always fails the run, wired or
not: those are the program's own shape, never an outcome to route around.
Nothing is caught once the node has started a stream (sent something on a
bus or a generator output): that stream ends failed, and so does the run. A
group has no _error; if you want one, carry the _error of the nodes
inside out through a group output.
What counts as a failure for a given node (an answer with an error status,
say) is in that node's description.
A node that could act outside the run is never started again after a crash. If its worker goes away mid-run, a fast run ends cancelled and a durable one fails the node that was running (How a run is kept, below, has both); only a pure node that had sent nothing on and read no stream runs again. With _error wired, that failure goes there like any other, and the log says the worker running '<node>' went away while it was running (or while it was reading a stream). A run waiting on a timer or a form, with nothing else of it running, survives its worker dying either way, unless its caller is still on the line with outlivesCaller off or a bus between its nodes is open: then its wait holds the worker (up to the trigger's holdSecs), and it ends with it.
ask = LlmInference { provider: prov.provider, prompt: job.text }
mark = PostgresExecuteQuery(reason: String, id: String) {
account: db.access
query: "update jobs set state = 'error', reason = $reason where id = $id::uuid"
reason: ask._error
id: job.id
}
mark runs only when ask failed: its reason input arrives only then.
This is how a program records a failure. Never catch it inside Python.
A node carries one gate, never both: wiring _should_flow and
_should_not_flow on the same node is a compile error, two-gates. The
editor draws both as the same triangle, with a small circle marking
_should_not_flow; right-click the gate to flip which one it is.
_should_flow is also the ordering wire, and it accepts ANY port: any type,
any node, no declaration needed on either side. typed = ExecPython { _should_flow: typing.done } means run once typing has fired. The value is
never read, so you never invent a port to carry it (an after: typing.done
port the code ignores puts a value on the graph that nothing reads, while
_should_flow already shows in the graph as a permission wire). The one
value that IS read is false, so wire a Boolean port here only when its
false should mean "do not run".
That is also how a node with nothing to receive gets its turn. Close takes
no data at all, so ending a branch early is one wire from any port on it:
bye = Close { status: 204, _should_flow: cleanup.removed }
A branch that skipped closes its ports, so whatever hangs off it by
_should_flow skips with it: the branch that did not run does not act.
On a group or an included file the wire means "run what is in here": a route's run takes the whole group along, and everything the group needs (a database wired into it from outside), exactly as it takes a node.
live = Route { path: "live/count", method: "GET" }
work = Group(db: Access) {
rows = PostgresExecuteQuery { account: self.db, query: "select count(*) from cards" }
out = Reply { body: rows.rows }
}
work.db = db.access
work._should_flow = live.fired # the route runs the group; nothing else connects them
Route and Socket have a fired output, always true when called: gate
work on route.fired, never on a request port like method.
A group, and so an included file, decides once whether it runs (once per
iteration inside a loop, once for each @include of the file). With a gate
wired onto it, or written in its braces, the gate decides, the way a node's
does. With none, it opens on the first value that reaches it from outside,
or at once when nothing comes in from outside, and it skips only when every
wire coming into it closed with nothing on it. Once it is open, a closed
input goes inside like any other pulse, to whatever reads it.
A node with no inputs at all inside a group runs when the group opens, so it
needs no _should_flow to get its turn. It runs only in a run that takes it
along, though: a trigger's run holds what the trigger feeds plus what those
nodes read, so it takes the node along when something that run uses reads
it, or when the run feeds the group's _should_flow, which brings in the
whole group. If several routes feed one include, a route that did not fire
never holds it up: the include opens on the value the route that fired
sends. At the
top level of a file, an unconnected node with no inputs runs only on a
manual run; a trigger's run never takes it along.
Running something once, when the program goes live
If you want something to happen once before a program serves anything (creating tables, seeding a row, warming a cache), write the node and wire it into the trigger. That is the whole mechanism, and it is worth understanding rather than memorising.
Everything UPSTREAM of a trigger is the trigger's setup program, and weft runs that program once, at activation. On a fire the trigger reads none of it: a fired trigger's inputs come from its bake, so nothing upstream runs again.
Any wire into the trigger puts the producer there, not only the gate. The gate is just the wire with nothing else to say:
make = PostgresExecuteQuery { account: db.access, query: @file("assets/sql/schema.sql") }
live._should_flow = make.count
Whatever that setup does, it does again on every activation, so it has to be safe to do twice: doing it a second time must not undo, duplicate or refuse what the first time did. How you write that is the node's business, and the node's own description says how; the language's part is only that it will happen more than once.
The same holds for infrastructure: anything upstream of an infra node is that node's setup program and runs when infra starts.
Two things follow that surprise people. A weft run --fire warns that the
wire was not delivered, which is correct and not a problem: that wire's whole
job was to run at activation. And when the trigger lives in an included file
and the setup node does not, the value has to cross the include's boundary
like any other, so the file declares a port for it; there is no shortcut, and
keeping the setup node in the same file as its trigger avoids the crossing
entirely.
That second one has a limit: setup that several triggers SHARE (the schema
every included file's tables live in) is written once, in the file that
includes them, and each included file takes its result as a port. Every
trigger's setup runs at activation as one program, and two nodes that do not
depend on each other run at the same time, so two files each carrying their
own create extension if not exists pgcrypto race each other and one of
them fails on a duplicate key. "Safe to do twice" is not "safe to do twice
at once".
Inline port signatures
Types that leave ports open declare them in the declaration:
calc = ExecPython(a: Number, b: Number) -> (sum: Number, diff: Number) {
code: "return {'sum': a + b, 'diff': a - b}"
}
answer = LlmInferenceJson -> (response: { label: String })
ok = Cast -> (value: Boolean)
What the node does with the ports you named is its own business, and its
description says it (how ExecPython hands inputs to the script and reads
its outputs back is in that node's description). Write only the ports the type leaves open. A port
typed with a type variable (T, T2) gets its type where the node is used:
from the type you declare on it, or from what is wired to it. A header port
written with no type, (x), takes the type of what is wired to it. An empty
body equals no body.
Inline expressions
A node literal as a value, with a mandatory trailing .port:
router = OpenRouterAccess
out.data = Text { value: "hi" }.value
ask = LlmInference { provider: OpenRouterModel { access: router.access, model: "z-ai/glm-5.3" }.provider, prompt: "hi" }
It synthesizes an anonymous child with id {host}__{field} plus the edge.
Full node syntax nests inside, including its own signature. Omitting .port
is an error (ambiguity). Bare form Type.port takes default config.
Comments and descriptions
# starts a line comment. If the first line inside a group or loop body is a
plain comment, it becomes that container's description, shown when collapsed.
Keep it one line, saying what the container does for its caller. Project name
and id live in weft.toml, never in a source header.
Directives
lookup = SlackFindUser {
account: slack.access
@require_one_of(email, id)
}
@require_one_of(a, b) on its own line in a node or inline signature (a
group or loop refuses it; put it on the node inside that needs the ports):
at least one named input must be satisfied. Compile error when unmet, and a
compile error when a name is not a port of that node (on a node that takes
custom ports, a name is a port once the header declares it, a wire lands on
it, or a config key names it); at run time the node skips when every port in
the group arrives [closed].
@per_instance on its own line in an infra node (no arguments; anything else is
per-instance-ineligible) gives each instance of the program (a separate
running copy under its own id, one per person or per session, say) its own
container for that node. @instance_filled (or @instance_filled(<fallback>)) where a field's
value goes makes that value each instance's own, a connection included
(account: @instance_filled); the fallback may be a value, @file(...) or
@asset(...); never on a wired field (instance-filled-wired) or
a group's own port (instance-filled-boundary), nor on a field of a node
in an included file whose sites give it different types
(instance-filled-place-types). Every node reading either
follows, per instance. A Route or Socket that would follow is a compile
error: routes stay shared and pick the instance per call. The
weft-instances skill has what all of that means and the shape a program
with instances takes; weft-members adds the shape for several people.
Types
| Kind | Written |
|---|---|
| primitives | String, Number, Boolean, Null |
| stored files | Image, Video, Audio, Blob (references, not bytes) |
| aliases | Media = Image | Video | Audio; File = Media | Blob |
| containers | List[Number], List[List[String]], Dict[String, String] |
| unions | String | Number, Number | Null |
| records | { role: String, name?: String }, strict: undeclared keys refused |
| opaque | JsonDict, compatible with any Dict[String, V] both ways |
| type variables | T, T2, unified across a node's ports, pinned by what is wired to the port or by the type the program declares on it (Cast's output is T2: -> (value: Card) pins it, and so does the port it feeds); one nothing pins is unresolved-typevar |
| live handles | Bus, Generator[T], Access; never literals |
A record is usable inline in any signature (p: { wpm: Number, delay: Number }), and a type gets a name either in a node's metadata types (e.g.
ChatHistory) or in the source, at the top of a scope:
type Profile = {
wpm: Number,
delay: Number
}
typing = ExecPython(p: Profile) -> (ms: Number) { code: "..." }
Any type, multi-line allowed, other declared names allowed on the right.
Declared at file level it is visible to the whole file; directly inside a
group or loop body, to that body and its nested bodies only (the group's own
signature sits outside and cannot use it). It also reaches down through
@include: a type declared above an include is visible inside the included
file and whatever that file includes, so a record several files share is
declared once, at the highest scope that includes them all. An included file's
own types never reach back up. Never inside a node's braces. A visible name cannot be declared again (no
shadowing). Named types are nominal: the name is the contract, nothing
unnamed wires into a named target, and the door between the two is a cast.
? goes on the name and means "may be absent": here?: String on an input
port (accepts a [closed] pulse, fires with the input absent), name?: String
on a record field, notes?: review.notes on a config key that creates a port.
On a key, ? only works when that key CREATES the port (a node like
ExecPython that lets you invent inputs). On a port the node already declares,
width?: self.width is refused with "'width?' marks a port optional, but
'width' is not a port this node creates. Declare optionality on the port
itself (width?: Type) instead".
here: String? is refused. An output port takes no ?: emitting nothing on
it closes it, and no marker changes that.
Whether a node needs an input comes from its metadata and from what the program writes on the port:
| What the node's metadata says | What the program writes | Result |
|---|---|---|
| required | nothing | always required |
| required | x?: T |
compile error naming the port: the node's code needs it |
| optional | nothing | unwired: runs without it. Wired: waits for it, and skips if the wire closes |
| optional | x: T (no ?) |
always required |
| optional | x?: T |
runs without it even when wired and the wire closed, whether nothing arrived or the source failed |
A wire closes when its source finished without sending on that port, or
failed, and a skipped node closes its own outputs in turn. So a wired input
skips its node when it closes, unless the program wrote ? on it.
One exception: an input in one of the node's oneOfRequired sets (or in a
@require_one_of you wrote on it) is decided by its set. The node skips only
when every member of the set closed, unless you declared that input without
?.
If an input the node's metadata leaves optional must be filled before the
program builds, declare it without ?. SlackSendMessage takes text or
blocks, so neither is required alone; declared this way, text is:
send = SlackSendMessage(text: String) {
account: slack.access
channel: "#support"
text: draft.answer
}
Leave text out and weft validate refuses send: required input 'send.text' has no driver (no edge, no body value, no default).
If the node must run when a wire into it brings nothing, write ?. Here
ask is a SlackReceiveMessage, whose thread sends nothing for a message
that was not in a thread. With threadTs? the answer goes to the top of the
channel; without the ?, answer waits for threadTs and skips:
answer = SlackSendMessage(threadTs?: String) {
account: slack.access
channel: "#support"
text: draft.answer
threadTs: ask.thread
}
A node whose every wired input is written with ? has nothing saying when it
runs, and the compiler refuses it (run-undecided): gate it with
_should_flow, drop the ? from the input it cannot do without, or add
@require_one_of.
A cast CONVERTS a value to the type declared on its output. Any node can be
one: its metadata names one input and one output under features.castPorts,
and the compiler checks that pair against weft's conversion table
(cast-not-allowed for impossible pairs); record and named targets are
validated at run time, errors name the offending field. The standard
node that implements it is Cast, the one to use:
card = Cast() -> (value: Card).
Reach for it when a value has to CHANGE type while it travels, and only then. It is not how you put a type on a value: a port you declare already carries the type you wrote on it, and the value is checked against that on arrival, so a shape that fits flows and one that does not fails loudly. If the node can name the port, name it there and skip the cast. And a value that already fits its target passes through a cast untouched, so one written "to be safe" is a node that does nothing.
If you have a file and the port wants one kind of file, that is a cast too:
a node that fetches whatever a message held emits File (any stored file),
a transcriber takes only Audio or Video, and File into Audio is
type-mismatch, because the kind is only known once the bytes are there. Say
which kind you are claiming, and the run checks the claim:
voice = Cast -> (value: Audio) { value: msg.file }
say = ElevenLabsTranscribeFile { account: key.access, audio: voice.value }
A file that really is audio passes through untouched; one that is not fails the cast loudly, naming what it got.
Compatibility: identical types; unions member-wise; JsonDict with any
string dict; a named type only with the same name (a JsonDict into it is
type-mismatch, and the door is a cast, which checks the shape; a group that
carries an LlmProvider declares its port LlmProvider, never JsonDict);
containers element-wise; type variables unify. Everything else is
type-mismatch naming both types.
How a program runs
A node fires when every required input holds a [pulse] that agrees on the run and on frames (loop iterations).
A node that emits on some outputs and not others closes the rest. A [closed]
pulse on an input the node needs (a required one, or a wired one the program
did not write ? on) skips the node and closes its outputs; on an input
written ? the node fires anyway; on _should_flow the node skips, period.
Skips cascade forward until a node opted into absence with ?. A failure
closes the failing node's outputs too, and an input written ? further down
takes that closure like any absence, but the run still fails unless the
failing node's _error is wired. If you want to recover from a failure,
wire _error; a ? only lets a node run without a value.
Branching is only this: a branch in weft is a node that ran or a port that
closed, and nothing else. Any Boolean reaches a _should_flow, so a node of
any kind decides a branch by emitting one (a moderation check's flagged, a
request's ok, a lookup's found). The standard nodes below implement
the decision itself, and they are the ones to use. Switch tests a value against
its cases config and emits true on the winning case's port, closing the
rest; wire a case port into the branch's _should_flow. FirstInOrder
does not decide anything, it MERGES: it emits the first of its inputs that
carried a value, in written order, so alternative paths rejoin into one
wire. All says yes only when every input wired onto it arrived and none
of them is false, and closes its output otherwise; it is where the second
answer goes when a gate takes one wire and the permission has two parts.
Not flips one Boolean, for the node that should run when the answer was
false.
Three shapes come up constantly:
# One gate, two conditions: delete only if the model said rude AND said sure.
agreed = All { rude: judge.rude, sure: judge.sure }
remove._should_flow = agreed.yes
# A default: the caller's `limit` when they sent one, else 20.
limit = FirstInOrder { asked: door.limit, fallback: 20 }
# An object out of values the graph computed: one key per wire.
body = JsonObject {
job: open.id
status: "running"
}
That last one is worth knowing before you reach for a script: an object
LITERAL holds plain values only, so { "job": open.id } is refused, and
building one out of wires is a node's job. Declare the shape on its output
when the consumer wants a named type (-> (object: Ticket)), and nest by
wiring one onto a key of another.
All reads a value exactly the way a gate does, so only false is a no and
anything else is a yes, and a branch that closed never arrives at all, which
is a no as well. That makes it the AND of decisions and of arrivals in one
node. FirstInOrder's written order is priority, so the fallback goes last.
A node can also branch by ABSENCE, which often needs no deciding node at all.
A port that emits nothing is closed, and a closed port skips what hangs off
it, so a lookup picks the branch by itself. A query's row closes when the
query answered no row:
look = PostgresExecuteQuery(id: String) -> (row: Card) {
account: db.access
query: "select title, seen from cards where id = $id"
}
A node fed by look.row runs when the card exists and skips when it does
not, and a node gated _should_not_flow: look.row runs only on the miss. A
port that DECLARES null among its types (-> (row: Card | Null)) is the
opposite: a null arrives as data and the node runs. And a statement with
nothing to say still emits count (0) and
rows ([]), so a setup script is gated on count without inventing a row
for it to return.
A port's DECLARED type is what judges every value on it, at run time as at
compile time: a JsonDict port takes any object whatever keys it carries, and
only a port declared Image reads a picture out of one. A value the declared
type refuses fails the node by name, with whatever it already sent still
sent.
A value on a wire is at most 100 KB, and the check is on the port that EMITS
it: port 'results' of 'search' carries 107 KB, and a wire carries at most 100 KB. Return less: one page of it with where the next one starts, or a part that says it was cut. Or store the data and send the stored file instead, whose value weighs a few hundred bytes whatever the file holds. A node one hop later cannot trim what already
failed, so the bound is set on the emitting node, when that node is written:
it pages (a GoogleSheetsRead takes offset and maxRows and answers
nextOffset and hasMore), caps (a FetchPage keeps maxChars and says
truncated), or a query keeps its LIMIT. Anything that is bytes rather
than a value (a file, an audio clip, a long document) travels as a stored
file: the node puts it in storage and emits the file value, a few hundred
bytes whatever the file weighs. A program that would carry a big value on a
good day and fail on a long one is not done until the bound is written.
If the big value is text (a long document), keep it in a stored file.
TextToFile turns text into a stored file and FileToText reads one back as
text (both in the storage package; FileToText only works while the text
fits on a wire).
A value that grows with use (a conversation, a log) passes 100 KB one day, so
it never rides a wire at all: it lives in a stored file that each node edits
in place. A node that keeps such a value takes it on a file input and gives
the same file back on a file output, the next version of it. Two nodes
editing one file at once (a parallel loop, two runs on one project file)
never lose each other's change, and each edit shows in the graph, under the
node's "Files edited", as the lines it added. A node that keeps a value this
way names the file ports in its own description, as the AI nodes do for a
conversation. If you want a file that later runs keep adding to, store its
first content in the project scope with TextToFile and wire that file in.
If you want an agent that calls tools until it is done, the shape is a
sequential Loop that carries the one conversation file: each turn asks the
model, the tools it asked for run, each result is added to the file in
place, and the next turn reads the file. The turns stop when the model asks
for no tool. Which ports carry the file, the tools and the model's tool
calls is in the descriptions of the AI nodes.
What runs: a manual run starts ordinary roots within its selection. A trigger
requires one explicit fire or supplied emitted outputs. --target <node> runs
through that endpoint; --before <node> excludes it. Repeated endpoints
select the union of their required work. Ordinary groups are cut precisely,
with their flow gates still applied; loops stay whole and refuse interior
cuts. A shared root does not dispatch work outside the selection.
A trigger fire starts from exactly that trigger and follows its program, including the dependencies needed downstream. Its input settings come from matching preparation; its wake payload belongs to this event. Other triggers are not fired. Preparation and infra extraction also stop precisely inside ordinary groups and refuse interior loop cuts. One file can hold several trigger programs sharing dependencies without running unrelated branches.
Ends: completed (no pulse in flight), suspended (every live firing parked on
an external wait: a person, a timer; costs nothing), stuck (provably
deadlocked, fails loudly), or cancelled (a person pressed Stop, another run stopped it by tag, the live caller hung up, the worker of a fast run went away, it reached the hour a cloud install allows one stretch of a run, or the runtime itself ended it, such as a newer build replacing its image). Every recorded run is readable node by node with
the values on the wires; one whose trigger has recorded: false keeps no
history: if it fails, its failure is kept, and if it reported a cost or asked
weft for something on its behalf, its costs and how it ended are kept.
How a run is kept
Every trigger has inputs, written in its braces like any other, that say how the runs it starts are kept: durable, recorded and keepRunsFor:
| Input | Default | Change it when |
|---|---|---|
durable |
off | the run has to carry on from the nodes already finished if its worker dies, with a record of exactly which ones ran |
recorded |
on | a route is polled every few seconds and each call would leave a run behind (turn it off) |
keepRunsFor |
the project's [runs] keep_for in weft.toml, else a week |
its runs should be kept longer or shorter once they end (12h, 30d, forever), then they are deleted |
pay = Route -> (body: JsonDict) {
path: "pay"
method: "POST"
durable: true
}
With durable off you get a fast run: it waits for its record only where weft has to act on it (a pause, a call to weft that names the run), and if its worker dies the run ends cancelled and is not run again. With it on, everything a run did is on record before a node that could act outside it starts (a pure node does not wait), and an answer to a caller is on record before it leaves. If the worker dies, another worker picks the run up and keeps every finished node; a node that was running is failed rather than run twice, unless it belongs to a pure node, read no stream and had sent nothing on: that one simply runs again. If a caller started the run, that caller's connection died with the old worker, so a node that answers it then fails, saying no live caller is attached. A
run with recorded: false cannot be tagged, and durable or outlivesCaller (on any trigger that answers a live caller) with recorded: false is the run-settings error.
A run that cannot pause (recorded: false, a caller on the line with outlivesCaller off, a bus between its nodes open) holds a wait (a timer, a form) in its worker instead of pausing, and the answer carries it on there. Every trigger has holdSecs (default 60, 0 to never wait, at most 30 days): when nothing has moved in the run for that long, the waiting node fails saying it gave up, which it may handle itself or send to _error. If the run can pause again (its last bus closed), the wait pauses after all.
A run you start by hand is always recorded. If it fires a trigger (weft run --fire), it is kept the way that trigger's durable and keepRunsFor say, and any other hand-started run is fast. --durable, --fast, --keep-for and --hold-secs override that for the one run. For polling routes and answering early, go and read the weft-api skill.
Stopping other runs
When something new makes the work already in flight pointless (a second
message before the first answer is finished, a new upload replacing a file
still being processed, one run of a batch finding the batch broken), you
stop the old runs with two catalog nodes, wired right after the trigger and
before any work. A run puts tags on itself with TagRun, and a new run
stops every older run carrying a tag with StopTagged:
telegram = TelegramAccess
ask = TelegramReceiveMessage { account: telegram.access }
claim = TagRun { sender: ask.chatId }
stop = StopTagged {
_should_flow: claim.done
sender: ask.chatId
}
router = OpenRouterAccess
draft = LlmInference {
_should_flow: stop.done
provider: OpenRouterModel { access: router.access, model: "z-ai/glm-5.3" }.provider
prompt: ask.text
}
If you catch yourself building this with a loop, a flag, a Python node, or a table, stop and write: "Wait. Tag the run, then stop the tagged ones." Bookkeeping you write yourself cannot reach a run already parked on a person or a timer, and two events arriving together will race it. Tagging and stopping are the engine's, reached by two calls: the catalog wraps them as the pair below, and a node of your own makes the same two calls (named at the end of this section).
Every input you wire onto TagRun is a tag the run puts on itself; here the
sender, so one person's runs all carry the same tag. StopTagged reads its
tags the same way and stops every older run carrying one, waiting runs
included: a run parked on a person or a timer is stopped and never resumes,
its form and its timer are gone, and answering the old form does nothing. A
stop only reaches runs that put the tag on before this one did, so two
messages arriving a moment apart do not kill each other: the newer run
survives, the older ones die. The two _should_flow wires set the order
that makes that true: tag first, then stop, then the work.
includeSelf: true on StopTagged stops this run too, with the rest: the
shape for one broken run taking its whole batch down. A StopTagged whose
run never put the tag on itself (a supervisor run clearing one user's whole
backlog) has no place in the order, so it reaches every run carrying the tag.
The other reason to reach for the pair is a run that HOLDS something rather than one that has gone stale. A run that holds a caller's connection open lives as long as that caller: one run per open tab, and a person who reloads three times leaves three behind. A socket run always holds one, and so does a route whose answer arrives over time rather than in one piece. Tag such a run by whatever names the connection (the room, the board, the person) and the next connection from it stops the one before, so a reconnect REPLACES its run instead of stacking on it.
A stopped run ends cancelled, and its journal names who did it:
Stopped by run <run-id> (tag <tag>). Any string is a tag: a Telegram
chat id, a WhatsApp address (49151@s.whatsapp.net), a phone number with a
+ and spaces. Both nodes clean it the same way (unsafe characters become
_, a short fingerprint of the original is appended, so two different
values never share one), so you wire the raw value and never clean it up
yourself. Inside your own node the same two moves are ctx.tag_run
and ctx.stop_tagged, which take a tag already clean (letters, digits, _,
-, at most 64) and refuse anything else, naming the character; for those,
go and read weft-node-authoring.
Asking which runs are going
If a page has to say "still working" or "failed", ask weft about the runs
instead of keeping a status column: a column the run sets at the end stays
stuck at "working" when the run fails before it gets there. ListRuns
(the runs themselves, newest first, with their status, and on a failed
run its error, what failed it) and CountRuns
(how many) in the logic package take the same filters: instance,
status (running, completed, failed, cancelled; running includes
a run parked on a wait), node (the trigger or node that started it), tag
and olderThanSecs. tag reads a tag the way TagRun wrote it: wire the
same value and you find the same runs. Inside a node of your own the same
question is ctx.runs() with those filters, ending in .list(limit) or
.count().
Groups
A group is the unit of readable size, what [the level rule] asks for: every
level of the graph, the file and the inside of every group, aims for
six items, nodes or groups; past fifteen the compiler warns
level-too-large. At the file's top level the count is per connected part:
everything a wire links counts together, a shared database or a connection
node (a key set, a provider) included, and only pieces that share no wire at
all count apart (worked example below). The inside of a group, a loop and an
included file is counted whole. The warning names what it counted: "one
connected part of the top level holds N items", or "this included file holds
N items". A level holding a per-instance node and its routes is the one
expected exception (weft-instances, "Routes stay shared").
Growing work goes down into a nested group, never
wide across a level, and a group's boundary stays small: a group with a
dozen ports is two groups, or the wrong split.
preprocessor = Group(raw: String) -> (result: String) {
# Cleans and transforms text
clean = ExecPython -> (out: String) {
code: "return {'out': text.strip()}"
text: self.raw
}
self.result = clean.out
}
# a group's boundary ports: wired from outside, on their own lines
preprocessor.raw = input.value
self is the boundary: read self.<input> for what the group received,
write self.<output> for what it emits. Children reach each other and
self, nothing else (scope-reachability). A [boundary port] is set from
outside by wire or literal on its own line. Groups nest and names are
scoped. A group is a region, not a function: it has no call sites and does
not return, and wires run straight through its boundary.
A group stops as a whole when its gate says no, or, with no gate, when every
wire into it closed with nothing on it; then every node inside is skipped,
naming the group (scope_skipped). When a group opens, every node inside
that no wire feeds starts with it (once per iteration in a loop body), so a
group can hold its own source.
A [closed] pulse on one group input, while another brings a value, does not stop the group. It travels in like any other pulse and lands on the nodes that read that input; those skip, and everything else in the group runs. A card whose picture is optional:
look = Route -> (body: { text: String, photo?: Image }) { path: "cards", method: "POST" }
card = Group(text: String, picture?: Image) -> (saved: String) {
store = ExecPython(text: String, shot?: Image) -> (line: String) {
code: "return {'line': text + (' with a picture' if shot else '')}"
text: self.text
shot: self.picture
}
self.saved = store.line
}
card.text = look.body.text
card.picture = look.body.photo
answer = Reply { body: card.saved }
Post a card with no picture. The body has no photo, so the wire from
look.body.photo closes; card.picture carries that closure in. store declares shot?, so
a closed pulse there does not stop it: it runs with shot absent, writes its
line, and the caller gets an answer. Nothing else in the group noticed.
Drop the ? from shot and store skips instead, self.saved never gets a
value, saved closes, answer skips, and the caller is left with nothing.
That one character is the whole difference between "the picture is optional"
and "there is no card without a picture". Note where it sits: on the port of
the node that reads the value. The ? on photo in the body's record only
says the caller may leave the key out; it is the ? on shot that lets
store run without it.
The build holds a group's ports to that same rule. A port nothing outside
connects is fine while it reaches only inputs written ? inside, or
inputs of a join node such as FirstInOrder, which are optional without one. Once it reaches a
required input, or the last member of a @require_one_of set that could get a
value (through any group, loop or included file in between), weft validate
fails with required-port-unmet, naming the group's port and the
input inside that needs it. A ? on the group's own port changes nothing: the
node that reads the value is what decides.
Worked, for the top-level count: a file holding db = PostgresDatabase, a
cards group (four nodes inside), a gallery group (three nodes inside) and
a stats group (two nodes inside), each of the three wired to db, is one
connected part of four items (the three groups and the database), well inside
the rule, whatever the groups hold inside; the inside of each group answers
for itself. Twelve flat nodes all wired to db is one part of thirteen, and
the answer is those three groups, never a wider main.weft.
Included files
Reuse across files: triage = @include("triage.weft"), where the included
file is exactly one anonymous top-level group and nothing else; its ports
become triage's. That shape is for included files only: an anonymous
group in src/main.weft is a compile error, the entry file's groups carry
names:
# src/triage.weft: sorts a message into a lane
Group(text: String, provider: LlmProvider) -> (lane: String) {
sort = LlmInferenceJson -> (response: { lane: String }) {
provider: self.provider
params: LlmParams { systemPrompt: @file("assets/prompts/triage.md") }.params
prompt: self.text
}
self.lane = sort.response.lane
}
The model comes in through a port. Every access node and every model sits at
the top of src/main.weft, one access node per service, and is wired down
into each group and included file that needs it:
router = OpenRouterAccess
smart = OpenRouterModel { access: router.access, model: "z-ai/glm-5.3" }
triage = @include("triage.weft")
triage.provider = smart.provider
triage.text = ask.text
An access node written inside the file instead would be one more connection
to pick for every place the file is included. A value each instance fills in
for itself goes on that top-level node too (account: @instance_filled on
the access node, the weft-instances skill), so each person is asked for it
once.
A trigger inside an included file works like a trigger inside a group: it
registers when the including program activates, and it is addressed through
its site (one.door).
The file is compiled once and every @include of it is a call: a run
reaching triage sends its port values into the file's one body under a
frame naming the call site, and the results come back to that site alone.
Includes and loops nest freely. Only the type check repeats per call site: a
file holding Group[T: Media](file: T) -> (out: T) ([T: Media] limits
T to kinds of Media; without it, T takes any type) can be included once fed an Image and once fed a Video, and each
site's out keeps the type that went in. That holds however deep you go: for
a group nested inside the file, for a node's type variable, and for another
file the file includes. If the file is included as img and as vid, and it
includes another file as inner, then img.inner and vid.inner are
separate sites with their own types. The nodes and their code stay one copy
each.
An included file has no name you write or read: a node inside is always
addressed through the site, the way the source reads. weft events <run> --node triage.classify shows that one use of it and every row prints its
node that way, weft run --group triage runs the call, and a cut inside the
file is spelled the same (--from triage.classify, --target triage.classify) and runs inside that one call. The same file included
twice reads apart (triage.classify, again.classify) and runs apart: each
call has its own runs and waits, its triggers register once per call, and
its infra is provisioned once per call (weft infra status lists triage.db
and again.db, each with its own disks and endpoint). An alias's ports take
a value the way a group's do, on their own lines (triage.tone = "formal"):
the alias has no braces to write in.
Loops
doubler = Loop(values: List[Number]) -> (results: List[Number | Null]) {
parallel: false
over: ["values"]
double = ExecPython -> (out: Number) {
code: "return {'out': n * 2}"
n: self.values
}
self.results = double.out
}
The settings in the braces (parallel, over, carry, maxIters,
trimOnMismatch) take a written value only, never a wire: the compiler
reads them to build the loop. A loop's ports take a value the way a group's
do, on their own lines outside (doubler.values = [1, 2]), never in the
braces.
Four port roles, derived from the config:
| Role | Rule |
|---|---|
iter input (in over) |
outside List[T], inside T; several zip to the shortest |
carry (in carry, declared on outputs) |
accumulator; the compiler creates the matching input for the initial value |
| gather output | in the output signature, not in carry; outside must be List[T | Null]; inside the write port is T? |
| broadcast input | in the input signature, not in over; same type both sides |
Two implicit ports: self.index: Number (read-only, zero-based) and
self.done: Boolean (write true to stop launching, sequential only).
Both names are reserved.
Inside a loop, self is the loop's own boundary, never the group around it.
A loop nested in a group cannot read the group's self.db (that is
unknown-source-port, naming both scopes and the two lines to write); the
value comes in as a broadcast input of the loop, declared in its signature
and wired from the group (run = Loop(db: Access, ...) { ... } then
run.db = self.db at the group level).
parallel defaults false. Five shapes: parallel map (true, over, no
carry), sequential map, fold (carry), while (no over, stop vote or
maxIters), side effect (no over, no carry). Refused: parallel with carry;
parallel with empty over; parallel with any self.done write; a port in both
over and carry; a sequential loop with nothing that can end it.
When something inside an iteration fails: a carried value the failure left
unwritten stops the loop and fails it: loop '<loop>' stopped at iteration N: its carried value '<port>' could not be updated because '<node>' failed: <error>. A carry closed because a gate or a branch did not run
keeps its previous value, and the loop goes on. A gathered output of a failed
iteration is a null slot, and the loop goes on. So if one bad item must not
end a fold, wire the failing node's _error output (under Handling a failure)
and write the carry on both branches.
Trying a node again
If a node can fail for a passing reason (a model whose JSON does not fit the
shape you asked for, a service that times out now and then), the retry is a
sequential loop, and maxIters is its cap. The node's _error goes into a
carried value, which the next try reads and which comes out holding the last
failure; self.done is written once the node's answer arrived, so a try
that worked is the last one. A record type on the answer makes a reply with
a missing key, a wrong type or an extra key a failure on _error, which is
what sends it round again:
type Verdict = { label: String, score: Number }
grade = Loop(text: String, provider: LlmProvider) -> (problem: String, verdicts: List[Verdict | Null]) {
# Grades the text, asking again while the answer does not fit, three tries at most
carry: ["problem"]
maxIters: 3
ask = Format(text: String, problem: String) {
template: "Grade this text: {{text}}. Answer JSON with label and score. {{problem}}"
text: self.text
problem: self.problem
}
mark = LlmInferenceJson -> (response: Verdict) {
provider: self.provider
prompt: ask.text
}
fits = All { verdict: mark.response }
self.verdicts = mark.response
self.problem = mark._error
self.done = fits.yes
}
grade.text = essay.text
grade.provider = model.provider
grade.problem = ""
final = ExecPython(tries: List[Verdict | Null], problem: String) -> (verdict: Verdict) {
code: ```
good = [t for t in tries if t is not None]
if not good:
raise ValueError(f"no answer fit after every try; the last problem: {problem}")
return {'verdict': good[-1]}
```
tries: grade.verdicts
problem: grade.problem
}
Every try leaves a slot in verdicts, empty for the ones that failed, so
final takes the one that fitted and fails loudly, naming the last problem,
when none did. A carried value needs its first value wired from outside
(grade.problem = ""); without it the build stops at
required-port-unmet. A retry that calls a paid service pays for every try,
so the cap is part of the price. If the call keeps a conversation in a file
(like the AI nodes' historyFile), wire that same file into every try as a
broadcast input of the loop. A try whose answer did not fit the record type
leaves the file exactly as it came in, so the next try sends the same
conversation, plus the reason the last try failed, which the carried
problem puts in its prompt.
A Generator[T] port in over pulls the stream (must be the only over
port).
What the language promises about timing is this: the next iteration starts
once every value of this one has reached the loop's edge (its outputs: a
carry, a gather, self.done). Nothing else holds it back. A loop is a
launcher, not an owner: work started inside an iteration that is not wired
to the edge keeps running after the loop moves on, and even after the loop
exits. That is on purpose: a loop can start twenty agents and return their
buses, and the nodes after the loop coordinate them. So if the next
iteration has to wait for some work (a database write the next iteration
reads), wire that work's result to the edge, into a carry, a gather or
self.done.
Files and reuse
| Marker | Pulls | Writes back |
|---|---|---|
@include("x.weft") |
a program as a group | no |
@file("x.md") |
a file's contents as a value | yes |
@asset("x.png", Image) |
a file's contents as a value | no |
@asset("x.txt", String) |
a text file's contents inline | no |
An @include path is measured from the file that writes it, and an @file
or @asset path from the project root. If you want a file that sits under
nodes/ (an include file a package ships beside its nodes, or a prompt that
file reads), start the path with __catalog/ and it is measured from
nodes/ instead: @include("__catalog/my_pkg/triage.weft") reads
nodes/my_pkg/triage.weft, and that file reaches the prompt beside it with
@file("__catalog/my_pkg/triage.md"). A __catalog/ path that climbs out of
nodes/ is refused.
A stored file travels a wire as a small reference (its key, name, mime type
and size), never as bytes, and never with a link in it: a node that needs a
link makes one while it runs, so the journal and weft events hold none. A
node that runs your code may hand the reference over in its own shape
(ExecPython passes a plain dict with photo["internalUrl"] to fetch the
bytes from the script and photo["externalUrl"] to hand another service,
with photo["public"] False when the open internet cannot open that link,
so the script sends that service the bytes instead), and its description
says how.
A stored file lives as long as its scope says: a run's files are swept when
the run ends, a project's stay. The storage nodes TextToFile,
FetchToStorage and KeepFile all take scope (run or project)
and ttlDays, and emit file, filename, mimeType and sizeBytes like
every node that makes a file. ttlDays works in both scopes: the file
expires that many days after anybody last touched it, since every read,
download or link resets the clock, and 0 keeps it for ever. Any other node
that makes a file (an S3, Slack or Drive download, a generated picture)
stores it for its run only: if you want it longer, or in a later run, wire
it through KeepFile. Every write makes a new file, except where a node
keeps a growing value: it edits the file it was handed, in place, and its
description says so.
A link made to a stored file lives 15 minutes. If people need it longer (a
video somebody watches for an hour), pass the file through LinkOptions { file: clip.file, minutes: 120 } (seven days at most): every link made from
its output lives that long.
If you want a stored file gone before its scope ends, DeleteFile { file: old.file } deletes it now. A file that lives at a URL is refused.
@file is bidirectional (the editor writes edits back into the file), so
binary types are refused; it is the marker for assets/prompts/ and
assets/scripts/, and its type defaults to String. @asset
always names its type, and a file type names one kind: Image, Video,
Audio, or Blob (never File or Media; the compiler never guesses a
kind from a name or bytes). When the install builds a version, it
works out what each @asset stands for. A path the version holds is
already stored with the version, so nothing is uploaded. A file-typed
@asset is checked against its declared kind (an Image over an mp3 fails
the build; Blob checks nothing), and a text-typed one has its text
inlined. A source can also be a stored file's key, which the install looks
up, or an http(s) URL: a text-typed one is fetched and inlined, and a
file-typed one stays a file that lives at that URL, so nothing checks its
kind.
A path the version does not hold (an absolute one, ~/..., or a project
file outside the folders a version covers) is refused by a cloud install,
whose error says to put the file under assets/ and name it from there. A
local install reads it off the machine: an absolute path where it is, a
relative one from the folder of the working copy the build came from. A
program can also write code into a working copy that has no folder on any
machine, and a build from that one refuses a relative path as well. So
keep every file a program names in a folder a version covers, usually
assets/. An @file in the source is read from the version's files when
the install compiles, so it has to name a file a version covers: the
.weft files, weft.toml, and what is under src/, nodes/, prompts/,
scripts/, sql/, assets/, examples/ and layouts/.
A port that takes a list of files takes a list of markers:
[@asset("a.png", Image), @asset("b.png", Image)].
When a check runs
Every compiler error carries a slug naming the rule and a message naming the fix, so read the one you got rather than looking a list up. What is worth knowing in advance is WHEN each kind of check runs, because that decides whether a mistake reaches you while you type or while the program runs.
[the edit tier] is the strict parse plus the
structural rules, fast and local, run by the PostToolUse hook after every
edit and by your own validate run, and what keeps the graph rendering. [the runtime tier] is
rule-runtime: what only a running program can know, chiefly a connection
not picked on an access node (the compiler synthesizes that rule from the
node's service declaration; connectionOptional: true in the service
block is the opt-out, for a node that runs with no connection picked). weft validate and the editor's pre-flight gate on Run, Turn on and Resync report
it; the Problems panel is structural only and never shows it; weft build
skips it, so a half-wired program still compiles, and a run, an activate or
a resync is refused before it starts while one is open, naming the weft connect that picks it. [the build tier] is weft build:
structural errors plus the cargo and image build. The slug names the rule,
the message names the fix.