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

SKILL.md

GitHub

The 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.