The weft language

SkillFiles & storage

Lets your agent write correct .weft pipeline code by reading the language rules, syntax, and compiler errors first.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the The weft language skill

About this capability

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 pulse execution model, @file, @asset and @include, and every compiler error slug.

What this skill tells your AI

The instructions your AI receives, as published by weavemindai/weft in tangle/claude-code/.claude/skills/weft-language/SKILL.md and read by ahel’s review.

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 { config_field: value }
name = NodeType {}

name is the node id, unique in its scope, snake_case. NodeType must exist in the project's nodes/ catalog; you read its real ports with weft describe-nodes --node <Type> --compact, never from memory.

Connecting

target.input_port = source.output_port

Right to left: the value flows from source.output_port into target.input_port, 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 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. A source typed JsonDict or a scalar has no keys to read (deref-path): narrow the source port to the shape it carries (next), or Cast 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 you may write a TIGHTER one on the arrow where you use the node. The compiler checks yours against the node's own and refuses a contradiction (declared type ... incompatible with catalog type), 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"
}

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.

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

Narrow only what you control the shape of. A port fed by the outside world (a query string, request headers) holds whatever the caller sent, and a record refuses a key it does not declare, at run time, on the real request. Narrowing those compiles and then fails the first time somebody appends a tracking parameter. Leave them as the loose type the node declares and read the one key you want with a node.

When the opaque value comes from a node that lets you ADD OUTPUT PORTS, name the pieces you want on the arrow instead. Each arrives on its own port under the type you wrote, so there is nothing to unpack and nothing to Cast:

look = PostgresExecuteQuery(id: String) -> (title: String, seen: Number) {
  query: "select title, seen from cards where id = $id"
}

Declare the type you actually want there, a named one included (-> (card: Card)): the value is checked against it when it arrives, so a shape that fits flows and one that does not fails loudly, naming the field. Reach for the record type when the source cannot name the pieces for you, and 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 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}}" }.

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 step runs, which service is called, a call made once per item) is coordination, and it belongs in the graph, where each step is visible and journaled: a Switch with _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. key?: makes the created port optional, whatever the node's own default is.

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.

step = ExecPython -> (out: String) {
  code: @file("assets/scripts/step.py")
  text: draft.answer      # a String port, from the wire
  limit: 3                # a Number port, from the literal
  notes?: review.notes    # optional: a 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. FirstInOrder is the catalog node that does: its first input that carried a value is the one it emits, so reordering its lines changes which branch wins. That is the only way line order changes what a program does.

Literals on a connection line

post = SlackSendMessage { 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: step.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

step = 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):

KeyEffect
_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; every other port does the opposite, skipping its node once an input closes, 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 the node its _should_not_flow watches did not finish (...), the error in the brackets) 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 ends in : a node before it failed (...)), so the rule holds any number of skips down the line.

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 }
bye._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
  out.body = rows.rows
}
work.db = db.access
work._should_flow = live.method     # the route runs the group; nothing else connects them

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 = LlmInference -> (response: String)
ok     = Cast -> (value: Boolean)

Inputs arrive in Python as variables named after ports; the code returns a dict keyed by output port name; None or a missing key emits no [pulse] on that port. Write only the ports the type leaves open (a MustOverride output must be pinned once something reads it; one nothing reads can stay unpinned). An empty body equals no body.

Inline expressions

A node literal as a value, with a mandatory trailing .port:

out.data = Text { value: "hi" }.value
provider: OpenRouterProvider { model: "z-ai/glm-5.3" }.provider

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 {
  @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].

Types

KindWritten
primitivesString, Number, Boolean, Null
stored filesImage, Video, Audio, Blob (references, not bytes)
aliasesMedia = Image | Video | Audio; File = Media | Blob
containersList[Number], List[List[String]], Dict[String, String]
unionsString | Number, Number | Null
records{ role: String, name?: String }, strict: undeclared keys refused
opaqueJsonDict, compatible with any Dict[String, V] both ways
type variablesT, unified across a node's ports, must pin to concrete (unresolved-typevar)
MustOverridethe author pins it in an inline signature; wired and unpinned is must-override-unmet, unwired is left alone
live handlesBus, 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). 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 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. here: String? is refused. An output port takes no ?: emitting nothing on it closes it, and no marker changes that.

Cast CONVERTS a value to the type declared on its output: history = Cast() -> (value: ChatHistory). The conversion table is checked at compile time (cast-not-allowed for impossible pairs); record and named targets are validated at run time, errors name the offending field.

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, 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 [color] (one run: every run gets one color, and a re-run is a new color) and on frames (loop iterations).

A node that emits on some outputs and not others closes the rest. A [closed] pulse on a required input skips the node and closes its outputs; on an optional input the node fires anyway; on _should_flow the node skips, period. Skips cascade forward until a node opted into absence with ?. A failure propagates exactly like a missing value, and a downstream optional input is the recovery path.

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). Three nodes exist to shape the decision itself, and you reach for them rather than deriving them: 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.

Three shapes come up constantly, so reach for them rather than deriving them again:

# One gate, two conditions: delete only if the model said rude AND said sure.
agreed = All
agreed.rude = judge.rude
agreed.sure = judge.sure
remove._should_flow = agreed.yes

# A default: the caller's `limit` when they sent one, else 20.
limit = FirstInOrder
limit.asked = door.limit
limit.fallback = 20

# An object out of values the graph computed: one key per wire.
body = JsonObject {
  job: open.id
  status: "running"
}

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
2k
Forks
221
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
weft-language
Source
github.com/weavemindai/weft