Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Ask any team to draw their AI system on a whiteboard and you get boxes and arrows in a minute or two. Input arrives here, the model sees it there, this branch needs a human, that one writes to the database, and if the model says “delete” then something had better check first.

Now ask their codebase the same question. Nothing answers. The boxes and arrows are the program, and they live in the whiteboard photo and in the Python that happens to implement them, nowhere a machine can read.

Weft is a language for the boxes and arrows.

mailbox = EmailAccess

ticket = ReceiveEmail
ticket.account = mailbox.access

llm = OpenRouterProvider { model: "openai/gpt-4.1-nano" }

classify = LlmInference -> (response: String) {}
classify.prompt = ticket.body
classify.provider = llm.provider

review = HumanQuery {
  title: "Escalate this ticket?"
  fields: [{ "kind": "approve_reject", "key": "escalate" }]
}

alert = Debug {
  _should_flow: review.escalate_approved
  data: classify.response
}

An email arrives, a model reads it, a person approves the escalation, and only approved tickets reach the alert. Nothing above is an excerpt: that is the file the runtime compiles, and by the time it runs it is a Rust binary with your nodes built into the engine.

What is actually different

The orchestration is the source. The wires in that file are the program’s structure. So the compiler holds you to it: every type has to line up and every required input has to be connected, and it says so before anything runs.

Waiting is free. HumanQuery suspends the execution and lets the worker process exit rather than block. The execution becomes rows in a table, and when the answer arrives a fresh worker rebuilds the state and continues from where it stopped. How that works: The journal.

A node’s job is small enough to fit in one head. Everything a node needs from the outside world (an authenticated HTTP client, file storage, a channel to another node, the ability to pause) arrives through one object, already built. A node that calls Slack contains the Slack call and nothing else.

And a node’s ports say nothing about what is behind them, so HumanQuery and LlmInference are the same kind of thing here. A program built only out of people and services is an ordinary weft program.

Who writes it

Mostly an AI, in practice. The syntax is strict, so a model writing weft cannot wire a String into a Number, leave a required input dangling, or invent a control flow nobody checked.

You read the result as a graph, click a node, and look at the value that actually came out of it. This book is the full reference, so you should be able to read any program in it without help.

How to read this book

If you have never run weft, start at Install and follow the five short pages after it. They end with a program on a URL that pauses for a human.

If you want to understand the model before touching it, How a weft program runs is the chapter everything else rests on.

If you are here to write a node, What a node is and The ctx are the two pages that matter, in that order.

If you are here to argue, Things people say to me is where that is collected.

Where things stand

For what is solid today, what the catalog holds, and what is being built next, go and read the roadmap.

Install

One script builds everything and leaves a working runtime on your machine.

git clone https://github.com/WeaveMindAI/weft.git
cd weft
./setup.sh

On a clean checkout the first run downloads the CLI, the extension and the container images from the latest published build, creates a local Kubernetes cluster, and starts the runtime; no Rust or Node toolchain needed. Once you change any file, the script compiles from source instead (which needs the toolchains below), and later runs redo only what changed.

What you need first

The script checks for what your run needs before it starts and names every missing tool at once.

ToolWhyGet it
dockerruns Postgres and the clusterdocs.docker.com
kubectltalks to the local clusterkubernetes.io
kindthe local cluster itselfkind.sigs.k8s.io
cargocompiles the CLI and the runtime, once you have local changesrustup.rs
node 20+ and pnpmbuilds the VS Code extension, once you have local changesnodejs.org, then npm i -g pnpm

If you are on macOS you also need a newer Bash than the one Apple ships: brew install bash.

And if you only want part of it, the flags below skip the rest and skip their checks with them. If a published binary turns out broken, --from-source compiles the CLI and the extension locally even on a clean checkout.

What you get

One binary, weft, symlinked into ~/.local/bin. If that is not on your PATH, the script prints the exact line to add to your shell config.

The runtime itself is not a binary on your machine: the dispatcher and the workers run as containers in the local cluster, which is why the script builds images as well as compiling. Plus the VS Code extension, where the graph view and the live execution view live.

The script leaves the runtime up, so by the time it finishes the daemon is listening on port 9999 and you can go straight to your first program.

Picking a subset

Flags combine, so --cli --daemon does both and skips the editor.

If you wantPass
just the weft command--cli
just the runtime rebuilt and restarted--daemon
just the VS Code extension--vscode
just the browser extension, which is opt-in because it signs with Mozilla and builds every browser target--browser
the CLI compiled much faster, while you are iterating--debug
the binary somewhere other than ~/.local--prefix PATH
everything but the daemon refresh--no-daemon
an extension release: bumps its version, which is what makes CI publish the pushed commit to the stores--bump (with --vscode and/or --browser; the default install covers --vscode)

Your settings file

Weft reads a .env next to your project, and there is nothing in it you need to get started. Two settings are worth knowing about before you store anything you care about: CREDENTIAL_ENCRYPTION_KEY, which seals stored credentials at rest and boots with a development key until you set it, and WEFT_PUBLIC_TUNNEL_TOKEN, if you want a permanent public address rather than a fresh random one each time. The full list is the environment.

A malformed .env fails the boot rather than being half applied.

Removing it

There are two levels of it, depending on whether you want your work back afterwards.

./setup.sh --uninstall            # take the tools away, keep the work
./setup.sh --uninstall --purge    # take everything

--uninstall stops the daemon, removes the VS Code extension, and drops the weft symlink. It deliberately keeps the cluster, the database, the object store and its volume, the built images, the BuildKit cache, and target/. Run ./setup.sh again and your projects and their whole execution history are back in seconds.

Neither is ever needed to apply an update. ./setup.sh brings an existing install to whatever the code now says: the schema, the images, the manifests, the ingress and gateway controllers, and the object store’s container. When the cluster’s shape or your kind version has moved, it rebuilds the cluster too, and says so first, because every project’s own database lives inside the cluster’s node and dies with it. Your system database survives a rebuild either way; its files live in ~/.local/share/weft/postgres-data rather than inside the cluster.

--purge is the real clean slate: the cluster goes, the images go, the database volume goes. Reach for it when you want to prove a fresh machine would work.

A note on the cluster

Weft runs your programs as pods on Kubernetes, including on your laptop, where the cluster is a single kind node inside Docker. That is what lets a project ask for a Postgres, a headless browser, or a model server as a node you drop on the graph, and get a real container with health checks and a lifecycle.

You never write YAML. You will not think about the cluster again until you read Infrastructure nodes.

Next: your first program.

Your first program

weft new hello
cd hello
weft run

weft new scaffolds a project. weft run compiles it, registers it with the daemon, fires one execution, and streams the events back until it finishes.

Bring your AI assistant

You are meant to build weft by talking. The assistant that builds with you is called Tangle: a persona weft installs into your project, who knows the language, the whole node catalog on disk, and the loop of build one stage, run it, read what came out. Tangle is part of weft, not a plugin you wire up.

The flow is two steps:

weft new hello --assistant kilo-code       # shorthand: --assistant kc

then open the hello folder in that assistant (Kilo Code, here). Tangle loads on its own, with its method, its node reference, and its commands already in place. You describe what you want, in plain words; it shapes the program, picks or writes the nodes, runs it, and shows you what happened.

The flag’s value is the assistant you use, so the same command covers every assistant weft supports as more arrive (repeat the flag to install for several at once). And the choice is remembered: your next weft new installs the same assistant with no flag at all, until you pass --assistant <name> to change it or --assistant none to stop. Claude Code is also available as --assistant claude-code (shorthand cc).

Tangle is copied into the project, so those files are yours like the rest of it: they get committed, and someone who clones your project gets Tangle without needing a weft checkout. The price is that a project holds the Tangle that created it. If you want one on a newer version after updating weft, run weft tangle update in it, which re-copies every file Tangle owns and leaves anything your assistant wrote beside them alone.

What got created

hello/
  weft.toml      the project's name and its permanent id
  src/
    main.weft    the program
  nodes/         every node this project can use
  .weft/         build output and caches (already gitignored for you)

nodes/ is the surprising one, because it changes where your nodes come from. When you run weft new, the entire standard library is copied into your project under nodes/base_catalog/, so the build never reaches back into the weft installation and upgrading weft cannot change what your program does. If you want the newer standard library later, weft catalog update re-syncs that mirror.

Your own nodes go anywhere else under nodes/, or beside the code under src/, never inside base_catalog/, because weft catalog update wipes and recopies that folder and anything you edited in there goes with it.

The program

src/main.weft is three lines:

greeting = Text { value: "hello world" }
out = Debug

out.data = greeting.value

Two node declarations and one connection.

The first line says: make a node called greeting, of type Text, configured with the string "hello world". The second makes a Debug node called out. The third wires them.

Read the connection right to left, the way an assignment reads: the value flows from greeting.value into out.data.

  greeting (Text)                 out (Debug)
  ┌──────────────────┐            ┌──────────────┐
  │ value: "hello…"  │            │              │
  │            value ●───────────▶● data         │
  └──────────────────┘            └──────────────┘

Before anything ran, the compiler checked that connection. Both ports exist, Text.value emits a String, Debug.data accepts one, and nothing required was left unwired.

What weft run printed

One line per node event, in order: the execution started, greeting ran and emitted, out ran, the execution completed. All of it is written to the journal as it happens, and you can read them back later with weft events <color>.

A color is one execution. Running the same project again mints a new one, so whenever anything in weft says “per color”, it means per execution.

Change something

Edit src/main.weft:

greeting = Text { value: "hello world" }
shout = ExecPython(text: String) -> (out: String) {
  code: "return {'out': text.upper() + '!'}"
}
out = Debug

shout.text = greeting.value
out.data = shout.out

weft run again. The chain is three nodes now.

ExecPython is worth noticing because of the arrow. Most nodes have fixed ports declared by their author; this one lets you declare them inline. (text: String) is its input, -> (out: String) is its output, and the Python body gets text as a variable and returns a dict keyed by output port name. The compiler type-checks those ports like any others.

The mental model

A node fires when all of its required inputs have arrived. When it fires it runs its code and emits values on its output ports, and each emission travels along a wire to exactly one input port and waits there. A node with no upstream fires immediately, and the execution ends when nothing is left in flight and nothing is waiting.

Everything else in the language, groups and loops and streams and human pauses, is built out of that one rule, including the wrinkle where branching comes from: what happens when a node produces nothing. That is How a weft program runs, the chapter to read once you want to build something real.

Next: reading the graph.

Reading the graph

Open the project folder in VS Code. Open src/main.weft. The graph appears beside the code.

It is the same program, not a visualization generated from the code. The file and the picture are two views of one thing, and edits in either direction go through the compiler, so they cannot drift.

Two directions, one source of truth

Type in the file and the graph redraws on save.

Drag a node, edit a field, connect two ports with the mouse, and the editor does not touch the text itself. It sends a structured edit to the compiler, which rewrites the source and hands back the new text. That is why your comments and formatting survive a GUI edit: the parser keeps every byte of the original, and the editor only ever asks it to change one thing.

Node positions are the exception. They live in a layouts/ tree at the project root that mirrors your source paths, rather than in the .weft file, because where a box sits on a canvas is not part of what the program does.

A dotted wire with a label like .stats.wpm at its end is a wire that reads one key off the value it carries, the graph form of speed.wpm = reader.profile.stats.wpm. Right-click a wire to pick a key, one level at a time, or to go back to reading the whole value.

The arrow in the top-left corner

Every box, node or group, has a small amber arrow on its top-left edge, apart from its own inputs. That is _should_flow, the port that decides whether the box runs at all. It is filled in when something answers it and hollow when nothing does. For what counts as a no, go and read How a weft program runs.

Watching it run

Hit run from the editor, or weft run in the terminal with the graph open.

Each node lights as it fires. Click one and the inspector opens on the exact values that went in and came out of that firing.

You will spend most of your time in this loop: run it, click the step that looks wrong, read the real value, fix that step, run it again. You are never guessing what the data looks like at step four, because step four is one click away and it is holding the value.

Groups: how a big graph stays readable

Any subgraph can be collapsed into a single box with typed input and output ports. From outside, that box behaves exactly like a node.

preprocessor = Group(raw: String) -> (result: String) {
  # Cleans and transforms text

  clean = ExecPython(text: String) -> (out: String) {
    code: "return {'out': text.strip()}"
  }
  clean.text = self.raw
  self.result = clean.out
}

Inside a group, self is the group’s own boundary: reading self.raw pulls the group’s input, writing self.result sets its output. A group’s children can talk to each other and to self, and to nothing else, which is what makes each box a contract you can check without opening it.

When a group’s ports carry values written in the source rather than wires, a Config strip under its header lists them, the same strip a loop uses for its knobs. Open it to change one.

It is also why debugging scales, and why groups cost nothing at run time: Groups.

One file, several programs

A .weft file is not one program. Nothing in it declares an entry point: a run starts from where its first pulses are put (every root, for a manual run; the fired trigger, for a fire), so a file holding six triggers holds six programs. A fire runs its own program and leaves the rest alone.

That changes how you lay one out. Every process a team has can sit on one canvas where you see them together, instead of split across files, because fires stay inside their own programs. A branch you are half way through building costs nothing on a fire (no trigger reaches it); a plain manual run kicks every root in the file, so aim it at what you mean to run.

It also makes something worth keeping: a cluster of nodes you reach for often, parked off to one side and wired to nothing. Copy it into a new path when you need it, wire it up, and only that path fires.

And when you want one result out of a busy canvas, aim the run at it. Right click a node, Set as target, and it starts breathing in its own colour while the Run button becomes “Run 1 target”. Target a few and it counts them. Right click again to unset.

From the terminal it is the same thing:

weft run --target daily_report

Either way it is the same run from a smaller set of first pulses, and any node can be the target: What actually runs.

An aimed run also answers only to what it would execute. If the targets’ joined subgraph reaches no trigger, the Run button shows up even in a project whose triggers are the usual entry point, which is how a hand-fired maintenance branch runs without activating anything. The little arrow next to Run stays in every project, triggers or not: it opens the saved examples, so you can replay one whenever you like, and a run started that way is saved like any other. And it is gated by exactly the infra it would touch: an infra node inside that subgraph has to be running first (the button stays grey, and weft run --target refuses, until it is), while infra elsewhere in the project does not hold it up.

Next: putting it on a URL.

Putting it on a URL

So far the program runs when you ask it to. A trigger node makes it run when the outside world asks instead, and for an HTTP request that trigger is Route.

Replace main.weft with:

hello = Route -> (name: String) { path: "hello", method: "POST" }
answer = Reply { status: 201 }
answer.body = hello.name

Every POST to hello fires a fresh execution. The body’s name key comes out on the port you declared after the arrow, and Reply sends it back as the response, under a 201. No Rust, no node folder.

Two things in that program are worth a closer look.

The body keys are ports you declare. A route has no body port. You say which top-level keys you want (-> (name: String, age: Number)) and each one arrives typed on its own port, the same way an LLM call with Parse JSON on splits its reply. Anything you did not name is dropped, so name every key you mean to read. The request itself is always there on the fixed ports: method, path, params (the {name} captures of the path), query, headers, and caller (who the auth gate let in; null on an open route).

A port named like a {capture} in the path reads that capture (Route -> (id: String) { path: "cards/{id}" }). A port declared Image takes a picture the body carries as a data URL or base64: the route stores it and hands you the stored-file value, never the bytes.

Reply is one message. Behind a Route it is the response: status, headers, the body, and the exchange ends. The body’s shape follows the route’s dataType: any value on the default json, a String on text, a stored file on bytes. A stored file inside a json body goes out as a link the caller can fetch ({ url, mimeType, filename, sizeBytes }). Every answer mints a fresh link and the old one dies within minutes, so what you keep between runs is the file itself.

Turn it on

weft activate

activate compiles the project and registers it, printing activated <name> (<id>). A project with triggers has to be activated; one without them just runs.

It prints no URL, and it does not need to: the address is fixed by the install, so you can write it down before the program ever runs. Locally it is http://127.0.0.1:9999/connect/local/<your path>. That address answers on this machine only for now, so call it from here:

curl -X POST "http://127.0.0.1:9999/connect/local/hello" \
     -H "content-type: application/json" \
     -d '{"name":"ada"}'
"ada"

Each request is a full execution with its own color and its own row in the editor’s execution list. weft follow <project> streams them live as they arrive.

What just happened underneath

Activating the project told the runtime: when a request arrives at this path with this method, start an execution of this program and hand the held connection to whichever worker picks it up. Nothing in your program is listening.

So the endpoint exists whether or not any worker is running. When a request arrives cold the runtime starts one, which is why the first request after an idle period is slower. Workers shut themselves down after thirty seconds with nothing to do.

The response head is held until your program’s first outbound item: a Reply sets the status, a Stream starts a body, a Close ends it bare. A program that never does any of those holds the caller while it runs, and when the run ends the caller gets a 500 whose body says the run ended without answering. That is the program’s bug, and the 500 is how you find out.

The rest of the shapes an API takes (a route that answers 404, a stream of server-sent events, a WebSocket conversation, a route behind an API key) are in Building an API.

Next: putting a person in the loop.

Putting a person in the loop

Some steps belong to a human: an approval, or a correction the model should not make alone.

Getting a program to wait for one is normally the expensive part, because something has to remember where the flow was and wake it up again without losing anything, which usually means a queue, a webhook and a state machine.

In weft it is a node.

review = HumanQuery {
  title: "Escalate this ticket?"
  fields: [{ "kind": "approve_reject", "key": "escalate" }]
}

Wire something into it, wire its outputs onward, and run. The execution reaches review, suspends, and the worker process exits rather than blocking.

The task appears wherever a person can answer it. When they do, a fresh worker starts, rebuilds the execution’s state from the journal, and continues from exactly the point it stopped. That gap can be four seconds or four weeks; the code is identical and so is the cost, which is one row in a table.

The ports come from the form

HumanQuery has no fixed output ports. Its ports are derived from the fields you configured, at compile time.

The approve_reject field keyed escalate produces two Boolean outputs:

  • review.escalate_approved
  • review.escalate_rejected

You never declare those. Add a text_input field keyed reason and you get a review.reason String output alongside them. Change the form and the ports change with it, and every wire you had is re-checked against the new shape.

So this compiles or it does not:

alert = SlackSendMessage {
  _should_flow: review.escalate_approved
  channel: "#oncall"
  text: classify.response
}

_should_flow is on every node and decides whether it runs. A false there skips the node, which closes its outputs, which skips everything behind it, so rejecting the escalation ends that branch on the spot. That is how branching works here, and it is covered properly in How a weft program runs.

Where the task shows up

Tasks reach people through the weft browser extension. Build it with:

./setup.sh --browser --no-sign

--no-sign skips signing the add-on with Mozilla, which you do not need for a local install and which fails without AMO API keys.

Load the unpacked build, then connect it to your runtime with a token:

weft token mint --name "my laptop"

That prints a connect URL (and the bare token on a second line) exactly once, because the server stores only a hash of it. Paste it into the extension and pending tasks start arriving.

A token can also be narrowed to one project or one kind of task, which is how you hand a reviewer something that only ever shows them their own queue. That, and the per-browser loading steps, are in The browser extension.

Watch the handoff

Run the program with the graph open. The HumanQuery node goes into its waiting state and stays there. Answer in the extension and the graph continues in front of you.

Answer it tomorrow instead and you get the same result, because the execution is rows in a table rather than a process holding state. The journal covers what those rows contain.

Next: when something goes wrong.

A bigger example

Everything in the book so far has been one idea at a time. This is a whole program: a Telegram bot that draws pictures, and charges a credit for each one.

telegram = TelegramAccess
db = PostgresAccess
fal = FalAccess

ask = TelegramReceiveMessage { account: telegram.access }

credit = Group(db: Access, telegramUser: String) -> (paid: Boolean, refusal?: String) {
  # Take one credit off this telegram account, or say why we cannot.
  # The query reads the sender's id as `$telegram_id`.
  debit = PostgresExecuteQuery(telegram_id: String) {
    query: @file("assets/sql/spend_credit.sql")
    account: self.db
    telegram_id: self.telegramUser
  }

  # `refusal` says nothing when the credit was taken
  read = ExecPython(rows: List[JsonDict]) -> (paid: Boolean, refusal?: String) {
    code: @file("assets/scripts/outcome.py")
    rows: debit.rows
  }

  self.paid = read.paid
  self.refusal = read.refusal
}
credit.db = db.access
credit.telegramUser = ask.user

sorry = TelegramSendMessage {
  account: telegram.access
  chatId: ask.chatId
  text: credit.refusal
}

brief = Group(request: String) -> (prompt: String) {
  # Turn what they typed into an image prompt
  write = LlmInference -> (response: String) {
    prompt: self.request
    provider: OpenRouterProvider { model: "anthropic/claude-sonnet-4.5" }.provider
    params: LlmParams { systemPrompt: @file("assets/prompts/image_brief.md") }.params
  }
  self.prompt = write.response
}
brief.request = ask.text
brief._should_flow = credit.paid

picture = FalGenerateImage {
  model: "fal-ai/flux/dev"
  imageSize: "square_hd"
  account: fal.access
  prompt: brief.prompt
}

reply = TelegramSendMedia {
  kind: "photo"
  account: telegram.access
  chatId: ask.chatId
  file: picture.image
  caption: brief.prompt
}

Somebody messages the bot. One credit comes off their balance if they have one, a model turns their message into an image brief, fal draws it, and the picture goes back to the chat. No credits, or no account at all, and they get told so instead.

Three files sit beside it, none of them weft:

FileWhat it holds
assets/sql/spend_credit.sqlone statement that takes a credit and reports what happened
assets/scripts/outcome.pyturns the query’s row into paid and, when it failed, a sentence
assets/prompts/image_brief.mdthe system prompt that turns a message into an image brief

@file pulls each one in as that field’s value, so the SQL lives in a real .sql file your editor can highlight while still being the node’s config. The query’s parameter is a port the node declares for itself, PostgresExecuteQuery(telegram_id: String), and the SQL reads it as $telegram_id. For what else @file can do, and its read-only sibling, go and read Files and reuse.

The credit group

credit = Group(db: Access, telegramUser: String) -> (paid: Boolean, refusal?: String)

A group is a node with a graph inside it. Its children can only reach each other and self, which is why the database connection comes in as a port: db is out in the file where the children cannot see it, so the group asks for it. In return you can fold the whole thing shut and read the program without it. For what else the boundary buys you, go and read Groups.

Two endings from one query

The whole check is one statement, so the read and the debit cannot drift apart between two queries:

with account as (
  select id from users where telegram_id = $1
),
spent as (
  update users set credits = credits - 1
  where telegram_id = $1 and credits > 0
  returning id
)
select
  (select id from spent) as user_id,
  case
    when not exists (select 1 from account) then 'this Telegram account is not linked to an account'
    when not exists (select 1 from spent) then 'you have no credits left'
  end as refusal

The two endings are driven by one value. When the credit goes through, outcome.py returns no refusal key at all, so that port closes. sorry needs that text to run, so it is skipped and nobody gets an apology. The other way round, paid is false, the brief group is told not to flow, and everything inside and behind it (the model, fal, the reply) is skipped instead.

For the whole rule, go and read How a weft program runs.

Why nothing marks the endings

The picture and the apology are the two things this program is for, and neither says so: a fire of ask runs everything it reaches, and each ending runs or skips on its own _should_flow. For how a run picks its nodes, go and read How a weft program runs.

Where to go next

For the syntax used here, wires written inside a node’s braces and port signatures given inline, go and read Syntax.

Next: when something goes wrong.

When something goes wrong

The failures you will actually hit in the first hour, and what each one means.

dispatcher unreachable

The runtime daemon is not up.

weft daemon status
weft daemon start

weft daemon logs -f tails it if it starts and then dies.

Port 9999 is taken

That is the port the runtime is reachable on, and both the daemon and the CLI have to agree on it:

export WEFT_DISPATCHER_PORT=19999
export WEFT_DISPATCHER_URL=http://localhost:19999
weft daemon start

Put both exports in your shell config. The first is the port the cluster maps to your machine and the second is where the CLI looks, and a later weft daemon stop in a shell missing the first one goes hunting for a daemon on 9999.

The port is part of the cluster’s shape (the local cluster is a container, and Docker publishes the port when that container is created), so changing it on a machine that already has a cluster makes weft daemon start rebuild the cluster, and it says so before it does. Your system database survives the rebuild; every project’s own database lives inside the cluster and does not, so pick the port before you have projects you care about. The same goes for WEFT_INGRESS_PORT (9998, storage downloads) and WEFT_GATEWAY_PORT (9097, live callers).

kind not found on PATH

weft daemon start needs it, to make and reach the local cluster. Install kind and re-run. A full ./setup.sh checks for it before it starts anything, so this usually only turns up if you installed with --cli alone.

The compiler refused something

Read the code in brackets. Every diagnostic has a stable slug like type-mismatch, required-port-unmet, graph-cycle, and each one is listed with its cause and its fix in What the compiler refuses.

Every message names the fix. If you hit one that names the problem without the fix, report it as a bug.

A node ran and produced nothing

This is not an error. A node whose required input arrives closed is skipped, and that skip cascades downstream. That is how branching works, and the graph shows skipped nodes distinctly from failed ones.

If a whole branch is dark and you did not expect it, walk upstream to the first node that closed a port. How a weft program runs covers the rule in full.

The editor’s graph does not match the file

Both views come from the same compiler, so they should never disagree. Reload the VS Code window: that respawns the parse server, which is usually a stale one left over from an install.

An execution is stuck

weft events <color> prints what each node did, in order, with what it emitted. Read it to find the last node that fired and work out what the next one was still waiting for.

weft stop <color> cancels a running execution.

the canonical schema changed with no migration to match

This one only reaches you if you are changing weft itself. You edited a table’s CREATE TABLE, and the Postgres volume survives ./setup.sh runs, so the tables on disk are still the old shape and nothing was written to carry them across.

./setup.sh --migration <name> writes the migration for what you changed, and the next start applies it and keeps everything that was in the database.

The error also prints the SQL to drop just the tables it named, if you would rather throw that data away than carry it across.

Something else

If you do not know which side broke, look in two places, in this order: weft daemon logs has the runtime’s side and weft events <color> has the execution’s side. Between them almost everything is visible, because the runtime writes down every event as it happens.

If you are stuck, the Discord is the fastest place to ask.


That is the tour. From here:

How a weft program runs

Most people arrive already holding a model, and it is nearly right, which is the hard case.

The model is: it is a DAG. Nodes are tasks, edges are dependencies, something walks the graph in topological order and runs each task when its dependencies are done. Airflow, Prefect, a build system.

That model gets you a long way and then breaks in three places. It cannot express a node that produces nothing. It cannot express the same node running twice at once on different data. And it cannot express a task that pauses for a week.

All three come from the same difference: in weft, nothing walks the graph. Values move, and nodes react to them arriving.

Pulses

When a node finishes its work it emits values on its output ports. Each emission becomes a pulse: a small object carrying a value, addressed to exactly one input port on exactly one node.

flowchart LR
    A["classify<br/><i>LlmInference</i>"] -- "pulse: {response: &quot;critical&quot;}" --> B["reply<br/><i>SlackSendMessage</i>"]

A pulse carries more than its value:

FieldWhat it is
valuethe JSON value being delivered
target_node / target_portwhere it is going
colorwhich execution it belongs to
frameswhich loop iteration it belongs to
closedwhether it carries a value at all

A pulse sits at its destination port and waits. A node fires when every one of its required input ports is holding a pulse, and all of those pulses agree on their color and their frames. Then it fires, consuming them.

A node with no inputs has nothing to wait for, so it fires immediately. That is where an execution starts.

What a firing is

One firing is one call to the node’s run body, with those pulses as its inputs. It produces zero or more emissions and then returns.

A firing is not a process. It is an execution id and an iteration number, so the same node can be firing four times at once inside a parallel loop, told apart by those numbers. That is the second place the DAG model breaks, and it is why weft talks about firings rather than about running a task.

The closed pulse

A node does not have to emit on every output port. When it emits on some ports and not others, the ports it left out get closed: a pulse is sent down each of those wires carrying no value, meaning “nothing will ever arrive here, at this color, at these frames. Stop waiting.”

What the receiving node does with it depends on one thing:

  • If the closed pulse lands on a required input, the node cannot run. It is skipped, and it closes all of its own outputs in turn.
  • If it lands on an optional input (declared with a trailing ?), the node fires anyway, with that input simply absent.
  • If it lands on _should_flow, the node is skipped whatever else arrived: that port is the one that decides whether the node runs at all, and a closure on it means nothing ever said yes.

So a skip cascades forward, closing everything downstream, until it reaches a node that opted into handling absence.

flowchart LR
    G["triage"] -- "closed" --> S1["send_alert<br/>(skipped)"]
    S1 -- "closed" --> S2["log_alert<br/>(skipped)"]
    S2 -- "closed" --> S3["notify<br/>(skipped)"]
    style S1 stroke-dasharray: 4 4
    style S2 stroke-dasharray: 4 4
    style S3 stroke-dasharray: 4 4

Branching is just that

There is no if, no try/catch, and no conditional edge anywhere in the language. A branch is a node that did not run, and everything behind it closing in turn.

Every node has a _should_flow input deciding whether it runs at all. Leave it alone and the node runs. Wire it, and a false (or a closure, meaning whatever decides never spoke) skips that node, which closes its outputs, which skips everything behind it.

reply = SlackSendMessage {
  _should_flow: review.escalate_approved
  text: classify.response
}

Both branches of a conditional exist in the graph; the one that was not taken goes dark. Two nodes turn that into a shape you can read:

  • Switch tests a value against its cases and emits true on the one port the winning case names, closing the rest. Each case’s kind is its test (equals, in, between, otherwise, and the rest). Wire a case’s port into the _should_flow of whatever that branch runs.
  • FirstInOrder takes the branches back to one wire: it emits the first of its inputs that carried a value, in the order they are written, so the answer a person approved can sit above the automatic one and never be overtaken.
  • All is the AND a gate cannot hold: a gate takes one wire, and “delete it only if the model said rude and said sure” has two answers. Wire both onto an All and gate on what it emits. It says yes when every input arrived and none of them is false, and closes its output otherwise, so a _should_flow reads that closure as a no. It reads a value exactly the way a gate does, and a branch that closed never arrives at all, which makes it the AND of decisions and of arrivals at once.

A Group or a Loop takes _should_flow too, so one line turns off a whole subgraph: it closes the group’s outputs, which closes everything inside it, however deeply nested.

The same rule explains the rest:

  • A node that fails closes its outputs, so a failure propagates exactly like an absent value, and a downstream node with an optional input is the recovery path.

  • A trigger that did not fire closes its outputs, so the branches belonging to other triggers go dark and the branch belonging to the one that fired runs.

  • A loop iteration that failed to write its gather port leaves null at that index, which is why a gather output is typed List[T | Null] and the compiler makes you say so.

  • A node whose every input is optional would run even when everything upstream is dead, which is a bug you almost never want, so the compiler warns and suggests @require_one_of.

Groups and loops are compile-time only

Neither exists at run time. The compiler flattens a group into two boundary nodes and a loop into a pair of them, and their children become ordinary nodes.

So what the executor ever sees is one flat graph of nodes and pulses. Nesting, folding and iteration were all resolved by the compiler before it started.

Frames

A frames value is a stack of loop iteration indices. Top level is the empty stack. Inside a loop’s third iteration it is [2]. Inside the fifth iteration of a loop nested in that one it is [2, 4].

Two pulses only meet at a node if their frames match, so iteration 2’s data can never combine with iteration 4’s. Nothing copies the graph per iteration: there is one graph, and pulses that know which iteration they belong to.

What actually runs

A weft program has no main, and nothing declares an entry point. Every node the run reaches runs. What differs between the kinds of run is where the first pulses are put.

A manual run kicks every root: a node at the top level that no wire feeds. From there pulses go wherever the wiring takes them, and a node runs the moment its inputs are settled. A branch nobody reaches stays blank.

You can start narrower:

weft run --target daily_report --target alert

which runs those two nodes and what they need, and nothing else: the run is held to the targets’ upstream, so a root they share with another branch (a database the whole file reads) never drags that branch in, and nothing past a target runs either. Several targets run the union of what each needs, independent branches side by side. A target inside an ordinary group selects only the work needed through that node, with the group’s flow gate still applied. Loops stay whole; an interior cut is refused. In the graph, right-click a node and choose Set as target; the Run button then says how many targets it is aimed at.

You can also start in the middle, or run one piece with values handed in:

weft run --from classify='{"text":"..."}' --target reply
weft run --group triage='{"text":"..."}'

An input crossing into the selected work can use a compatible saved value with --seed, or a backup at the named start. Real execution values win over backups; a running producer is awaited. Widen the start when the producer needs to run again. Missing values follow normal port closure rules, including required-input skips. The editor labels a value you supplied provided by hand. If you want to know which flag picks which part of the graph, or how to fire a trigger by hand, go and read Versions, seeded runs and frozen examples.

An aimed run answers to what it would execute, reading “what it would execute” as the joined upstream walk from every target at once:

  • If no trigger sits anywhere in that walk, the run is an ordinary one-shot, so the Run button appears next to Activate / Deactivate even in a project full of triggers. This is how a maintenance branch works: a chain the triggers cannot reach, fired by hand whenever you need it, for example the enrollment door in the telegram example.
  • Only the infra inside that walk gates it. A run cannot start while an infra node it would touch is not running, the same rule as a plain run; but infra elsewhere in the project has no say, since this run never touches it. The Run button greys out accordingly, and weft run --target ... refuses with the same message until you weft infra start.

A trigger fire runs one program: the trigger that fired, everything downstream of it, and everything upstream of that, stopping at other triggers on the way up. Its input settings come from matching preparation, and its wake payload belongs to this event. A node that only prepared the trigger is not rerun to fire it. Other triggers are not fired.

A node the fired trigger cannot reach belongs to another program in the same file, and a value that spills into it from a shared node (one database feeding two programs) is dropped with no row. One file can hold several programs, one per trigger, and a middle section both of them need is picked up by whichever one fired without you saying so. What that buys you when laying a project out: one file, several programs.

An ordinary group dispatches only selected work after its boundary and flow gate settle. This applies to manual cuts, trigger programs and preparation for listeners or infra. Loops stay whole; an interior cut is refused.

When it ends

An execution is finished when no pulse is in flight and no node is waiting for one. There is no terminal node and nothing declares completion.

Two ends that are not completion:

  • Suspended. Every live firing is parked on a wait for an external event. The worker exits. The execution is alive and costs nothing.
  • Stuck. The engine can prove no remaining node can ever proceed, because every one of them is waiting on one of the others. That is a graph-shape bug and it fails loudly rather than hanging: the failure names each node left holding a value and the wired inputs it never received.

And one end that is a decision: cancelled. A person pressed Stop, or another run of the same project stopped this one. A run can tag itself, and any sibling carrying that tag can be stopped, even one parked on a person or a timer. The journal records who did it. For how a node asks for that, go and read Stopping other runs.

The journal, and why waiting is free

Everything above happens in a worker process, in RAM: pulses are values in memory and the drive loop is an ordinary loop.

Alongside it, the worker writes an append-only journal, one row per event, as it goes. Nothing reads that journal back during a normal run. It is read only when a worker has to rebuild an execution it did not run: it folds the rows in order, reconstructs the pulse table and which nodes completed or suspended, and carries on from there.

That buys:

  • A human pause costs a database row. HumanQuery parks a firing, and when the last live firing parks, the worker exits. Ten thousand executions waiting on ten thousand people are ten thousand rows and no processes.
  • Crashes are survivable. A worker that dies mid-execution is replaced, and the replacement folds the journal and continues, without re-running the nodes that had already completed.
  • A failure is readable. A run from last Tuesday is still legible node by node, with the actual values on the actual wires.

Weft’s execution guarantee is at-least-once. A crash can lose the write that recorded a node’s completion, so a node that had already finished when its worker died is re-run by the replacement. When a node’s work must not happen twice, ctx.run makes it happen once and replays the recorded result afterwards. See Surviving a restart.

The compiler’s half

None of the above is checked at run time. Before an execution exists, the compiler has already read the whole graph and refused it if:

  • any connection’s types do not match,
  • any required input is unwired,
  • there is a cycle in the wire graph,
  • a type variable was never pinned to anything concrete,
  • a node’s own config validation failed,
  • a loop’s configuration is internally contradictory,
  • a trigger sits somewhere a trigger cannot sit.

The full list is in What the compiler refuses.

What is left after a successful compile is external: a service is down, an API errors, a person never answers.

The shape of the whole thing

flowchart TD
    S[".weft source"] --> P["parse<br/><i>lossless syntax tree</i>"]
    P --> F["flatten<br/><i>groups and loops become<br/>boundary nodes</i>"]
    F --> E["enrich<br/><i>attach each node's declared<br/>ports from its metadata</i>"]
    E --> V["validate<br/><i>types, completeness,<br/>graph shape</i>"]
    V --> C["codegen<br/><i>emit a Rust crate</i>"]
    C --> B["cargo build<br/><i>a native binary</i>"]
    B --> R["run<br/><i>pulses, journal,<br/>suspend, resume</i>"]

Read the next chapters in whatever order you need. Syntax is the surface, Types is what the checker checks, and Groups and Loops are the two structures that make large programs stay readable.

Syntax

The whole surface, in one page. The language is written mostly by models, so the surface is small and strict: there is one way to say each thing, and the compiler refuses everything else.

Declaring a node

name = NodeType
name = NodeType { config_field: value, ... }
name = NodeType {}

name is the node’s id and must be unique within its scope. NodeType must exist in the project’s nodes/ catalog.

Connecting

target.input_port = source.output_port

Read it right to left: the value flows from source.output_port into target.input_port, and the types have to be compatible. Every required input must be wired; an optional one (port?) may be left alone.

If you only want one key of the value, keep going with dots:

speed.wpm = reader.profile.stats.wpm

That is still one wire, from reader.profile, and it delivers stats.wpm read off the value. For what it needs from the type, go and read reading a key off a wire.

Config values

Config fields are typed JSON-ish literals.

t     = Text     { value: "a string" }
n     = Range    { to: 10, step: 2 }
flag  = SomeNode { enabled: true }
arr   = SomeNode { items: [1, 2, 3] }
obj   = SomeNode { opts: { "k": "v" } }

multi = SomeNode {
  fields: [
    { "kind": "text_input", "key": "name" }
  ]
}

Multi-line arrays and objects are fine, and the comma between two fields is optional, so a field per line with no commas reads the same to the compiler.

Wires in the braces

A field whose value is source.port is a wire, not a config value. It is the same edge as the connection line, written inside the node it feeds.

reply = TelegramSendMedia {
  kind: "photo"
  chatId: ask.chatId          # identical to `reply.chatId = ask.chatId`
  file: picture.image
}

Both forms compile to one edge, and the editor can rewrite either. Which one to write is taste: a node with several wires reads better with them in its braces, next to its settings, instead of a stack of lines each starting with the same name.

A Group is the exception. Its braces hold its children, so the only field it reads there is _should_flow. Its interface ports are driven from outside, on their own lines, by a wire or by a value.

A key that CREATES a port

On a node type that accepts extra inputs (ExecPython, FirstInOrder, TagRun, StopTagged), a key naming no declared port creates one. A wire gives it the type of whatever feeds it, a literal gives it the literal’s own type, and a null literal is an error because it says nothing about the type.

step = ExecPython -> (out: String) {
  code: @file("assets/scripts/step.py")
  text: draft.answer      # a String port, from the wire
  limit: 3                # a Number port, from the literal
  notes?: review.notes    # optional: a closure here does not skip the node
}

Created ports keep the order they are written in, which is what FirstInOrder reads: its first input that carried a value is the one it emits. Reordering two of its lines changes which branch wins, and it is the only place in weft where the order of lines means anything.

The ? goes on the key, because it describes the port being created rather than the wire’s source, and it is refused on a key that creates no port (say so on the port itself instead: notes?: String in the signature).

Literals on a connection line

When the target is a node’s own port and the right side is a literal, the line fills that node’s config instead of creating an edge.

post = SlackSendMessage { channel: "#alerts" }   # in the braces
post.text = "deploy finished"                    # or on its own line

Both spellings are the same thing, a constant written for the port, and no port takes one spelling and refuses the other. What a port can refuse is a whole family: a value written in the source (literal) or a value another node produces (wire). Every port takes both unless its node says otherwise with accepts in its metadata, and getting it wrong is input-accepts, with the message reading the list back (“params accepts: wire”). A port the compiler reads to build the node (a form’s fields, the access picker) is the one exception: it takes an inline typed value only, never a wire and never a @file or @asset.

The same line works on a Group, a Loop, or an @include alias: its ports are the ones in its signature, and a value on one reaches everything inside that reads it.

escalation.tone = "formal"

An output never takes a value: a firing emits on it, and you read it as node.port. Writing one (step.out = "lit", or out: "lit" in the braces of a node whose signature declares -> (out: String)) is refused, and so is a group’s own output written from inside (self.result = "lit"). Drive it from a node.

Multi-line strings

Triple-backtick blocks carry code, templates, anything with newlines in it.

step = ExecPython() -> (out: Number) {
  code: ```
    return {'out': 42}
  ```
}

Reserved keys

Keys starting with _ are reserved, and there are exactly four.

KeyWhat it does
_label: "..."sets the node’s display label. A quoted string, settable once, never by wire.
_tags: ["a", "b"]attaches tags, used by signal scoping.
_should_flow: <wire or false>decides whether this node runs at all.
_should_not_flow: <wire or true>the same decision read the other way round: it runs when the thing wired here did NOT happen.

Any other leading-underscore key is a compile error, so the namespace stays available.

_should_flow is how a branch turns off. Leave it out and the node runs. Wire it and the node runs only when what arrives is not false; a false, or a closure (whatever decides never spoke), skips the node, which closes its outputs, which skips everything behind it. Writing _should_flow: false straight into the braces turns one node off without touching anything else.

reply = SlackSendMessage {
  _should_flow: review.approved
  text: draft.answer
}

A Group or a Loop takes it too, written inside its braces alongside everything else, or from outside on the container’s name (escalation._should_flow = false), the same way you would set any of its interface ports. A group that does not flow takes everything inside it with it, however deeply nested.

escalation = Group(question: String) -> (answer: String) {
  _should_flow: route.needs_a_person

  ...
}

The node itself never sees this port: it is the language deciding whether to call the node, not data the node reads.

Running on the thing that did not happen

If you want a node to run when something did NOT arrive, wire that something into _should_not_flow instead. Every answer flips: a value arriving means the node stays off, and a closure, the structural “nothing is coming”, is what runs it.

route = Route -> (photo: File) { path: "cards", method: "POST" }

# A card sent without a picture: `photo` closes, so this runs.
default_art = FetchToStorage { url: "https://example.com/blank.png" }
default_art._should_not_flow = route.photo

This is the one port in the language that starts a node on a closure. Everything else skips when its inputs close, which is why “act on the thing that is not there” needs its own spelling: there would otherwise be nothing left alive to notice.

Reach for it when the absence is DATA, like a key the caller did not send or an optional input nobody filled. When the absence is a DECISION your own node made, it is usually clearer to have that node say so on a second output port and gate on that, because the wire then reads forwards.

A node has one gate. Wiring both spellings is a compile error (two-gates) rather than some rule about which wins.

Inline port signatures

Some node types let you declare their ports in the declaration itself, with an arrow. ExecPython is the canonical one.

calc = ExecPython(a: Number, b: Number) -> (sum: Number, diff: Number) {
  code: "return {'sum': a + b, 'diff': a - b}"
}

Inputs arrive in the code as variables named after each port, and the code returns a dict keyed by output port name. A key set to None, or missing entirely, emits no pulse on that port, which closes it. The compiler type-checks these ports exactly like declared ones.

You only write the ports the node leaves open. Anything its metadata already types keeps that type, so a signature can be inputs only, outputs only, or a single port, and a node whose ports are all pinned needs no signature at all.

answer = LlmInference -> (response: String)   # the rest of its ports are typed
ok     = Cast -> (value: Boolean)             # the whole point of Cast

An empty body is the same as no body, so Cast -> (value: Boolean) {} and Cast() -> (value: Boolean) are the line above with more typing.

Inline expressions

A node literal can appear directly as a value, with a mandatory trailing .port naming which of its outputs feeds the target.

out.data = Text { value: "hi" }.value

That synthesizes an anonymous child node (id {host}__{field}, here out__data) plus the edge into out.data. The same form works as a config field’s value inside a node body, and carries full node syntax including its own inline signature and nesting. With a signature, the .port reads one of the outputs the signature declares:

summary.text = ExecPython(m: List[JsonDict]) -> (text: String) {
  code: "return {'text': ' '.join(x['body'] for x in m)}"
}.text

Omitting the trailing .port is a compile error, because a node with several outputs would otherwise be silently ambiguous.

Comments and descriptions

# starts a line comment.

One position is special: if the first line inside a group or loop body is a plain comment, that line becomes the group’s description and tooling shows it when the group is collapsed.

preprocessor = Group(raw: String) -> (result: String) {
  # Cleans and transforms text
  ...
}

The same rule applies inside an included file’s top-level group body. Comments outside any group have no special meaning.

The project’s name and id live in weft.toml, not in the source, so there is no header comment to keep in sync.

Directives

@require_one_of(a, b) states that at least one of the named inputs must be satisfied, either wired or set to a non-null literal. It goes on its own line inside a node body, or inside an inline port signature. A group or loop refuses it: a group’s inputs are all optional at its boundary, so the directive belongs on the node inside that needs one of them.

lookup = SlackFindUser {
  @require_one_of(email, phone)
}

It is a compile error when unmet (require-one-of-unmet), and it also governs runtime skipping: the node is skipped when every port in the group arrives closed.

Catalog nodes declare the same thing in their metadata as oneOfRequired, so a node author can build the requirement in rather than relying on every caller to write the directive.

Whitespace and formatting

The parser keeps every byte, including whitespace and comments, in a lossless tree. That is why the editor can rewrite one config field through a GUI gesture without reformatting your file, and why a round trip through the compiler is byte-exact when nothing changed.

There is no formatter, and no formatting rules are enforced.

The full grammar, informally

file        := decl*
decl        := IDENT '=' node_expr
             | IDENT '=' '@include' '(' STRING ')'
             | connection
node_expr   := TYPE port_sig? body?
port_sig    := port_sig_in? port_sig_out?
port_sig_in := '(' port_decl,* ')'
port_sig_out:= '->' '(' port_decl,* ')'
port_decl   := IDENT '?'? ':' type     # `?` (inputs only) = may be absent
body        := '{' body_item* '}'
body_item   := IDENT '?'? ':' value     # config field (`?` = the port it
             |                          #   creates is optional)
             | IDENT '?'? ':' path      # a wire into this node's port
             | connection               # inside a group or loop
             | directive
             | COMMENT
connection  := path '=' ( path | value | node_expr '.' IDENT )
path        := IDENT '.' IDENT | 'self' '.' IDENT

Group and Loop are node types with bodies containing connections, covered in Groups and Loops.

Types

Every port has a type. Every connection is checked against both ends before anything runs. This page is the complete list.

Primitives

TypeHolds
Stringtext
Numberany number, integer or not
Booleantrue or false
Nullthe absence of a value, as a value
Imagea stored image file
Videoa stored video file
Audioa stored audio file
Blobany other stored file: a pdf, a zip, a csv
Emptythe type of a value that cannot exist. An empty list literal is a List[Empty], and it wires into a list of anything.

The four file types are one idea with four names. What travels a wire is a small reference to a stored file rather than the bytes, so a conversation carrying twenty images stays cheap to journal. The type is what tells the runtime which slots hold files, which is what makes media conversion at a provider boundary possible without per-node code.

You never write Empty yourself. It turns up when the compiler has nothing to go on, as in List[Empty] for [], and it makes unions simplify: Number | Empty is Number.

Union aliases

Media is Image | Video | Audio, and File is Media | Blob. Both are just names for those unions.

Containers

List[Number]
List[List[String]]
Dict[String, String]

Unions

String | Number
Number | Null

JsonDict

An opaque Dict[String, *] whose value types are unchecked. It is compatible with any Dict[String, V] in both directions.

Reach for it if you are holding a raw API response whose shape you do not know, or do not want to declare. It says “I am not claiming to know what is in here”.

Records

A dict with known field names and per-field types.

{ role: String, name?: String }

A ? on a field marks it optional: the key may be absent, and a present null counts as absent.

Validation is strict. A value carrying a key the record does not declare is refused. A record is a contract, so declare every field the real values carry.

Reading a key off a wire

If a node gives you a record and the next node wants one field of it, write the field on the wire:

reader = ExecPython() -> (profile: { stats: { wpm: Number, name?: String } }) { ... }
speed  = ExecPython(wpm: Number) -> (out: Number) {
  wpm: reader.profile.stats.wpm
}

The compiler walks the keys against the record type: each one has to be a field of the record at that level, and the wire’s type is the last field’s type, so wpm: Number above type-checks like any other wire. A type with no fields to walk, a JsonDict or a scalar, is refused with deref-path and the fix in the message: declare the shape on the source port, or Cast first.

At run time the value is read right before it lands, once per wire. Five wires off one port are five separate reads, and none of them changes what the others get. A ? key that turns out absent (or null) closes that wire alone, and the port on the other end decides what a closure means to it: a required port skips the node, an optional one fires with the value missing. A required key that is absent is a value that broke its declared type, and that fails the firing rather than turning into a null.

In the graph, such a wire is drawn dotted with the path written at its end. Right-click any wire whose value is a record to pick a key.

Named custom types

A type gets a name in one of two places. Any node’s metadata.json can declare named types, and once declared anywhere in the project the name is usable in every port type and every inline signature.

"types": {
  "ChatHistory": "List[ChatMessage]",
  "ChatMessage": "{ role: String, content: String | List[Part], name?: String }"
}

Or the .weft source declares one itself, at the top of a scope:

type Profile = {
  wpm: Number,          # words per minute
  read_delay: Number
}
type Names = List[String]

typing = ExecPython(p: Profile, who: Names) -> (delay: Number) { ... }

The right-hand side is any type the language has, over as many lines as it needs, and it may name other declared types. Where you write it decides who sees it: a declaration at file level is visible to every header in the file, one directly inside a group or loop body is visible in that body and every body nested in it, and nowhere else. The group’s own signature sits outside its braces, so a type declared inside cannot name the group’s ports. Order within a scope does not matter. A node’s braces hold its values, so a type line inside them is refused, and a name that is already visible (from an outer scope, from a metadata types block, or a builtin like Media) cannot be declared again: nothing shadows. An included file sees the catalog’s types and its own declarations, never the including file’s, so a component compiles the same on its own as spliced in.

Named types are nominal: the name is the contract, not the shape.

  • A ChatHistory value wires freely into JsonDict, or into its own structural shape.
  • Nothing unnamed wires into a ChatHistory input. A dict that happens to have the right shape is not one.

The door between the two worlds is the Cast node, below.

Declaring the same name twice is fine when the bodies are structurally identical, so two packages can ship the same shared type without depending on each other. Two different bodies under one name is a loud error, which turns drift between two copies into a build failure.

Type variables

A bare capitalized name like T is a generic that unifies across a node’s ports, so a node with input T and output T carries whatever concrete type flows in. Every type variable has to be pinned to something concrete somewhere in the graph, and one that is not is rejected as unresolved-typevar.

MustOverride

A node whose metadata cannot know a port’s type declares it MustOverride, and the .weft author has to pin it with an inline port signature. A MustOverride port that is wired and still unpinned at compile time is an error (must-override-unmet); one nothing reads or writes is left alone.

Cast is the main user of this: its output type is whatever you say it is.

The ? marker

? after a name means “may be absent”, and it goes on the name in every place it can appear: an input port (here?: String) and a record field ({ role: String, name?: String }). On an input port it lets the port accept a closed pulse; without it, a required input that receives closure skips the node and cascades that closure downstream. See How a weft program runs. On a config key that creates a port (notes?: review.notes) it marks that created port. See wires in the braces.

name: String? is refused, and the message spells the accepted form. An output port takes no ? at all: a firing that emits nothing on it closes it, and there is nothing for a marker to add.

Number | Null is a different thing: null is a value that arrives, ? is a value that does not.

The wired-only types

Three types never appear as literals in source, because their values are live runtime handles that only exist while something is running.

Bus

A message channel between nodes that are alive at the same time. A Bus output connects only to a Bus input. Message payloads are not type-checked by the language; the channel carries what its creator declared it carries.

Generator[T]

A typed, one-directional, terminating stream.

The producer’s port accepts being emitted into repeatedly, and each emission is one item checked against T. The producer’s own state lives in ordinary local variables across all of them.

The consumer fires once, on the first item, and pulls the rest in its own code. Or a Loop names the port in over and pulls one item per iteration. The stream ends when the producer’s body returns.

One producer feeds one consumer, a stream cannot leave the group it was made in, and it takes no literal. Why each of those holds, when to reach for a stream over a List[T], and what happens when one side stops early: Live channels.

Access

The authorized ability to call a third-party service. One type for every service, emitted by the node that holds the connection and consumed by the nodes that make calls.

Because one type covers every service, the compiler never has to know which services exist. Wiring the wrong service’s access into a node fails loudly at run time.

The Cast node

Cast converts a value into the type declared on its output. The inline signature pins that type, since the metadata ships the output as MustOverride.

raw = HttpRequest { url: "https://api.example.com/history.json" }
history = Cast() -> (value: ChatHistory)
history.value = raw.body

The conversion table is checked at compile time, so an impossible pair like JsonDict -> Number is a compile error rather than a runtime surprise.

What is possible:

  • text parses into numbers, booleans, and JSON structures,
  • data stringifies,
  • Number and Boolean interconvert as 1 and 0,
  • any object shape casts into a record or named type by validation: the value is held to the declared structure and a mismatch is an error naming the exact offending field.

So Cast is where you say “this dict really is a ChatHistory”, and the runtime checks that before letting it through.

Compatibility, precisely

A connection from a source type S to a target type T is allowed when every value S can produce is a value T accepts.

  • Identical types are compatible.
  • A union is compatible with a target when every member is.
  • JsonDict is compatible with any Dict[String, V] in both directions.
  • A named type is compatible with JsonDict and with its own structural shape, but nothing unnamed is compatible with a named target.
  • Containers are compatible element-wise.
  • A type variable unifies with whatever concrete type reaches it, and stays that type for every other port that shares the variable.

Anything else is type-mismatch, and the message names both types.

Groups

Any subgraph can be a box with typed input and output ports. From outside, the box behaves exactly like a node. Groups nest arbitrarily.

preprocessor = Group(raw: String) -> (result: String) {
  # Cleans and transforms text

  clean = ExecPython(text: String) -> (out: String) {
    code: "return {'out': text.strip()}"
  }

  clean.text = self.raw
  self.result = clean.out
}

preprocessor.raw = input.value
output.data = preprocessor.result

The readable size

A weft program is read as a graph, and a level (the file, or the inside of a group or loop) is what the reader scans in one look. The readable size is about six items, nodes or groups; past fifteen the compiler warns (level-too-large), because by then the level has stopped being something a person can scan. At the file’s top level the count is per branch: the items one wire walk reaches, plus the infra nodes it touches. Two pipelines that never touch, or that only share a database, each answer for their own width (the database as its own node: a group holding one is an item like any other, and joins what it is wired to). Groups are the tool for staying readable: when a level grows past six, the nodes cooperating on one job become a group of their own. And because groups nest, depth absorbs size: growing work goes down into a nested group, never wide across a level. A group whose inside holds another group or two is the normal shape, not a special one.

self

Inside a group, self is the group’s own boundary.

  • Reading self.<input> pulls a value the group received.
  • Writing self.<output> sets a value the group emits.

The value flows right to left, so clean.text = self.raw pulls the group’s input into the child and self.result = clean.out pushes the child’s output out of the group.

The boundary is real

A group’s children can talk to each other and to self. That is the complete list, and the compiler enforces it, so a group is a contract: these inputs, these outputs, nothing else crosses. You can reason about what a group does without opening it, and change what is inside it without checking the rest of the program.

The wiring outside a group is identical whether it is collapsed or expanded in the editor.

Setting a group’s ports from outside

A group’s input port takes a wire, or a written value, on its own line:

triage.tone = "formal"
triage.email = inbox.message

The value reaches everything inside the group that reads that port.

Turning a whole group off

_should_flow is on a group like it is on a node, and it decides whether the group runs. Write it inside the braces, or from outside on the group’s name:

escalation = Group(question: String) -> (answer: String) {
  _should_flow: route.needs_a_person
  ...
}

A group that does not run closes its outputs, so everything behind it closes in turn, and every node inside it, however deeply nested, is marked skipped with the group’s name as the reason. For what counts as a no, go and read How a weft program runs.

That is the only way a group as a whole stops. A group input that arrives closed does not stop it: the closure passes through the boundary to the nodes inside that read that port, those skip, and the rest of the group runs. If you want the whole group to depend on one input, wire the group’s _should_flow from whatever decides that input. For the same reason @require_one_of is refused on a group; put it on the node inside that needs one of the ports.

What starts inside

When a group starts, every node inside it that no wire feeds is started too, at the same moment. A group can hold a source of its own, a fixed Text or a node that reads the clock, and it fires once per start of the group: once for a plain group, once per iteration for a loop body.

A group that hands nothing back

The arrow is optional. A group whose job ENDS inside it, writing the row, sending the message, uploading the file, has nothing to hand its caller, so it takes inputs and stops there:

archive = Group(db: Access, ready: Number) {
  # Write the finished order to the warehouse
  ...
}

It still works like any other group. Its _should_flow still turns the whole thing off, and the compiler still builds the same two boundary nodes, the outgoing one simply carrying nothing.

A loop has the same shape with its own name, the side-effect loop, over in Loops.

The description line

The first line inside a group body, if it is a plain comment, is the group’s description. The editor shows it when the group is collapsed.

triage = Group(email: JsonDict) -> (severity: String) {
  # Classify an inbound ticket and normalise its severity
  ...
}

Keep it to one line and make it say what the group does for its caller.

Why this scales

Groups are what keep debugging tractable however large a program gets.

When a value comes out wrong at the end, you look at the top-level boxes, find the first one whose output is already wrong, open it, and repeat inside. Each level of descent divides the search space, because each boundary you cross is a place where the value was either already wrong or still fine.

The same property is what lets you hand a group to somebody else. “Build the thing that turns a raw email into a normalised ticket, here are its input and output types” is a complete task, buildable without seeing the rest of the program.

Nesting

Groups nest to any depth, and names are scoped: two groups can each contain a node called clean without collision.

A group does not exist at run time

The compiler flattens groups away. Your group becomes two ordinary boundary nodes, one for the inputs and one for the outputs, and its children become ordinary nodes with scoped ids. By the time anything runs there is one flat graph of nodes and pulses, and the executor has never heard of a group.

So nesting costs nothing at run time and there is no per-group bookkeeping to go wrong. The boundary is a compile-time contract that the compiler checks and then deletes.

Loops flatten the same way, into a pair of boundary nodes plus an iteration number carried on each pulse.

What a group is not

A group is not a function. It has no call sites and does not return; it is a region of the graph with a boundary drawn around it, and pulses cross that boundary the same way they cross any wire.

So a group does not run “once per call”. Two pulses arriving at its input at different frames both flow through the same children at their own frames, exactly as they would have without the box. If you want “run this subgraph N times”, that is a Loop.

Reusing a group across files

triage = @include("triage.weft")

The included file must be exactly one anonymous top-level group. Its ports become triage’s ports and you wire it like any node. The file is compiled once and every @include of it is a call, like a loop body is compiled once and every iteration runs it. See Files and reuse.

Loops

Loop is a built-in like Group, but its body runs many times: once per element of a list, once per item of a stream, or until the body votes to stop.

doubler = Loop(values: List[Number]) -> (results: List[Number | Null]) {
  parallel: false
  over: ["values"]

  step = ExecPython(n: Number) -> (out: Number) {
    code: "return {'out': n * 2}"
  }
  step.n = self.values
  self.results = step.out
}

doubler.values = nums.values

Note the types on the boundary. Outside, values is a List[Number]. Inside, self.values is one Number. The loop unwraps on the way in and gathers on the way out.

The four port roles

Every port on a loop is in exactly one of four roles, derived from the config.

1. Iter input, named in over

Outside List[T], inside T. The body sees one element per iteration.

Several ports in over zip together in lockstep. When their lists are different lengths at run time they are zipped to the shortest, unless you set trim_on_mismatch: false, which fails the loop loudly instead.

2. Carry port, named in carry

Declared on the output side of the signature. The compiler auto-creates a matching input port with the same name and type for the initial value.

This is the accumulator. Reading self.<port> gives the previous iteration’s value, or the initial one on the first pass; writing it sets the next iteration’s. At termination the final value is emitted outward.

3. Gather output

In the output signature and not in carry.

The outside type must be List[T | Null], spelled out (gather-output-must-be-nullable). An iteration that fails to write leaves null at its slot, so the type says so and whoever wires it downstream handles it.

Inside, the write port is T?. One value per iteration, ordering preserved by iteration index even in parallel mode.

4. Broadcast input

In the input signature and not in over. Same type inside and out, and the value is available unchanged to every iteration.

The two implicit ports

Every loop body has two ports nobody declares:

  • self.index: Number, read-only, the zero-based iteration number.
  • self.done: Boolean, write-only. Writing true stops launching new iterations. Sequential mode only.

index and done are reserved port names.

Drive modes

parallel defaults to false. Sequential mode is the one where carry and self.done work and where there are no ordering surprises.

A loop terminates on whichever comes first: the over lists are exhausted, the body wrote self.done = true, or max_iters is reached.

The five shapes:

ShapeparallelovercarryEnds when
Parallel maptrue[...][]over exhausted
Sequential mapfalse[...][]over exhausted
Foldfalse[...][acc]over exhausted
Whilefalse[][acc?]self.done = true
Side effectfalse[][]self.done = true

For “run N times”, feed a Range node into a map loop’s over. Its values port is a Generator[Number], so declare the loop’s port that way too.

Combinations the compiler refuses

  • parallel: true with a non-empty carry. Carry implies an order.
  • parallel: true with an empty over. The iteration count has to be known up front.
  • parallel: true with any self.done write (parallel-with-done).
  • A port in both over and carry (over-and-carry-overlap).
  • A sequential loop with no over, no max_iters, and no self.done write anywhere in its body (loop-unbounded-no-termination). That loop is provably infinite, so it is refused at compile time.

Empty over and empty carry is allowed: that is the pure side-effect loop, terminated by self.done or max_iters.

Unknown config keys are rejected (loop-unknown-config-field), and a non-boolean parallel or trim_on_mismatch is its own error.

Looping over a stream

over dispatches on the port’s type.

On a List[T] port it is the iteration above and the count is known up front.

On a Generator[T] port the loop pulls the stream. A sequential loop takes the next item once the previous iteration finished; a parallel loop launches a lane per arriving item; “over exhausted” means the stream ended.

Constraints, all for the same reason (a stream has one taker and one direction):

  • A stream in over must be the only over port. A loop iterates one stream at a time.
  • A Generator input on a loop is legal only as the over port. A stream cannot broadcast into the body.
  • A loop over a stream whose producer failed fails loudly, rather than gathering a list that looks complete but is truncated.

The early-exit edge

A loop that can stop early (a self.done vote, a max_iters cap) while its stream’s producer is parked holding an item makes that item impossible to deliver, and the producer fails loudly. The two ways out, and the rule behind them: The early-termination edge.

A loop compiles away too

Like a group, a loop is a compile-time construct. It flattens into two boundary nodes, and an iteration becomes nothing more than a number pushed onto each pulse’s frame stack.

There is no per-iteration copy of the graph and no dynamic subgraph instantiation. Fifty parallel iterations are one graph and fifty frame values, which is why nesting loops is free and why two iterations can never mix their data: pulses only meet at a node when their frames match.

A loop is a launcher, not an owner

In C or Python, a for loop owns its body’s lifetime. When the loop ends, the body is done, by definition.

In weft it does not. A loop is two things only: a launcher that decides how many iterations start and when, and a single outward emitter that assembles the gathers and carries at termination.

Work started inside an iteration carries its own iteration number and keeps running until it finishes, even after the loop has emitted outward. Only the branch wired to the loop’s outputs holds that emit back. So a body node can launch a long-lived agent, or open a channel that stays alive for hours, and the loop emitting its results does not kill it. An edge still cannot cross the loop boundary, and the execution as a whole terminates only when all body work has drained.

The canonical use: a parallel loop launches N agents, gathers their channel markers as List[Bus | Null], and a coordinator wired to that list talks to the still-running agents.

flowchart LR
    L["Loop<br/>parallel, over: prompts"] --> G["gathered<br/>List[Bus | Null]"]
    G --> C["coordinator"]
    L -.->|"still running"| A1["agent 0"]
    L -.->|"still running"| A2["agent 1"]
    L -.->|"still running"| A3["agent 2"]
    C <-.->|"bus"| A1
    C <-.->|"bus"| A2
    C <-.->|"bus"| A3

Turning a loop off

A loop takes _should_flow and written port values exactly like a group does. For both, go and read Groups.

A loop also stops when a port it iterates or carries arrives closed: with no list to walk, or no seed to carry, there is no iteration to launch, so the loop skips and its outputs close. Any other input arriving closed reaches each iteration as a closure, and the body node that reads it skips there.

A body node that no wire feeds fires once per iteration, at that iteration’s frame, so a loop body can hold its own source.

Failure and nesting

A failing body branch cascades only through that branch:

  • a gather port that received a closure yields null at that index, which the List[T | Null] type forces you to handle,
  • a closed carry write keeps the previous carry value,
  • a closed self.done reads as false.

The loop emits once every iteration it launched has reached the boundary. It does not wait for the work those iterations started.

Nested loops add one frame per level to the iteration frame stack, and outer-loop termination does not cascade to inner loops.

Live channels: streams and buses

Most wires in a weft program carry one value once. Two port types carry something that keeps arriving, and they answer different questions.

Generator[T]Bus
Directionone way, producer to consumerany participant to any participant
Readersexactly oneany number
Endswhen the producer’s body returnswhen the creator closes it
Typed payloadyes, every item checked against Tno, the channel declares its shape
Crosses a group boundarynoyes

A stream is a sequence someone is producing. A bus is a conversation between things that are alive at the same time.

Streams

A Generator[T] port accepts being emitted into repeatedly. Each emission is one item.

rows = ReadCsv() -> (rows: Generator[Row])
rows.path = @asset("data/big.csv", Blob)

summarise = SummariseRows
summarise.rows = rows.rows

The consumer fires once, on the first item, and pulls the rest itself at its own pace.

Or a Loop names the port in over and pulls one item per iteration:

each = Loop(rows: Generator[Row]) -> (kept: List[Row | Null]) {
  over: ["rows"]
  ...
}
each.rows = reader.rows

Why a stream instead of a list

The visible difference is when the second stage starts.

With List[Row], the producer builds the whole list, emits it, and only then does anything downstream begin. With Generator[Row], the consumer starts on item one while the producer is still working on item four hundred.

For a ten-row query nobody cares. For a query that takes a minute to page through, or an LLM streaming tokens, the first result lands in seconds instead of after the whole thing finishes.

If the whole collection exists up front, use List[T]. If the items appear over time, use Generator[T].

The rules, and why each exists

  • Exactly one producer, exactly one consumer. A stream has one taker. Broadcasting is a bus’s job.
  • A stream cannot cross a group boundary, sit inside a container, or be carried between loop iterations. A stream is a live handle, and those three operations would all mean holding it somewhere its producer cannot reach.
  • A Generator input must be required. An unwired stream has no meaning.
  • No literal. The value is minted at run time.

Backpressure

A producer that emits without waiting runs ahead of its consumer, and the un-taken items buffer on the edge. That buffer is bounded, 4096 items by default, and an emission past the bound fails the producer loudly rather than growing until the pod runs out of memory. A producer that means to run far ahead raises its own bound. A producer that yields in lock step waits for each item to be taken, so its buffer never grows past one.

The early-termination edge

A lock-step producer is parked holding an item until the consumer takes it. If the consumer stops early, that item can never be taken, and the producer fails loudly, failing the execution.

So when the consumer decides how much of the stream to use, the producer must emit fire-and-forget (leftovers are dropped) or be the side that decides when to stop. The same rule appears in Loops.

An empty stream still runs the consumer

A producer that closes without yielding anything delivers a stream whose first pull answers “finished”. The consumer still fires, its loop runs zero times, and whatever comes after the loop runs normally.

Buses

A bus is an in-process channel between nodes that are alive at the same time. One node creates it and emits a marker on a Bus-typed output; downstream nodes resolve that marker and exchange messages.

host = ConversationHost() -> (channel: Bus)

guest = ConversationGuest
guest.channel = host.channel

observer = ConversationTap
observer.channel = host.channel

Three nodes on one channel, all three talking, which is what a stream cannot do.

What a bus carries

The creator declares the channel’s shape once, and every participant reads it back off the handle:

  • payload: Json for chat-shaped traffic, or Bytes for media frames, which travel raw end to end with no base64 in between. Frozen at creation.
  • meta: whatever a consumer needs to know before the first message, such as an audio stream’s sample rate and encoding, instead of every message repeating it.
  • ephemeral: keeps payloads out of the journal entirely, for bytes that are transient by nature. A route or a socket has the same switch, spelled journalEphemeral on the trigger, and it means the same thing. For what is kept either way, and where a big payload gets trimmed, go and read what the journal costs.
  • window: how many frames the bus keeps for a consumer that falls behind, 64 by default. Raise it in the node that creates the bus.
  • journal_window: how coarsely the trail is recorded, one row per bus per window, one second by default. What travels the bus is untouched.

Reading a bus

Every reader has its own position, so a responder and an observer both see every message. Positions are absolute over the channel’s whole life, so a saved one keeps naming the same message as the window moves.

When a bus is closed, every reader’s pull ends cleanly. Closing is therefore the end-of-stream signal, and a producer that forgets to close leaves its readers parked forever, which is why the node-side API closes on every exit path for you.

Buses and the graph

A bus is the answer to “these two things need to talk while both are running”, which the pulse model does not express on its own: a pulse is one delivery in one direction.

The canonical shape is a parallel loop that launches N agents, gathers their bus markers as List[Bus | Null], and hands that list to a coordinator that talks to all of them while they work. See a loop is a launcher, not an owner.

Which one do I want

Ask what happens when a second reader appears.

If a second reader would be a bug (each item must be handled once), it is a stream. If a second reader is fine or desirable (everyone should see the message), it is a bus.

Ask who decides when it ends.

If the producer decides, by finishing, it is a stream. If the conversation ends when the participants are done, it is a bus.

The Rust side of both is in Streams and buses in Rust.

Triggers

A trigger node is what starts an execution from outside. A web request, a timer, a form submission, a message landing in Slack, an email arriving.

It is an ordinary node whose metadata sets isTrigger: true. You wire its outputs downstream like any node. The difference is that an external event fires a fresh execution carrying that event’s data.

hello = Route -> (name: String) { path: "hello", method: "POST" }
answer = Reply
answer.body = hello.name

Two phases

The language drives a trigger through two phases, and a trigger’s body never inspects which one it is in. The runtime calls a different function for each.

Trigger setup happens when you activate the project. The trigger registers what it wants to watch: an endpoint path, a cron spec, a subscription to a provider’s events. Its upstream nodes run during this phase, and whatever they delivered is saved with the registration.

Fire happens each time the event occurs. A fresh execution starts, the trigger’s body runs exactly once, and the event’s data arrives on a separate channel from its inputs.

That split has a consequence worth knowing. A trigger’s inputs are read once, at activation, and replayed on every fire, so nothing upstream of a trigger runs again when it fires, and re-activating the project is what refreshes those values. If you want something computed fresh per event, compute it downstream of the trigger.

The same rule is how you run something once before a program starts serving. Anything wired into a trigger’s _should_flow runs at activation and never on a fire, so creating your tables is one node and one wire:

make = PostgresExecuteQuery { account: db.access, query: @file("assets/sql/schema.sql") }
live = Route { path: "live/count" }
live._should_flow = make.count

Every activation runs it again, so write SQL that is safe to repeat (create table if not exists, alter table ... add column if not exists).

What runs on a fire

One program: the trigger that fired, everything downstream of it, and everything upstream of that, stopping at other triggers on the way up. A _should_flow wire counts as downstream like any other, and on a group or an included file it takes the whole group along (work._should_flow = live.method runs everything in work, and pulls in what those nodes need), so a route can hand its work to a group without wiring any data into it. At fire time a trigger’s outputs are the event, not a function of its inputs (those were read once, at activation), so a node that only feeds a trigger has nothing to contribute to a fire. Sibling programs it cannot reach do not run: every other trigger in the set is kicked with no payload, which closes its outputs, and the skip cascade prunes the branches that belong to it.

That set is written into the run itself, so it holds on a resume too, and the rest of the file is left alone. A database or a provider shared by two programs emits down every wire it has, so on every fire a value does reach the other program’s first node; the runtime drops it there without a trace, because that program is not this run’s business.

Why the runtime picks the program that way, and what it buys you: What actually runs.

flowchart LR
    T1["cron<br/><i>fired</i>"] --> P["process"]
    T2["webhook<br/><i>idle</i>"] -. "closed" .-> P
    P --> O["output"]
    style T2 stroke-dasharray: 4 4

If you run a project by hand, no trigger fires: every one of them closes, and the run exercises only the paths that do not need one. To exercise a trigger’s path, fire it. The editor can send a hand-written payload.

The built-in kinds

NodeFires when
Route { path, method }an HTTP request arrives; the body’s keys come out on the ports you declare, and Reply / Stream / Close answer it (Building an API)
Socket { path }a WebSocket connects; every message is one item of its inbound stream
Cron { cron, timezone }the schedule says so, on that zone’s clock (UTC unless you pick one)
HumanTrigger { fields }a person submits a form

Beyond those, node authors write triggers that subscribe to an event stream, poll a URL, or hold an outbound socket, using the runtime’s signal kinds. See Writing a trigger.

For triggers that fire on something happening at a connected service, read Events from a service, which explains why some of them need your weft reachable from the internet and some do not.

Form-derived ports

HumanTrigger and HumanQuery do not have fixed ports. Their ports come from the fields you configured, resolved at compile time.

review = HumanQuery {
  title: "Escalate this ticket?"
  fields: [
    { "kind": "approve_reject", "key": "escalate" },
    { "kind": "text_input", "key": "reason" }
  ]
}

That produces three outputs: review.escalate_approved and review.escalate_rejected as Booleans, and review.reason as a String. You never declare them, and changing the form changes the ports, with every existing wire re-checked against the new shape.

What the compiler refuses

ErrorWhat it stops
graph-cyclea cycle in the wire graph. Iterate with a Loop; exchange feedback over a bus.
trigger-in-loopa trigger inside a Loop. A trigger is an entry point, and an entry point per iteration is meaningless. A trigger inside a plain group is fine: it fires there and the run starts from it.
infra-in-loopan infra node inside a Loop. Infra is provisioned once for the project, not once per item.
trigger-into-triggera trigger wired into another trigger. There is no phase in which that delivers.
trigger-into-infraa trigger wired into an infra node. Provisioning happens before any fire exists.

Activation

A project with triggers has to be turned on:

weft activate            # register every trigger, mint the URLs
weft deactivate          # drop them

activate prints the live addresses it minted. Re-activating re-registers everything and refreshes each trigger’s saved input snapshot.

If you deactivate a project with work in flight, you have to say what happens to it, so deactivate takes a mode:

ModeWhat happens to suspended executions
wipedropped
hibernatekept, resumable when reactivated
parkkept, and queued to run on reactivation

and a --running-policy of wait or cancel decides what happens to executions currently mid-flight. The CLI lists the defaults.

If a trigger cannot be served, activation stops there

It refuses, naming what is missing, rather than registering into a state where it looks active and never fires. That way a misconfigured trigger fails while you are looking at it. See Design principles.

Building an API

A program becomes an API with two triggers and three answering nodes, all in catalog/api, none needing a line of Rust. Route (HTTP) and Socket (WebSocket) start a fresh execution per request or connection and put the request on their ports. Reply, Stream and Close answer whichever caller the run has.

The rule underneath them is the one every weft mechanism follows: the engine knows nothing about these nodes. It holds a connection, it lets a node read the request and set the response head, and it delivers the trigger’s wake payload. The catalog nodes are one use of that; a node you write in Rust reaches the same handle through ctx (Talking to a live caller).

One route, one run

hello = Route -> (name: String) { path: "hello", method: "POST" }
answer = Reply { status: 201 }
answer.body = hello.name

A Route is a pattern and, optionally, a method. path: "users/{id}" captures the segment under id; method empty serves every method.

Two routes can share a call as long as one of them spells out what the other captures: chat/general beside chat/{room} is fine, and chat/general takes that one call while chat/{room} takes the rest. What is refused when you activate, naming both, is the pair where neither is the more specific, because then the shared call has two equal claims: chat/{room} beside chat/{name}, or a/{x}/c beside a/b/{y}, which both answer to a/b/c.

The request comes out on six fixed ports: method, path (as called, no leading slash), params (the captures), query, headers (lowercase names), and caller (who the auth gate let in; null on an open route). The body comes out on the ports you declare after the arrow: on a json route each declared name is a top-level key, and there is no port for the whole object: declare every key you read (a key that is itself an object is meta: JsonDict). A text route delivers the whole body as a String on the one port you declare; a bytes route stores it as a file and delivers the stored-file value (declare the port File). An undeclared key is dropped; a body key named like a fixed port loses to the fixed port.

If you want a capture on its own port, declare a port with the capture’s name: Route -> (id: String) { path: "cards/{id}" } puts the {id} segment on id, typed, and a body key called id loses to it (the capture is part of the request, like the fixed ports). params still carries every capture.

Pictures in and out

Nothing on a wire is ever base64. A value a node emits is at most 100 KB (the node fails, naming the port, above that), and bytes live in storage as a stored-file value: a few hundred bytes that say where the file is.

If a caller sends a picture in a JSON body, declare the port as the file kind you expect and the route stores it on the way in:

upload = Route -> (photo: Image, caption: String) { path: "cards", method: "POST" }

The body key photo holds a data:image/png;base64,... URL (the media type comes from it) or bare base64 (the media type comes from the bytes’ own signature). Bytes that are not what the port declares fail the run by name (port 'photo': the caller's bytes are not what the port declares). The file lands at execution scope, walled to that run and swept when it ends; a later request cannot read it even if it was kept. To serve it from a later request, wire it through KeepFile { scope: "project" }, which copies it into the project’s storage, and keep the stored-file value it emits in your database (a jsonb column).

Sending it back to a caller needs nothing else: Reply and Stream walk the whole answer and link every stored file they find, at any depth, so a route serving a list of rows with pictures is three nodes and no loop. Cast a stored-file value read out of the database to Image (or File) only when you want the file as a typed value on a wire, to hand it to a node that takes a picture.

On the way out, a stored file anywhere in a Reply or Stream body (nested in an object, in a list of rows) goes out as a link:

{ "url": "http://127.0.0.1:9998/public/files/<token>", "mimeType": "image/png", "filename": "photo", "sizeBytes": 48211 }

The url is what a browser puts in an <img>. Its address is the install’s, never the caller’s: the public tunnel’s when the install has one running, else the install’s own base, which locally is http://127.0.0.1:9998. Multipart bodies are not read; send JSON with a data URL.

That link is minted for the answer it goes out in and expires minutes later, so never write it into a table. Keep the file itself (KeepFile with a scope that outlives the run) and store the value that node emits; each answer mints a fresh link from it. A stored url serves dead pictures by morning.

Reply is the response: status (default 200), headers (an object of strings), and body, whose shape follows the route’s dataType. The first thing your program sends commits the status line, so a Reply with status: 404 on a branch works, and a Reply after a Stream fails loud (“response head already sent”).

A run that stops without a body answers through Close: a bare one is a bodiless 204, and with a reason that sentence is the body under the status you set (Close { status: 404, reason: "nothing to sweep" }).

Branching on the request

Let the graph decide the status. A Python node reading params and returning both the body and a number wires straight into the reply:

user = Route { path: "users/{id}", method: "GET" }
lookup = ExecPython(params: Dict[String, String]) -> (body: JsonDict, status: Number) {
  code: "uid = params['id']\nif uid == '42':\n    return {'body': {'id': uid}, 'status': 200}\nreturn {'body': {'error': 'no user ' + uid}, 'status': 404}"
}
lookup.params = user.params
found = Reply
found.body = lookup.body
found.status = lookup.status

Do this, and if it did not work, stop here

Close takes no value from upstream, so its _should_flow gate is the whole wiring, and that gate takes any port of any type (the value is never read, only a false says no). The shape is a Switch on the outcome: the failing case gates a Close with the reason and status already written on it, the passing case gates the rest.

sweep = Route { path: "sweep/{what}", method: "DELETE" }
clear = ExecPython(params: Dict[String, String]) -> (removed: Number) {
  code: "return {'removed': 3 if params['what'] == 'cards' else 0}"
}
clear.params = sweep.params
outcome = Switch {
  value: clear.removed
  cases: [
    { "kind": "gt", "value": 0, "port": "some" },
    { "kind": "otherwise", "port": "none" }
  ]
}
report = Reply
report.body = clear.removed
report._should_flow = outcome.some
nothing = Close { status: 404, reason: "nothing to sweep" }
nothing._should_flow = outcome.none

A branch that skipped closes its ports, so the Close behind it skips too and the other branch’s answer stands.

Answer first, keep working

A webhook receiver wants to say 200 at once and do the slow part after. Reply early, then the rest of the graph; set outlivesCaller: true on the route so the caller hanging up does not cancel the run. Off, the run is tied to the caller and a disconnect cancels it, which is what a request-response API wants.

Long job, poll later

Answer 202 with an id from one route, store the state in the project’s Postgres, and read it back from a second route. No new node: two routes and a database.

Streaming

Stream pipes a bus to the caller, one chunk per message, until the bus closes. The canonical producer is an LLM stream:

ask = Route -> (prompt: String) { path: "chat", method: "POST" }
prov = OpenRouterProvider { model: "openai/gpt-4.1-nano" }
live = LlmStream
live.provider = prov.provider
live.prompt = ask.prompt
out = Stream { format: "sse" }
out.bus = live.stream

format decides the framing on an HTTP body: sse (text/event-stream, one data: line per line of the payload and a blank line per message) for a browser’s EventSource and LLM-style clients, ndjson (one JSON value per line) for a script, raw for the payloads as they are. The head goes out with the first chunk; the response ends when the bus closes. A bus that already closed still streams whole: Stream reads from the earliest message retained.

Sockets

sock = Socket -> (inbound: Generator[JsonDict]) { path: "chat/{room}" }
turn = Loop(msg: Generator[JsonDict]) -> (results: List[Boolean | Null]) {
  parallel: false
  over: ["msg"]
  echo = ExecPython(msg: JsonDict) -> (body: JsonDict) { code: "return {'body': {'echo': msg['text']}}" }
  echo.msg = self.msg
  say = Reply
  say.body = echo.body
  self.results = say.done
}
turn.msg = sock.inbound

inbound is a Generator: one item per message the caller sends, ended when the caller disconnects, so a Loop over it runs its body once per message in lock-step. Declare its item type to match the socket’s dataType (Generator[JsonDict], Generator[String], Generator[File]); an undeclared inbound wired anywhere is a compile error. Behind a socket, Reply is one message and the socket stays open (a status or headers there is refused), Stream is one message per bus message, and Close { code, reason } sends the close frame.

One connection is one run. A bus lives inside one execution, so two sockets cannot see each other’s messages through a bus. A chat room today is a table (the project’s Postgres) each run writes to and a trigger that reads it; a room where one socket’s message reaches another socket live is not expressible yet.

Auth

A route is open unless you wire an auth access node into its auth input. Three ship, each a connection you store as the editor stores any other:

NodeThe connection holdsThe caller presentscaller
ApiKeyAuthkeys, comma-separatedX-Api-Key: <key> or Authorization: Bearer <key>{"key": <index of the matched key>}
JwtAuthissuer, jwks_url, optional audienceAuthorization: Bearer <jwt>the token’s claims
HmacAuthsigning_secretX-Timestamp: <unix seconds>, X-Signature: <hex hmac-sha256 of "<timestamp>.<body>">{}

The dispatcher checks the caller before a run starts: a refusal is a 401 and the program never sees it. What the check established comes out on the trigger’s caller port, so finer rules (this key may only read, this user owns that room) are a branch in the graph. The dispatcher does not hold the key or the secret: it hands the request to the broker, which holds the connection’s material and does the comparison, and answers with the identity it established.

A scheme these three do not cover is a fourth access node: a metadata.json with a service recipe whose verify block names the scheme and whose paste fields hold the material by the names the scheme reads (keys, signing_secret, public_key, audience; an oidc scheme names its addresses as templates over the fields). No Rust.

When you need a custom node

Two nodes reading one socket (inbound is broadcast to ctx readers, each with its own cursor), a reply assembled from many chunks with logic between them, a response that mixes writes and a computed head: reach for ctx.http_caller() / ctx.ws_caller() in a node of your own, described in Talking to a live caller. The catalog nodes first; ctx when the graph cannot say it.

Trying it

weft activate                      # registers the routes; it prints no URL
curl -X POST "http://127.0.0.1:9999/connect/local/hello" -H 'content-type: application/json' -d '{"name":"ada"}'
websocat "ws://127.0.0.1:9999/connect/local/chat/room7"
weft follow <project>              # one execution per request, live

activate prints activated <name> (<id>) and nothing else, because there is no URL to hand you: the live URL is <dispatcher base>/connect/<tenant>/<path>, every piece fixed by the install rather than minted at activation. Locally the base is http://127.0.0.1:9999 and the tenant is local, so a frontend can be written against http://127.0.0.1:9999/connect/local/hello before the program ever runs. That address is reachable from this machine only for now: the public tunnel (--public-url) does not forward /connect/, so a route called through it answers 404. A browser page on another origin can call it: the redirect the dispatcher answers with and the gateway it lands on both carry open CORS headers (a route’s auth is per route, so the caller’s origin says nothing about whether it may call). That redirect is a signed pointer at the worker chosen to serve the call, and nothing has run yet when it goes out: the execution is born when the caller arrives at that worker, so a client that ignores redirects burns no run and leaves nothing behind. A route with the wrong method answers 405 naming the verbs it serves, an unknown path 404. A program that never replies holds the caller while it runs, then answers 500 with the body the run ended without answering: the run shows in weft follow with no Reply reached, and the fix is in the graph.

Files and reuse

Four markers pull something from disk into a program. They differ in whether edits ever flow back to the file.

MarkerPulls inWrites back
@include("x.weft")another program as a groupno
@file("x.txt")a file’s contents as a valueyes
@asset("x.png", Image)a file’s contents as a valueno
@asset("x.txt", String)a text file’s contents inlineno

@include

triage = @include("triage.weft")

triage.email = inbox.message
alert.data = triage.severity

The included file must be exactly one anonymous top-level group:

# triage.weft
Group(email: JsonDict) -> (severity: String) {
  # Classify an inbound ticket
  ...
}

Its ports become triage’s ports and you wire it like any node. An include is the same boundary as an ordinary group, so the same guarantees hold: children reach each other and self, and nothing else.

An included file is compiled once, however many places include it. Each @include is a call: when a run reaches triage, the values on its ports go into the file’s one body under a frame that names the call site, the body runs, and its results come back to that site alone. Ten includes of the same file are one body and ten frames, the way ten iterations of a loop are one body and ten frames, and the two nest freely: a loop inside an include inside a loop is simply a deeper stack.

An included file has no name you write or read. Its nodes are named the way the source reads, through the site: triage.classify is the node classify of the file triage includes, and only that use of it. That spelling is what weft events prints and what --node triage.classify filters on; weft run --group triage runs that call, and a run is cut inside the file the same way (--from triage.classify, --target triage.classify): the cut runs inside that one call, and a frozen example keeps the spelling. Include the same file from two places and the two read apart (triage.classify, again.classify).

@file

prompt = Text { value: @file("assets/prompts/triage.md") }

Reads the file’s contents as a config value. With a type:

triage = LlmParams {
  systemPrompt: @file("assets/prompts/triage.md")
}

A marker is a constant like any other, so it goes wherever a literal goes, in either spelling: Literals on a connection line. The type defaults to String and can be any type whose value is text (@file("n.txt", Number) casts the file’s text, and a text that will not cast is a compile error on that line).

@file is bidirectional. If you edit that field in the editor, the file on disk changes too. That is the point: a long prompt lives in its own file where a writer can work on it, and you can still edit it from the node that uses it.

Because it writes back, it accepts only types that survive the round trip, which rules out the binary ones.

@asset

@asset is the same idea, one direction only: nothing ever writes back.

send = TelegramSendMedia {
  file: @asset("assets/photo.png", Image)
}

@asset always names its type, because the type is what the value carries: a file value holds exactly one marker (Image, Video, Audio, or Blob), and nothing ever guesses it from the name or the bytes. @asset("a.png") is refused and the message spells the fix; so is File or Media, which leave the kind open. Declare Blob for bytes of any shape.

With a file type the file resolves through the build’s asset sync, and its bytes never ride the compile. The sync reads the file’s first bytes and holds them to the declaration: an Image over an mp3, or over bytes with no signature weft knows, fails the build naming the file and both kinds. Blob checks nothing. A file-typed @asset from a URL is checked the same way when the worker fetches it at run time, and one picked from stored files against the kind the upload recorded.

With a text type (String, Number, a JSON shape) @asset puts the file’s text in the value, like @file, with two differences: it is read-only, and the file may sit anywhere (a path outside the project, a URL, a stored file), since the build reads it, once, and casts it; the graph shows the source, not the text. In the editor, the badge next to the field flips a text-backed value between @file and @asset.

Several files go in a list, which is how a port that takes many (an email’s attachments, the media on an LLM call) is written:

send.attachments = [@asset("assets/report.pdf", Blob), @asset("assets/logo.png", Image)]

A marker is a value, so it sits wherever a value sits, and each one in that list is synced and resolved exactly like a marker standing alone.

The source can also be:

  • a path outside the project, for local runs,
  • an http(s) URL, which is never uploaded; a file-typed one the worker fetches at run time, a text-typed one the build fetches once,
  • a stored runtime file’s short address, project/<project-id>/<file-id>, picked from the editor’s stored-files browser.

@file keeps pure disk paths: a project directory literally named project/ stays readable through it, and a URL is refused (there is no file to write edits back to; use @asset).

A path in @file or @asset is relative to the project root wherever it is written: @asset("assets/logo.png", Image) names the same file from src/main.weft and from any included file. There is nothing above the root a relative path could mean, so ../ out of the project is refused. A file outside the project is named where it sits, by an absolute path or one under ~ (your home directory, expanded at compile), and only for local runs; that is what the editor’s file picker writes for a file picked from outside, so a large file is never copied into the project. An @include path is the one exception: it is relative to the file that writes it, the way an import is, so @include("../lib/auth.weft") reaches a sibling folder.

The asset sync

Right before every build, weft uploads new or changed files and puts their stored references into the compiled workflow. Identical content shares one uploaded copy, so an unchanged file is not uploaded again.

Uploaded files stay available while the current workflow uses them. An upload starts on a 30-day countdown, and a successful build clears it for every file the workflow references; a build that failed after uploading leaves its files to expire on their own. Once a successful build or status check sees that a file was replaced or removed, its old uploaded copy gets the same 30-day countdown. Opening or using that copy restarts the countdown; building or checking status again does not. Using the file in the current workflow again removes the countdown.

A waiting run keeps the reference to its original file. Waiting alone does not extend the file’s life: after 30 days without access, the old copy can expire. If that run later needs it, the node fails with the file’s name, an explanation that it may have expired or been deleted, and instructions to upload or create the file again and start a new run. It does not silently skip the node. Files created by nodes keep their own chosen lifetime rules.

Your node code never sees any of this. At run time the value on the port is an ordinary media value, and inside the running node its marker also carries a url minted for that firing (an hour), so a Python snippet or a provider that only takes URLs can fetch the bytes straight off it. The link never leaves the node: what it emits is the stored form again, and the journal never holds one.

What lands in source

The source gets one line, with no storage key or encoded blob in it.

send = TelegramSendMedia {
  file: @asset("assets/photo.png", Image)
}

When you drop a file onto a node in the editor, it writes the file under assets/ and writes exactly that line, so copying a file there by hand and typing the line gets an identical result.

The project’s own nodes

Reusing node types, rather than graph fragments, is a folder: anything under nodes/, or beside the code under src/, is available by its type name. The catalog knows a node by the metadata.json at its folder’s root and a package by its package.toml, in either tree, and the two trees form one catalog, so a type name is unique across them. nodes/ is where the standard library lives and where a node several modules share goes; a node one module alone uses sits next to that module’s file.

my-project/
  src/
    main.weft
  nodes/
    base_catalog/     the standard library, copied in at `weft new`
    reply/            a node you wrote
    scoring/          a package you wrote

The whole folder

A project is laid out like any other language’s, so nothing here should be new:

my-project/
  weft.toml           the manifest: name, id, version
  src/
    main.weft         the entry point
    triage.weft       a module: one group per file, pulled in by @include
    billing/          a package of modules, grouped by what they are about
      charge.weft
  nodes/              dependencies: base_catalog plus your own node types
  assets/             anything pulled in by @file or @asset (prompts, scripts, images)
  examples/           frozen runs, from `weft freeze`
  front/              a frontend if you have one, with its own toolchain; weft ignores it
  layouts/            generated: where the editor put each node
  .weft/              generated: build state

Start with src/main.weft alone; an image workflow or a small bot never needs more. A group earns its own file the way a module does elsewhere: it got big, or two places use it. Folders under src/ are yours to name by topic, the same call you make in any repo; nothing about the graph’s nesting dictates them. A node one module alone uses sits beside that module (src/billing/charge.weft next to src/billing/stripe_charge/), and the catalog finds it there exactly as it does under nodes/.

weft catalog update re-syncs base_catalog/ to the installed weft’s standard library. Pulling a package from git is not built yet, so a package somebody else wrote gets into your project by being copied there.

Every line of code a build compiles comes from inside the project folder, so the project directory is portable and upgrading weft cannot silently change what an existing program does. An @asset pointing at a path outside the project is the one thing a build reaches for elsewhere, which is why it is for local runs and does not travel with the project.

What the compiler refuses

Every validation error carries a stable slug. This is all of them, grouped by what they protect, with the fix.

Parsing errors, and errors from the pass that looks up each node’s ports, carry no slug. They point at the exact spot in your file and say what is wrong there.

Reading a diagnostic. The slug names the rule. The message names the fix. If you find one that names the problem but not the fix, that is a bug worth reporting.

Wiring

SlugMeaning
type-mismatcha connection’s source type is not compatible with its target. The message names both.
deref-patha wire reads a key off its value (t.n = s.out.profile.wpm) that the source type does not have: the message names the keys that exist, or, when the type has no keys at all (JsonDict, a scalar), says to declare the shape on the source port or Cast first.
required-port-unmeta required input has no wire and no literal. Wire it, give it a literal, or mark it optional with ?.
unknown-source-nodethe left side of a connection names a node that does not exist in this scope.
unknown-target-nodethe right side names a node that does not exist in this scope.
unknown-source-portthe node exists; that output port does not.
unknown-target-portthe node exists; that input port does not.
double-driven-portone input has two drivers: two wires, or a wire and a literal. An input has exactly one source.
input-acceptsa driver the port does not take: a wire on a port whose accepts is ["literal"], a written value on one whose accepts is ["wire"], or a wire or a @file/@asset on a port the compiler reads to build the node. The message reads the list back.
duplicate-input-portthe same input name declared twice on one node.
duplicate-node-idtwo nodes share an id in one scope.
gate-not-booleana gate written down (either spelling) that is not true or false. A wire may carry any value; a constant is a Boolean.
undeclared-port-no-customa port was referenced that the node neither declares nor allows you to add.
value-on-outputa value was written on one of the node’s output ports. An output takes no value: a firing emits on it, and you read it as node.port.

Types

SlugMeaning
unresolved-typevara type variable was never pinned to a concrete type. Pin it with an inline signature or wire something concrete in.
must-override-unmeta MustOverride port was left unpinned. Declare its type in the inline signature.
cast-not-allowedthis Cast conversion is impossible, for example JsonDict -> Number.
config-type-mismatcha config literal’s type does not match its field.
config-null-literala config field was given null, which is not a way to say “unset”. Omit the field.
literal-out-of-rangea number literal falls outside the min/max its widget declares.
named-type-conflictone type name declared twice with different bodies. Rename one. Identical bodies are fine.

Graph shape

SlugMeaning
graph-cyclea cycle in the wire graph. Iterate with a Loop; exchange feedback over a bus.
scope-reachabilitya connection reaches across a group boundary. Children reach each other and self, nothing else.
level-too-largea warning. A level of the graph (the file, or the inside of a group or loop) holds more than fifteen items, nodes or groups. At the file’s top level the count is per connected branch (the items one wire walk reaches, plus the infra nodes it touches; an infra node written at that level joins nothing, while a group holding one is an item like any other). About six per level is what reads; group the nodes cooperating on one job, and nest groups rather than widen the level. The program still runs: this is advice about how it reads.
loop-boundary-unpaireda loop’s internal boundary nodes do not line up. This is an internal invariant; hitting it is a compiler bug worth reporting.

Triggers

SlugMeaning
trigger-in-loopa trigger inside a Loop. An entry point per iteration is meaningless.
infra-in-loopan infra node inside a Loop. Infra is provisioned once for the project, not once per item.
trigger-into-triggera trigger wired into another trigger. No phase delivers that.
trigger-into-infraa trigger wired into an infra node. Provisioning happens before any fire exists.
route-overlaptwo nodes of this program claim public addresses a single call could reach, so which one answers has no answer (cards/count against cards/{id}, on a shared method). Change one path, or give them different methods. The same question is asked again at activation, across every project of the account, because only the dispatcher knows what your other programs already serve.
duplicate-porttwo ports on the node share a name on one side, which config-derived ports are the usual way to reach. Give them different names.
config-ports-not-a-listthe config key a node derives its ports from does not hold a list.
config-entry-not-an-objectan entry of that list is not an object.
unknown-config-entry-kindan entry names a kind this node does not offer.
config-entry-without-a-portan entry does not name the port it adds.
unknown-config-entry-keyan entry carries a key its kind does not take, usually a mistyped test.
config-entry-bad-testa test carries the wrong shape of value (a number where the matched input is a String, a regex that does not compile).
duplicate-catch-alltwo entries match anything; the second could never be reached.
catch-all-not-lastan entry matches anything and is not last, so the entries after it could never be reached.

Loops

SlugMeaning
loop-unbounded-no-terminationa sequential loop with no over, no max_iters, and no self.done write. Provably infinite. Give it something to exhaust, a cap, or a stop vote.
parallel-with-carryparallel: true with a non-empty carry. Carry implies an order.
parallel-without-overparallel: true with an empty over. The count has to be known up front.
parallel-with-doneparallel: true with a self.done write anywhere in the body.
over-and-carry-overlapa port listed in both over and carry.
over-not-a-lista port in over is neither a List[T] nor a Generator[T].
over-stream-not-alonea stream in over alongside another over port. A loop iterates one stream at a time.
gather-output-must-be-nullablea gather output not typed `List[T
carry-port-type-mismatcha carry port’s inside and outside types disagree.
loop-over-unknown-portover names a port the loop does not have.
loop-carry-unknown-portcarry names a port the loop does not have.
loop-config-missing-parallelan internal invariant broke while flattening the loop. You cannot cause this from source, so hitting it is a compiler bug worth reporting.
loop-parallel-not-booleanparallel is not true or false.
loop-trim-not-booleantrim_on_mismatch is not true or false.
loop-max-iters-not-integermax_iters is not a whole number.
loop-unknown-config-fieldan unrecognised key in a loop’s config.

Streams

Every one of these enforces the same property: a stream is a live handle with one producer and one taker.

SlugMeaning
generator-multiple-consumerstwo consumers on one stream. Broadcasting is a Bus.
generator-through-groupa stream crossing a group boundary.
generator-in-containera stream inside a List or Dict.
generator-not-carriablea stream carried between loop iterations.
generator-input-must-be-requiredan optional Generator input. An unwired stream has no meaning.
generator-not-iterateda Generator input on a loop that is not the over port. A stream cannot broadcast into a body.
generator-into-generic-porta stream wired into a port whose type is a bare type variable.

Names

SlugMeaning
reserved-namea node named self, a type keyword, or another reserved word.
reserved-port-namea port named index or done on a loop. Both are implicit.

Requirements and warnings

SlugMeaning
require-one-of-unmetan @require_one_of group where nothing is satisfied.
unknown-typea warning. A declared node type is not in the project’s catalog: a typo, or the node was never built. The message names the type.
no-required-skipa warning. Every input a wire feeds on this node is optional and there is no @require_one_of, so the node runs even when everything upstream is dead. Usually not what you want; add @require_one_of. A node built from written constants alone has no upstream and never gets this.
rule-structurala node’s own declarative validation rule failed at compile time. The message is the node author’s.
rule-runtimea node’s own rule flagged something checkable only at run time. The language writes one of these itself: every access node requires a connection picked (unless its recipe declares connection_optional), with no rule in its metadata.

Where these run

There are two validation modes, and the same slugs appear in both.

Structural is what a build runs, and what the editor runs constantly while you type: it fills the Problems panel and decides whether your program compiles.

Runtime adds the rule-runtime checks on top, the ones about things only knowable once a program is about to run, such as a provider node with no connection picked. A build and the Problems panel skip those deliberately, so that a program you are still wiring up builds without squiggles. They run when the question is whether the program is ready: the editor checks them right before Run/Activate/Resync (findings land on the action bar, and nothing is sent until they are fixed), and weft validate runs this mode in the terminal.

Both read source on stdin and print JSON. --file does not open a file: it names the path the source came from, so @file and @include resolve against the right directory. A finding inside an @included file carries that file’s path (a file key in the JSON), and the terminal output prefixes it as path:line:col.

What a node is

A node does one thing: calls an API, transcribes a piece of audio, writes a row, renders a PDF.

It takes its inputs, does that one thing, emits its result, and returns.

The node does not orchestrate

Looping, retrying, branching, fanning out, gathering results, waiting for a person between two steps: all of that is the graph’s job, expressed declaratively, and the engine gives you per-iteration journaling, resumability, and cancellation for free.

When you feel the urge to write one of these in Rust, stop and reach for the graph instead:

The urgeThe graph’s answer
a for loop driving a multi-step processa weft Loop, firing your node once per iteration
retry with backoff around a callexpress the retry in the graph
“call A, then depending on the result call B or C”wire A’s outputs to both, and let each branch’s _should_flow decide. A node that is told not to run closes its outputs, which skips everything behind it: the closure rule
“work, then wait for a human, then more work”three nodes

A node that owns a loop with a wait inside it is a small workflow engine hiding inside a node, and it has to solve by hand every problem weft already solved. Durable execution has the survival guide for when a genuine constraint forces that shape on you.

The node does not do plumbing

A node body contains its own logic and nothing else. No transport choice, no credential handling, no acknowledgement protocol, no subscription lifecycle, no retry bookkeeping. Those belong to the language, implemented once and hardened once, which is why a real node body is usually under a hundred lines. The full list of what falls on which side is the commandments of plumbing.

A node that sends a Slack message reads its inputs, builds the request body, posts it on the client it was handed, and emits the message id. It has no way to find out how the token was acquired, whether the call was measured, or whose money paid for it.

Everything it needs arrives through one object: the ctx.

When the ctx does not have what you need

First, check it is missing. The surface is large, and things are named for what the caller receives rather than for how they work, so what you want is often sitting there under a name you would not have guessed. Skim The ctx, or just ask in Discord and save yourself the reading.

If it really is missing, you are still not blocked. A node in your own project’s nodes/ is nobody’s business but yours, so write the workaround there and ship it today. The higher bar is for the shared catalog, because that is vocabulary everyone inherits.

Then come and argue for the mechanism. Whether a thing is weft’s job or yours is a line we drew and are willing to move, and the commandments of plumbing is where that line is written down, along with how to argue with it.

If it can be expressed as declared data, it becomes declared data. If it cannot, it becomes a closed typed variant, added deliberately and shared by every service.

StreamListen is the example worth knowing. Someone needed a trigger that watches a mailbox, and IMAP speaks neither HTTP nor WebSocket. Instead of an IMAP branch in the listener, what shipped describes the conversation as data, and it now serves IMAP, MQTT, Redis and XMPP alike: the signal kinds.

One node, one process

A node embodies exactly one user expectation. When one capability answers two different questions, build two nodes, even when the machinery underneath is identical. The test is what the user expects, not what the code does:

  • GoogleSheetsRead signed in, versus reading a public share link, is one node. The expectation is “read this sheet’s rows” either way; only the mechanics differ.
  • SlackReceiveMessage (your bot, your workspace, a channel you picked) versus SlackAppMessages (you own the app; every workspace that installed it) are two nodes. Same event stream, different questions, different inputs, different outputs.

Which transport serves a capability is never a node split, because the expectation is the same either way and the environment decides. Which scope it operates at always is.

Why this shape

It saves the plumbing. Credential handling, retry logic and cost accounting are the bulk of what an integration usually costs to write, and none of it is in the node.

It makes node-building an independent task. One job, a small context, a test rig that forces the author to prove the node works before it ships. That is exactly the task shape a model is good at.

Once a node exists, composition cannot misuse it: the compiler enforces its declared contract, and the API call lives inside it.

The anatomy

A node is a directory:

nodes/my_node/
  mod.rs              the Rust: a `Node` trait impl
  metadata.json       the declared surface: ports, config, presentation
  deps.toml           optional: extra cargo crates this node needs
  tests.rs            optional: the node's own tests

The trait has three bodies, and the engine picks which to call from the manifest. A node never inspects the lifecycle phase itself.

#[async_trait]
pub trait Node: NodeManifest + Send + Sync {
    /// Infra nodes only. The desired shape of the long-running service.
    async fn provision_infra(&self, ctx: InfraProvisionContext, input: ValueBag)
        -> WeftResult<InfraSpec> { /* default: error */ }

    /// Triggers only. Register the wake signal. Called INSTEAD of `run`
    /// at registration time.
    async fn setup_trigger(&self, ctx: ExecutionContext)
        -> WeftResult<()> { /* default: error */ }

    /// The normal body. The only way to fire downstream is
    /// `ctx.pulse_downstream(output)`.
    async fn run(&self, ctx: ExecutionContext) -> WeftResult<()>;
}

Most nodes implement only run.

Next: your first node.

Your first node

The smallest node in the standard library, near enough in full.

metadata.json

{
  "type": "Text",
  "label": "Text",
  "description": "Emit a literal string configured at design time.",
  "tags": ["basic"],
  "icon": "Type",
  "color": "#64748b",
  "inputs": [
    { "name": "value", "type": "String", "required": true,
      "label": "Value", "description": "The string to emit." }
  ],
  "outputs": [
    { "name": "value", "type": "String",
      "description": "The configured string." }
  ]
}

"required": true is there because Text cannot run without a string. For every key you can write, and when each one earns its place, go and read metadata.json.

mod.rs

//! Text: emit a literal string configured at design time.

use async_trait::async_trait;

use weft::{ExecutionContext, Node, NodeManifest, WeftResult};
use weft::node::NodeOutput;

#[derive(NodeManifest)]
pub struct TextNode;

#[async_trait]
impl Node for TextNode {
    async fn run(&self, ctx: ExecutionContext) -> WeftResult<()> {
        let value: String = ctx.inputs.get("value")?;
        ctx.pulse_downstream(NodeOutput::new().set("value", value)).await
    }
}

That is a working node. Drop that folder under nodes/ and Text is available to every program in the project.

The four things to notice

Everything imports from weft. One crate name, one place to look. It is the only author-facing name.

#[derive(NodeManifest)] reads the JSON. At compile time it finds the metadata.json sitting next to this source file and embeds it. The node’s type name comes from the JSON’s type field. You never write node_type or build a metadata struct by hand, and a missing or malformed JSON is a compile error, so the two files cannot drift.

Reads go through ctx.inputs. One bag, one accessor. It does not matter whether the value arrived on a wire, as a literal in the braces, or from the input’s declared default; the node reads it the same way.

pulse_downstream is the only way out. Returning a value does nothing. Emitting is an explicit call, because a node may emit on several ports, may emit repeatedly on a stream port, and may deliberately emit on none.

Naming

Two conventions the codebase holds to everywhere:

  • The type in metadata is PascalCase: Text, SlackSendMessage.
  • The struct is that plus Node: TextNode, SlackSendMessageNode.
  • The folder is snake_case: text/, send_message/.
  • Ports are camelCase: threadTs, postAt, scheduledId.

A node that actually does something

Here is the shape almost every real node has. It reads a connection, calls a service, and emits what came back.

//! Post a message to a Slack channel and emit its timestamp.
//!
//! Emitting the permalink is best-effort: a failure to read it back is
//! logged and the node still succeeds, because failing here would invite
//! a retry that double-posts.

use async_trait::async_trait;

use weft::{Access, ExecutionContext, Node, NodeErrExt, NodeManifest, WeftResult};
use weft::node::NodeOutput;

#[derive(NodeManifest)]
pub struct SlackSendMessageNode;

#[async_trait]
impl Node for SlackSendMessageNode {
    async fn run(&self, ctx: ExecutionContext) -> WeftResult<()> {
        let account: Access = ctx.inputs.get("account")?;
        let channel: String = ctx.inputs.get("channel")?;
        let text: String = ctx.inputs.get("text")?;

        let slack = ctx.client(&account).await?;

        let posted = slack
            .post("https://slack.com/api/chat.postMessage")
            .json(&serde_json::json!({ "channel": channel, "text": text }))
            .send()
            .await
            .node_err("posting the message")?
            .json::<serde_json::Value>()
            .await
            .node_err("decoding Slack's reply")?;

        let ts = posted["ts"].as_str()
            .ok_or_else(|| weft::node_error("Slack accepted the post but returned no timestamp"))?;

        ctx.pulse_downstream(
            NodeOutput::new()
                .set("ts", ts)
                .set("channel", channel),
        ).await
    }
}

Everything in it is this node’s own business. The token, the refresh, the signing, the measurement and whose account is paying all happen below ctx.client(&account), where this code cannot see them.

The header comment

Look at the //! block above, and write yours the same way.

A file header states the module’s responsibility in prose and records the why behind anything non-obvious. Not a summary of what the code does: what you cannot read from the code is why the permalink failure is swallowed, and that is what the comment is for.

Adding dependencies

deps.toml next to mod.rs:

[dependencies]
reqwest = { version = "0.12", features = ["json"] }

weft, tokio, serde, serde_json, async-trait, anyhow, tracing and uuid are there already, so a deps.toml only names what those do not cover. More about it, including how to pull in an OS package: Packaging.

Next: metadata.json for the full declared surface, or the ctx for everything a running node can reach.

metadata.json

The node’s declared surface. Ports, config, presentation, and the handful of flags the engine reads.

It is data rather than code because the compiler has to know a node’s shape without compiling its Rust, which is what lets the editor draw a graph and the validator check every wire in milliseconds.

Unknown keys are rejected everywhere, so a typo is a load error rather than a setting that silently does nothing.

The top level

KeyWhat it is
typethe node type name. PascalCase. This is the node’s identity.
labelwhat the editor shows on the box
descriptionone paragraph, saying what the node emits
tagsstrings, for search and grouping
icona Lucide icon name in PascalCase
colora hex string for the node’s accent
inputsthe input ports
outputsthe output ports
typesnamed custom types this node declares. See Custom types.
requires_infratrue for infra nodes
imagescontainer image directories, for infra nodes
publishesthe service name this node hands out a connection to
servicethe whole service recipe, for access nodes
accessAppsa public, secretless OAuth app this project ships
portsFromConfigwhere a node’s ports come from, when they come from its own config
firesWithfor a trigger, what a firing has to carry to start one
featuresboolean-ish flags
displaywhat the editor renders inline on the node body
validatedeclarative validation rules

features holds flags. Anything with structure gets its own top-level key.

A name Lucide does not ship renders as a plain square, with the reason in the console.

Inputs

Every value a node takes from the graph is an input port, one value per port. A node never asks the author to pack several values into one list or dict first: PostgresExecuteQuery once took its parameters as a List, and every query with two parameters cost the program a Python node whose whole body was return {'params': [a, b]}. When the set of values is open-ended (a query’s parameters, a template’s holes, a script’s variables), the node declares canAddInputPorts in features and the author declares the ports inline: PostgresExecuteQuery(user_id: String) { query: "... WHERE id = $user_id" }. The body reads them with ctx.inputs.custom().

{
  "name": "method",
  "type": "String",
  "required": true,
  "widget": { "kind": "select", "options": ["GET", "POST"] },
  "default": "GET",
  "label": "Method",
  "placeholder": "...",
  "description": "The HTTP verb to use."
}

accepts

Which of the two drivers this input takes. A value written in the source is a literal (whatever its spelling: in the braces, on its own n.x = ... line, a @file or @asset marker); a value another node produces is a wire (an edge, a dotted value in the braces, an inline node). Leave the key out and the input takes both, which is what almost every port wants.

"accepts": ["wire"]

An input never adds a form; it only removes one, and only for a reason the node can name. ["wire"] refuses every written value, for a port that needs a real node rather than a value (an inference node’s provider, history, params). ["literal"] refuses wiring. Getting it wrong is input-accepts, and the message reads the list back: “params accepts: wire”.

Two kinds of port carry no list, because their drivers are not yours to pick. A Bus or Generator port is wire-only by nature (a live handle no human can write); the loader forces it, and a list naming literal there is an error. A port the compiler reads to build the node, the list named in portsFromConfig or the input carrying the access picker, takes an inline typed value only: no wire, no @file, no @asset, so the node’s shape is readable in the source without following anything. That is a fixed rule nobody writes and no accepts loosens.

An input has exactly one driver. Two is double-driven-port.

widget

Overrides the editor control. Absent, the control derives from the type through one central mapping: file types get a drop control, Boolean a checkbox, Number a number box, a List[String] the add-one-at-a-time list, a String a single-line box, and everything else a text area holding the value as JSON text. A String field that really holds prose (a prompt, a message body) declares "widget": { "kind": "textarea" }.

A port that holds SEVERAL files (List[Audio], or a Media | List[Media] that takes one or many) gets the same drop control keeping a list: it picks several at once, adds one at a time, and writes one @asset(...) per file.

KindFor
select / multiselecta small fixed vocabulary; takes options. A written value outside them is a compile error, a wired one fails the firing
textareaa multi-line box, for a String that holds prose
codea code editor; takes language
numbertakes min / max / step; the editor and the compiler hold a written value to them, and the run holds a wired one (a whole-number step refuses a fraction)
datetimea calendar-and-clock picker for a String holding one moment; stores ISO-8601 with the picker’s zone offset
passworda masked field
file_dropnarrows the file filter beyond the type; takes accept
text_lista list of short text values, added one at a time
entry_listbuild the list a node’s ports come from (see below)
accessthe connection picker. See Using a connection.
remote_selectpick a resource inside the connected account

A field whose value names something enumerable is never a bare text field. If the vocabulary is small and fixed, declare select. If the provider can list the choices (model ids, voices, channels, databases, repos), declare remote_select so the user searches instead of copying an id out of the provider’s docs. Add free_text: true when a pasted id is also valid, for example a model route the list has not caught up with. Plain text is for free-form values: a prompt, a URL, a message body.

remote_select gets its own treatment in Using a connection.

required

Write "required": true on an input the node cannot run without. Leave the key off everywhere else: absent already means optional, so "required": false says nothing and reads as though somebody meant something by it. Outputs never carry it at all (metadata load refuses one that does).

default

The value the runtime supplies when nothing else drives the input. Consulted at run time, rendered by the editor as the effective value, and never written into source. required plus default is satisfiable with no driver at all.

requiresScopes and requiresValues

Only on Access inputs. They state what this node needs from a connection. See Using a connection.

Outputs

{ "name": "ts", "type": "String",
  "description": "The posted message's timestamp." }

Simpler than inputs: no widget, no default, and no required. An output has no optionality to declare: a port not present in a firing’s output emits no pulse, which closes it and skips what is downstream, whatever the metadata could have said. That is the closure rule. A required key on an output is refused at load, naming the removal.

firesWith

A trigger starts an execution from outside: a listener wakes it, or somebody types weft run --fire. Whatever they hand it is the fire payload, and it is neither the node’s inputs nor its outputs: it is the one thing that has to exist before the node’s own logic can even begin.

firesWith writes down the shape of that payload, as a flat object from field name to a weft type. It goes right before features.

"firesWith": {
  "scheduledTime": "String",
  "actualTime": "String"
},

A ? on the end of a field NAME, not the type, marks it optional:

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

Only a node with features.isTrigger may declare it, and every trigger should. The engine checks a real firing against this shape before the node’s body runs, and weft run --fire checks a hand-typed payload against it before building or starting anything. Either way, a listener whose fields moved, or a typo in a payload you typed yourself, is refused by naming the exact field that is wrong or missing, instead of failing somewhere inside the node.

It is the complete list, not a highlight of the useful bits

A payload carrying a field you did not name is refused, exactly like one missing a field you did. So name every field that can arrive, not only the ones you put on ports, and put a ? on the ones that only sometimes come.

This is strict on purpose. A trigger’s payload is the one value a node did not compute and cannot check for itself, and this is where it gets checked, once, before anything runs. Let an unnamed field through and what the node actually receives quietly stops being what the node says it receives, and the day that matters is the day some code reads a field nobody wrote down.

The cost is that a provider adding a field stops that trigger until somebody adds the field here. That is one line in a JSON file, and the refusal names the field, so the fix is obvious and takes a minute. The alternative is a node whose declared shape and real shape drift apart silently, which is not one line and not a minute.

If your trigger is fed by a connection’s events, you do not have to guess the list: the service’s recipe declares it, per topic, under events.<topic>.fields (writing a service). A firing carries those names and no others, minus any the provider’s event did not have, so that map is exactly what belongs here. weft-compiler/tests/fires_with.rs holds every shipped trigger to its topic’s list, in both directions, so a field you forget fails there rather than in front of somebody’s users.

The type strings are ordinary weft types, and a field can be a record nested to any depth:

"item": "List[{ id: String, tags: List[String] }]"

Two triggers in the catalog declare nothing, both for reasons no declaration could fix: ReceiveEmail opens its own IMAP session and reads the mail itself, so there is no payload field to name. HumanTrigger’s fields are the form fields somebody typed into that instance, which differ per node, so no static declaration could name them either.

features

The complete set, hidden aside, which only catalog nodes use:

FlagMeaning
isTriggerthis node starts executions from outside
optionalCustomInputsports created by a wire on this node are optional by default
customInputTypethe type every WIRED created port takes; a shared variable (T) makes them one type. A port created by a config literal takes the literal’s own inferred type instead, so a non-string literal on a String-typed node is caught at run time by the node, loudly
liveEndpointnames the endpoint serving /live for an infra node
canAddInputPortsthe .weft author may add input ports to this node, by declaring them or by wiring a config key that names no declared port. Without it, an extra port is a compile error. This is how a node takes an open-ended set of values (ExecPython, Format, PostgresExecuteQuery); never a List input the author has to assemble.
canAddOutputPortsthe same for outputs
showDebugPreviewthe editor renders the node’s latest output inline on its body
oneOfRequiredgroups of ports where at least one of each group has to arrive, or the node is skipped. [["message", "attachment"]] means a send needs one or the other.
castPortsthis node converts a named input into a named output’s declared type, checked against the conversion table at compile time

display

For a node whose firing produces or receives a file worth seeing.

"display": { "kind": "media", "output": "image" }

kind is media, which renders the file by its own mime type (an image inline, audio and video with a real player, anything unplayable as a file card with a save button), or link, which renders the metadata and download card only. There is no flag per media type: a newly playable format is a renderer change.

Name the port with its side, exactly one of output (a generator showing what it made) or input (a display sink showing what was wired in), which is what keeps a node with a same-named input and output unambiguous. Declaring both, neither, or a port that does not exist is refused when the catalog loads.

Package defaults

A package (a directory with package.toml) may hold a partial metadata.json at its root: defaults every member node inherits.

The merge is top-level and key by key: a member gets each key unless its own metadata.json carries that key, in which case the member’s value wins wholesale. There is no deep merge.

type, label, and description can never be defaults, because they are one node’s identity.

If a package’s nodes share a portsFromConfig vocabulary, a types block, or a provider name, this is where it goes.

catalog/human/
  package.toml
  metadata.json        PARTIAL: { "portsFromConfig": {...} }, shared by both
  form_helpers.rs      shared code
  trigger/  metadata.json    HumanTrigger
  query/    metadata.json    HumanQuery

Ports that come from a node’s own config

A node can grow its ports from a LIST in its own config: HumanQuery from the form fields somebody configured, Switch from its cases. It declares which config key holds that list, and the entry kinds the list may use, under portsFromConfig. The real ports are derived at compile time from what the graph author wrote.

{
  "portsFromConfig": {
    "field": "fields",
    "specs": [
      {
        "kind": "approve_reject",
        "label": "Approve / Reject",
        "render": { "component": "buttons", "source": "static" },
        "fields": [
          "label",
          { "key": "approveLabel", "label": "Approve button text", "shape": "typed", "valueType": "String" },
          { "key": "rejectLabel", "label": "Reject button text", "shape": "typed", "valueType": "String" }
        ],
        "addsOutputs": [
          { "nameTemplate": "{key}_approved", "portType": "Boolean" },
          { "nameTemplate": "{key}_rejected", "portType": "Boolean" }
        ]
      },
      {
        "kind": "text_input",
        "label": "Text input",
        "render": { "component": "text" },
        "addsOutputs": [ { "nameTemplate": "{key}", "portType": "String" } ]
      }
    ]
  }
}

Every entry names its kind, and the port it adds under the key its spec asks for: keyField defaults to "key" (a form field), and Switch sets it to "port" so a case reads in its own vocabulary. {key} is substituted with that name. T_Auto as a port type requests a per-entry type variable.

Derivation reads only each entry’s kind and its port name. It does not read an entry’s render from the graph source; that is inherited from the spec. An entry may override render, but it need not, and the editor emits the minimal entry so the source stays lean.

A port name has to be a legal identifier.

What a kind asks the author for

A kind lists under fields the values an entry of that kind has to carry: a select’s options, a case’s value. Each one declares the SHAPE its value must have, which is what the compiler holds it to, and the editor draws a control for it from its widget. Nothing between the metadata and the graph learns what any key means.

{ "key": "options", "label": "Options", "required": true,
  "shape": "typed", "valueType": "List[String]" }

An entry may also be a bare NAME, which is the same declaration with everything obvious filled in: a one-line String box, keyed and labelled after the name, optional.

"fields": ["label", "placeholder"]

is exactly

"fields": [
  { "key": "label", "label": "Label", "shape": "typed", "valueType": "String" },
  { "key": "placeholder", "label": "Placeholder", "shape": "typed", "valueType": "String" }
]

A name of several words reads as words, so minLength and min_length both label as “Min Length”. Write the whole declaration when the label is not the key (approveLabel wants “Approve button text”), when the value is not a String, or when it is required.

shape: "typed" names a plain weft type in valueType. The other shapes are measured against the input the entries are matched on, so they only make sense on a list whose entries compete (below): value (something that input could hold), valueList, number, element (one item of a list, or a piece of the text), and regex, which has to compile.

The control comes from the shape unless the field names a widget, using the same widget vocabulary an input port uses. A List[String] gets text_list, a number shape gets a number box, everything else a text box.

Entries that compete

When the entries are TRIED IN ORDER and only one wins (a switch’s cases, never a form’s fields), the KIND is the test: one kind per way of matching, each asking for the value it compares against. matchInput names the input they are all matched against, and the kind that takes anything says catchAll.

{
  "field": "cases",
  "matchInput": "value",
  "specs": [
    { "kind": "equals", "keyField": "port", "label": "is exactly this value",
      "fields": [{ "key": "value", "label": "Value", "required": true, "shape": "value" }],
      "addsOutputs": [{ "nameTemplate": "{key}", "portType": "Boolean" }] },
    { "kind": "between", "keyField": "port", "label": "between these two numbers",
      "fields": [
        { "key": "min", "label": "Lowest", "required": true, "shape": "number" },
        { "key": "max", "label": "Highest", "required": true, "shape": "number" }
      ],
      "addsOutputs": [{ "nameTemplate": "{key}", "portType": "Boolean" }] },
    { "kind": "otherwise", "keyField": "port", "label": "anything else",
      "catchAll": true,
      "addsOutputs": [{ "nameTemplate": "{key}", "portType": "Boolean" }] }
  ]
}

The compiler holds a catchAll entry to being unique and last, since anything after it can never be reached. An entry carrying a key its kind never declared is a compile error.

The ctx

One object, handed to every node body, carrying everything a node needs from the outside world.

async fn run(&self, ctx: ExecutionContext) -> WeftResult<()>

Why one object

Left to themselves, two nodes calling two APIs end up with two HTTP clients, two retry policies, two ideas about where a token lives, and two different bugs. So authentication, storage, buses, journaling, suspension and cancellation are built once and reached through this object.

ctx.client(&access) hands back an HTTP client already signed in to the service you named, so a node whose connection declares AWS SigV4 as JSON gets every request signed without a line of node code.

Where the dividing line runs, and how to argue that it is in the wrong place: the commandments of plumbing.

Identity

Plain fields, always present.

ctx.execution_id
ctx.project_id
ctx.node_id
ctx.node_type
ctx.node_label      // Option<String>
ctx.color           // the execution's id
ctx.frames          // the loop iteration stack

Values in

ctx.inputs      // everything wired, configured, or defaulted
ctx.wake        // a trigger fire's event payload

Both are ValueBags with the same accessors. Full treatment in Reading inputs, emitting outputs.

Values out

ctx.pulse_downstream(NodeOutput::new().set("port", value)).await
ctx.yield_downstream(output).await     // waits until the value was taken
ctx.close_port("port").await?        // explicitly emit nothing
ctx.fan_declared(&value)               // fan a JSON object onto same-named ports
ctx.output_type("port")                // the port's resolved type

Calling a third party

let conn = ctx.open(&access).await?;    // resolve + lease for this firing
conn.client()                            // signed in, and measured if a meter exists
conn.credential()?                       // the raw string, when there is one
conn.value("imap_host")?                 // a stored value by name
conn.socket(url).await?                  // the service's realtime API

ctx.client(&access).await?               // sugar: open, hand back the client
ctx.http()                               // a plain client, for unauthenticated calls

The node names a service and nothing else. Whether calls are measured, and whose money pays, are decided elsewhere and are invisible here. Using a connection.

Files

let storage = ctx.storage(StorageScope::Project);
storage.put(...).await?;
storage.get(...).await?;
storage.presign(...).await?;
storage.externalize(&value, &ty, policy).await?;
storage.internalize(&response, &ty, None).await?;

The scope decides where the file lives and how long. Storage.

Pausing

ctx.await_signal(Form { .. }).await?     // park this firing; the worker exits
ctx.register_signal(Route { .. }).await?         // a trigger's registration
ctx.run("name", || async { ... }).await? // run once, replay the result forever

Surviving a restart.

Talking to a live caller

ctx.http_caller().await?      // fails loud if this run has no HTTP caller
ctx.ws_caller().await?
ctx.live_caller().await?      // either protocol, connected
ctx.caller()                  // Option<CallerHandle>, the protocol-typed form
ctx.caller_request()?         // what the caller sent to open the exchange
ctx.is_api_call()
ctx.is_websocket()

Talking to a live caller.

Talking to other nodes

ctx.open_bus("channel", BusOptions::default(), "host").await?
ctx.join_bus("channel", "guest")?
ctx.bus_from_input("channel")?
ctx.set_max_buffered_items("rows", 100_000)?

Streams and buses in Rust.

Infrastructure

let api = ctx.endpoint("api").await?;   // resolves, then waits until it answers
api.url();
api.host_and_port()?;
api.call(EndpointMethod::Get, "/outputs", None).await?;

Infrastructure nodes.

Stopping

ctx.is_cancelled()
ctx.cancellation()      // Arc<CancellationFlag>

Ordinary async Rust is cancellable with no code at all. You need these only for subprocesses, blocking CPU work, and resources needing explicit cleanup. Cancellation.

Steering other runs

ctx.tag_execution(["user_7"]).await?;                 // label this run
ctx.stop_tagged("user_7", StopSelf::Keep).await?;     // stop the others carrying it
ctx.stop_tagged("exp_3", StopSelf::Include).await?;   // stop them all, me too

A run can label itself and stop every other run of the project carrying a label, including runs parked on a person or a timer. This is how three messages from one sender end with only the latest one answered. For the ordering rule and what the journal says afterwards, go and read Stopping other runs.

Logging and errors

ctx.log(LogLevel::Info, "message").await?;

// on any non-weft Result or Option:
something().node_err("doing the thing")?;

// for a bad condition you detected yourself:
weft::node_bail!("bridge rejected: {reason}");

// the expression form, for map_err / ok_or_else closures:
Err(weft::node_error(format!("no timestamp in {body}")))

Those are the only error doors: the input accessors stamp their own errors, every ctx handle already returns WeftResult, and node code never names a WeftError variant. Worked examples of each are in Values and emission.

What the ctx will not give you

There is no way to ask whose credential you are using, whether the call was billed, or what it cost. A node that could ask could branch on it, and then the same node would behave differently for different users.

There is no way to construct a client for a connection yourself. A hand-rolled client is invisible to the cost trail and will not carry the routing a runtime-supplied credential needs.

There is no way to write to the journal directly. The journal records what happened; it is not a log you post to. ctx.log is the log.

There is no lifecycle phase to inspect. A trigger writes two bodies and the engine calls the right one.

Every one of those is missing so that a node cannot behave one way on the author’s machine and another way in production.

Reading inputs, emitting outputs

One bag

A node reads its named values from ctx.inputs. However the value got there, a wire, a literal in the braces, a statement literal, or the input’s declared default, it is read the same way. When several sources could supply one, a wire or a literal wins over the declared default.

A trigger’s fire payload is a separate bag, ctx.wake, with the same accessors. See Writing a trigger.

The accessors

let name: String = ctx.inputs.get("name")?;              // required, typed
let alias: Option<String> = ctx.inputs.opt("alias")?;    // absent or null -> None
let limit: u32 = ctx.inputs.get_or("limit", 50)?;        // absent -> default
let raw = ctx.inputs.raw("payload");                     // Option<&Value>

get fails loudly when the value is absent or the wrong type, and the error names the input.

opt answers None for absent or null, but a present, wrong-typed value still errors, because “you did not give me one” and “you gave me a number where I need a string” are different situations.

If the input has a sensible fallback, reach for get_or. Never write .get(..).unwrap_or(..), which swallows a real type error into a silently-wrong default.

.raw(name) gives the optional raw JSON for pass-through reads. A required raw read is .get::<Value>("name")?.

Reading a nested object

let cfg = ctx.inputs.nested("config")?;
let model: String = cfg.get_or("model", "default-model".into())?;

An object-valued input becomes its own bag with the same accessors. Absent means an empty bag with every knob at its default; a present non-object value errors loudly.

That is the config-node pattern, and the engine does nothing special for it: the config node emits one plain object, the consuming node declares an ordinary object-typed input (usually "accepts": ["wire"], so a real node must be wired), and reads that object itself. No input name triggers hidden behavior, and an object wired to an input always arrives as that object.

Iterating

Four projections, for nodes that loop over values without knowing their names in advance:

CallYields
.iter()every named value
.declared()only the node type’s own metadata-declared inputs
.custom()only this instance’s extras: created ports, config-derived ports
.in_order()every value that ARRIVED, in the node’s port order

.custom() is the one for nodes that treat “whatever the user wired in” as a dynamic set: script variables, a query’s parameters, a template’s holes, form prefill. It pairs with canAddInputPorts in the metadata, and it is the shape for any open-ended set of values: a node never takes a List the author has to assemble from wires, because a list literal cannot hold a wire and the author ends up writing a Python node just to build it.

.in_order() is for a node that answers by ORDER. A port that delivered nothing is absent, so the first pair is the first branch that spoke, which is the whole of what FirstInOrder does. For a created port, that order is the order the author wrote it in.

The whole bag at once

let obj = ctx.inputs.object()?;

For a node that consumes or forwards the bag as a record. On ctx.inputs this always answers. On ctx.wake it fails loudly when the fire delivered no keyed record, so a broken delivery can never pass as an empty one.

Files

let handle = ctx.inputs.get::<FileHandle>("image")?;

Reading a file value parses the handle, failing loudly when there is nothing readable, and the storage verbs take that handle directly.

Emitting

ctx.pulse_downstream(
    NodeOutput::new()
        .set("ts", ts)
        .set("channel", channel),
).await

.set takes anything that converts to JSON, and an already-built Value passes through untouched. Chain it for more ports.

.extend_from_object(&json) fans a JSON object’s keys onto same-named ports, and ctx.fan_declared(&value) does the same restricted to ports the node declares.

A port not present in the output emits no pulse, which closes it, which skips everything downstream. That is how you express “there was no result”. See the closure rule.

A Generator[T] output accepts repeated emissions, each one an item of the stream. Every other port takes at most one emission per firing, and a second is refused.

Explicit closure

ctx.close_port("value").await?;

Says “nothing will arrive here” without emitting. On a Generator output it is the early end-of-stream verb, legal after any number of yields.

Waiting for the value to be taken

ctx.yield_downstream(output).await?;

Same emission, but it does not return until the value was taken: the consumer dispatched, or the stream item pulled.

On a stream port it is the lock-step yield. On an ordinary port it is a real synchronization point: “do not continue until the next stage started”. A phone-call node that must not proceed until the answering node is live wants exactly this.

It fails loudly when the delivery can never happen, because the consumer skipped or finished without taking the value, rather than waiting forever.

Errors

// wrap any non-weft Result or Option
let body = resp.json::<Value>().await.node_err("decoding the reply")?;

// a condition you detected yourself
weft::node_bail!("pick ONE destination: a channel or a user, not both");

// the expression form, for closures that build a message first
.ok_or_else(|| weft::node_error(format!("no id in {body}")))?

On a Result, .node_err("doing X") produces a node failure reading doing X: <the underlying error>. On an Option, None becomes a failure carrying the message verbatim.

Node code never names a WeftError variant, because the variants are the runtime’s vocabulary and a node’s failure is always the same kind of thing: this node could not do its job, here is why.

A failed node closes its outputs, so a failure propagates exactly like an absent value, and a downstream node with an optional input is the recovery path.

Showing a result on the node

A node whose firing produces or receives a file worth looking at can have the editor render it inline on the node body, by declaring a display block: metadata.json.

Custom types

A types key in any metadata.json, a node’s own or a package root’s shared partial, declares named types the whole project may use.

"types": {
  "ChatHistory": "List[ChatMessage]",
  "ChatMessage": "{ role: String, content: String | List[Part], name?: String }",
  "Part": "{ type: String, text?: String, image_url?: { url: Image } }"
}

The four rules

Declarations are global. Once any metadata declares ChatHistory, every node’s ports and every .weft inline signature may name it. Declare a type next to the node that owns the concept.

Named types are nominal. Only a same-named value wires in. The value wires out into JsonDict freely, so forgetting the name is always safe. The user’s escape hatch for a hand-built dict is the Cast node, which validates at run time, so your node can trust that a named input already fits its declared structure.

Redeclaring an identical body is absorbed silently. Two packages may ship the same shared type without depending on each other. A different body under the same name fails the catalog load loudly, so drift between two copies is a build error.

Record validation is strict. A value carrying a key the record does not declare is refused. So declare every field the real values carry, with ? on the optional ones. Half-declaring a shape produces a type that rejects real data.

What a name buys you

A named type is a contract that survives being passed around. Without one, a chat history is a JsonDict and every node that touches it works out the shape again for itself, and they do not all reach the same answer.

Media inside a custom type

A field declared Image, Audio, Video, or Blob is a media slot. In stored form, which is what rides edges and lands in the journal, it holds a small stored-file reference, so a conversation carrying forty images stays cheap to journal.

A provider wants bytes or URLs. Two storage verbs convert a whole typed value at that boundary, driven by the declared type, so there is no per-node walking code anywhere:

use weft::storage::media::{ExternalizePolicy, MediaForm};

let ty = ctx.output_type("history").expect("declared on the port");
let storage = ctx.storage(StorageScope::Project);

// Going out to a provider: each media slot becomes something it can use.
let wire = storage.externalize(
    &value,
    &ty,
    ExternalizePolicy { audio: MediaForm::Inline, ..ExternalizePolicy::urls() },
).await?;

// Coming back: raw media (a data: URL, an external URL) is stored, and every
// slot becomes a stored-file value again. Already-stored slots pass through.
let stored = storage.internalize(&response_value, &ty, None).await?;

MediaForm::Url is a preference, not a promise: a slot becomes a public link when an internet-reachable address is configured, and falls back to inline base64 bytes when none is. Declare Inline only for consumers that accept nothing else.

Emit only the internalized form. Public links expire, so one sitting in a journal row is a broken link when somebody opens that run later.

The chat nodes in catalog/ai are the worked example: ChatHistory carries media through an arbitrarily long conversation with one externalize per call and one internalize per reply.

Sharing state across executions

If you are thinking of reaching for a plain Rust static in a node’s module, know that a worker process multiplexes many executions of the same project, so every firing of every node in that file sees the same instance for as long as the worker lives.

A worker only ever hosts one project, so nothing of another tenant’s can reach it. So executions reading and writing each other’s state through it is a feature: caches, pools, warmed clients, a shared buffer one execution fills and others drain.

/// The process-shared `GeneratorInfo` for a model. Shared because the model's
/// published rates are cached on the generator, so the price sheet is fetched
/// once per TTL rather than once per execution.
fn shared_generator(model: &str) -> GeneratorInfo {
    static POOL: OnceLock<Mutex<HashMap<String, GeneratorInfo>>> = OnceLock::new();
    let fresh = GeneratorInfo::openrouter(model);
    let mut pool = POOL.get_or_init(Mutex::default).lock().expect("generator pool lock");
    pool.entry(fresh.pricing_key()).or_insert(fresh).clone()
}

One rule keeps it sound: it is a per-process layer, not durable state. It dies with the worker, and workers shut down when idle. Other pods never see it. Anything that must survive a restart or be visible across pods belongs in the durable primitives (ctx.run, buses, storage), with the static as at most a warm cache in front.

And hold locks only across map lookups, never across an .await.

If a piece of state should be private to one execution, key it by ctx.execution_id.

The same pattern covers repeated storage reads: a node that inlines the same bytes on every firing, such as an audio clip re-sent to a provider each conversation turn, can keep a byte cache in a static keyed by the file’s storage key. Values themselves stay single-form, so the journal replays without any cache, which makes this a pure optimization you add when a real workload measures slow.

What your node shows in the graph

A node on the canvas is a box with its ports and its config, and it can also carry a panel on its body. Four things can go in there. Three of them you ask for in metadata.json; the fourth arrives on its own if your node is a trigger.

A file it made or received

If your node’s firing produces or receives a file worth looking at, one line in metadata.json puts it on the body:

"display": { "kind": "media", "output": "image" }

For what kind and the port side mean, go and read display.

Its last value

If you want a node’s value visible on the canvas while people are wiring things up, ask for the debug preview:

"features": { "showDebugPreview": true }

The editor renders it as JSON under the body, with a copy button. Which value depends on your node type’s declared ports: one with outputs shows what it emitted, one with none shows what flowed into it. It is the last execution’s value, and with no value to show the box says what the node is doing instead: “Waiting for data…” before any run, “Processing…” while it runs, “Suspended” while it waits for an answer, and “Execution complete” or “Execution failed: …” after.

Collapse the node and both the preview and the file display go away. An infra or trigger feed stays visible either way.

A live feed from an infra node

An infra node is a container you brought up, and it can report what it is doing. Serve /live, name that endpoint in features.liveEndpoint, and the editor polls it while the graph is open. For what the endpoint must return, go and read the routes your container serves.

Four item types render. text is a copyable box, image takes anything an <img src> accepts, progress takes a number from 0 to 1 and draws a bar, and secret sits behind a •••• mask until the user clicks the eye. Use secret for anything that should not sit on somebody’s screen, like an API key; the copy button hands over the real value whether or not it is revealed.

An item whose type is known but whose data is the wrong shape, a progress carrying a string, becomes an amber chip reading <its label> (unrenderable: progress) rather than disappearing quietly.

An item is dropped before the panel sees it if it is missing its label, if its data is neither a string nor a number, if its type is not one of those four, or if it carries an action without a label and an actionKind.

If the feed cannot be reached, the items are replaced by the reason: “Infra not running. Start it from the project’s action bar.”, or whatever the container answered with.

A trigger’s address

There is nothing to opt into here. Any node with features.isTrigger gets a panel showing what the runtime knows about its registration.

A trigger mounted on a public path shows that path, how a caller authenticates, and the API key if one was minted, masked behind the same ••••. After a listener restart the plaintext is gone and the panel says so. One that fires from inside the runtime, a timer say, has no address, and its panel carries a single line, Auth: public (no key), which means there is no key rather than that anybody can reach it.

That panel is built by the editor from what the listener reports, not by your node, because a trigger’s address is minted at activation and lives in the runtime. It is read-only: a panel button reaches your trigger kind’s handle_action in the listener, and no shipped kind implements one yet.

An unregistered trigger says so: “Trigger not registered. Activate the project from the action bar.”

When there is no panel

Add a panel when the alternative is asking the user to go and read a log.

Storage

A running node reads and writes files through ctx.storage. Every write takes a scope, and the scope decides where the file lives and how long it lives. Pick it by how long you need the file to survive.

The scopes

ScopeLives underLifetime
StorageScope::Executionexec/<run>/one run, unless flagged kept
StorageScope::Projectproject/<project_id>/as long as the project
StorageScope::Shared { name }shared/<name>/as long as the owner
StorageScope::Assetthe project’s @asset filesread-only; a worker write to it is refused

Execution is the default, and it is for scratch: intermediate files, temporary downloads, anything nothing cares about once the run ends. It sweeps itself up.

Project is for a project’s own persistent state: a cache, an index, accumulated outputs. It outlives individual runs and is shared across the project’s executions, and it is deleted when the project is deleted.

Shared { name } is tied to the owner rather than any project. It survives runs and project deletion both. Projects naming the same name meet in the same space, and first use auto-grants it.

So if you want a file to survive deleting the project, that is the scope argument and nothing else: Project means no, Shared means yes. Changing that one argument is the whole knob.

Reach for Shared when it is a dataset the owner reuses across projects, a model they paid to build, or anything they would be upset to lose while tidying up.

The verbs

let storage = ctx.storage(StorageScope::Project);

storage.put(bytes, mime, filename, keep).await?;
storage.put_stream(stream, mime, filename, keep).await?;
storage.put_response(resp, what, mime, filename, keep).await?;  // straight from an HTTP response
storage.put_from_url(url, filename, keep).await?;               // the runtime fetches it
storage.identified("whatsapp:m1").put_from_url(url, None, None).await?; // once per identity, see below

storage.get(&handle).await?;
storage.get_bytes(&handle).await?;
storage.get_range(&handle, range).await?;

storage.delete(&handle).await?;
storage.list().await?;

storage.keep(&handle, KeepTtl::Default).await?;
storage.presign(&handle, ttl_secs).await?;      // a temporary link, always fetchable from your body
storage.public_link(&handle, ttl_secs).await?;  // an internet-reachable link, or None

A stored file arriving on one of your inputs already carries a url inside its marker, minted for this firing (an hour): the runtime links every file input before your body runs, so a body that hands the value to something that only fetches URLs needs no call of its own. The link is the internet-reachable one when the install serves one (a public address, or a bucket declared public), so a provider can fetch it too. Otherwise it is signed for the cluster’s own address: your body can fetch it, nothing outside can, and a node that hands a file to something outside asks public_link and inlines the bytes when it answers None. It is stripped from everything you emit, park, or memoize, so the stored form is what travels and the journal never holds a link.

Scope governs writes and lists. Key-addressed verbs act on the key’s own scope, so reading a handle works regardless of which scope you asked for.

If you pull a thing by a stable id (a message, a document at a provider), name it: .identified("<service>:<id>") before the put. The same identity in the same scope is then one file, however many runs ask for it: a put_from_url asks the store first and fetches nothing when the file is there, and two runs fetching at once cannot both land (the second sees a conflict and retries). Pair it with Project scope so the copy outlives the run that first pulled it. The identity is a label, scoped to the scope you put in; choose one that names the source, never the content.

The keep rule

An Execution-scoped file your node emits must be kept.

Every Execution write takes keep: Option<KeepTtl>. None means the file is swept shortly after the run ends.

That is right for scratch and wrong for anything you pulse downstream, because an emitted reference lands in the journal and renders in the editor long after the run, where a swept file shows up as “media expired”. So:

  • A node producing a user-facing artifact (a generated image, synthesized speech, received media) passes Some(KeepTtl::Default). That is 30 days, and every access bumps the clock, so artifacts still in use never expire while abandoned ones age out.
  • A node whose file is cheaply re-fetchable, such as a plain download, may expose a keep boolean config input defaulting to off, and pass keep.then_some(KeepTtl::Default), letting the user decide.

The KeepFile node extends or pins an execution file’s lifetime after the fact. Kept or not, an execution file stays walled to its run; with scope: project the node instead copies it into the project scope and emits the copy, the only form a later run can read.

Files from the graph

A user-supplied file arrives as an ordinary typed input.

"inputs": [
  { "name": "image", "type": "Image", "required": true }
]

The type drives the editor’s file filter and is what gets written into source; a "widget": { "kind": "file_drop", "accept": "image/png" } narrows it further. Your node reads it with ctx.inputs.get::<FileHandle>("image")?.

What lands in source is one clean line:

send = TelegramSendMedia {
  file: @asset("assets/photo.png", Image)
}

The asset sync runs before every build and uploads what the code references. Current source files stay; replaced or removed uploads expire after 30 days without access. Your node never sees any of it: at run time the value is a normal media value, and get and get_bytes read its bytes whichever handle it carries.

Media in typed values

For converting whole typed values at a provider boundary, see Custom types.

The marker stays inside weft

The __weft_image__ / __weft_audio__ / __weft_blob__ wrapper is how a file travels between nodes: it carries the storage key the runtime reads by. Anything you hand to something that is not weft (a provider’s request body, a bridge’s action payload, a form spec a browser renders, a live item) gets the plain thing that consumer reads: a URL string, a data: URL, or a plain { url, mimeType, filename } object. externalize does this for a typed value, public_link and presign for one file. Wrapping a link in a marker and sending it out puts weft’s internal shape in an external contract, and the consumer, which reads value.url, shows nothing. The form image field did exactly that once, and the tasks app rendered “(no image)” over a link that worked. A link you hand out also has a life, so never store one: a form parks the stored file itself, and the person who opens it gets a link minted at that moment through the signal-token files door, however long the form waited.

Reaching files outside the editor

The stored files are addressable independently of the editor:

weft files ls
weft files inspect <key>
weft files download <key>
weft files rm <key>
weft files usage

So data a project wrote in Shared stays reachable after the project is gone.

Surviving a restart

A node body can touch the outside world in a way that survives its worker dying and a fresh worker picking the execution back up hours or days later. Four things it can reach for, and one of them is doing nothing special.

PrimitiveFor
ctx.register_signal(kind)a trigger declaring a persistent endpoint, cron, or feed
ctx.await_signal(kind)a mid-flow wait for a human or an external event
ctx.run("name", closure)non-deterministic or side-effecting work between waits
nothingpure logic, branching, computing from journaled values

ctx.await_signal

Parks this firing until the signal fires. The worker exits while parked. A fresh worker spawns when the fire arrives.

use weft::signal::Form;

async fn run(&self, ctx: ExecutionContext) -> WeftResult<()> {
    let answer = ctx.await_signal(Form {
        form_type: "human-query".into(),
        schema: my_form_schema(),
        title: Some("Approve?".into()),
        description: None,
        consumer_kind: Some("human_in_the_loop".into()),
    }).await?;

    ctx.pulse_downstream(NodeOutput::new().set("answer", answer)).await
}

Other firings of the same execution keep going. Only this one parks.

The thing to understand: the body re-runs from the top

When the fire arrives, the next worker re-runs the whole body from the first line. The await_signal call that parked last time returns instantly with the journaled value, and execution continues past it.

So a body with two waits runs three times across its life:

  1. First dispatch. Hits wait 0, suspends.
  2. Approval fires. Body re-runs from the top. Wait 0 returns its value. Logic runs. Wait 1 suspends.
  3. Confirmation fires. Body re-runs from the top. Both waits return their values. The body completes.

Everything between the waits ran three times.

ctx.run: reusing a saved result

Anything between waits that is non-deterministic or has a side effect must be wrapped.

async fn run(&self, ctx: ExecutionContext) -> WeftResult<()> {
    // Once saved, every replay returns the same request identifier.
    let idem = ctx.run("idem", || async {
        Ok(json!(uuid::Uuid::new_v4().to_string()))
    }).await?;

    let approval = ctx.await_signal(approval_spec()).await?;

    // Billing must recognize `idem` and refuse to charge it twice.
    let http = ctx.http();
    let receipt = ctx.run("call_billing", || async {
        let resp = http.post("https://api.billing/charge")
            .json(&json!({ "idem": idem, "approved_by": approval["who"] }))
            .send().await.node_err("charging the card")?
            .json::<serde_json::Value>().await.node_err("reading the receipt")?;
        Ok(resp)
    }).await?;

    ctx.pulse_downstream(NodeOutput::new().set("receipt", receipt)).await
}

Once the result is saved, later replays return it without invoking the closure. Each loop iteration has its own saved results. If the worker dies after an external action succeeds but before saving its result, the closure can run again. ctx.run alone cannot prevent that duplicate action.

The billing example depends on the billing service treating repeated requests with the same idem value as one charge. The identifier is saved before the billing call begins, so a replay reuses it. Check the receiving service’s contract; merely sending an identifier does not make a request safe to repeat.

The name is only for traceability in the journal. The runtime keys on call-site order, so two ctx.run calls may share a name and you may rename any call freely.

What needs wrapping

Safe between waitsWrap in ctx.run
pure logicrand::random(), Uuid::new_v4(), Instant::now()
branching on values from waits or runsnetwork calls, database writes, file I/O
reading ctx.inputsenvironment reads that might change
anything that could differ between two runs of the same code

The replay rule

The sequence of ctx.await_signal and ctx.run calls must be identical across every replay.

The runtime checks each call against the journaled sequence, so a mismatch fails the node loudly rather than desyncing quietly.

So do not make the number or order of those calls depend on anything that could change. Branching between them is fine as long as both arms make the same calls, or neither does.

Emitting before a wait is refused

Emitting on, or closing, an output port before an await_signal is refused outright, because the resume replays from the top and would touch the port twice.

Emit after all your waits. Or, for a node that wants to stay warm and interactive, use a bus instead of suspending.

ctx.register_signal

The trigger form, covered in Writing a trigger. register_signal declares a persistent entry point that spawns a fresh execution per event; await_signal parks the current firing. Which one you get is the method you called, never a flag on the kind.

Worker lifetime

A worker pod dies whenever every live firing is parked.

A suspended firing holds no worker, so ten thousand pending approvals cost no compute, just journal rows. When a fire arrives, a fresh worker spawns, folds the journal, and re-runs every node that has a fire to deliver, with each prior wait and run returning instantly from the record.

Why this matters even without a wait

Weft’s crash guarantee is at-least-once for a node whose completion never reached disk. The mechanism is in The journal.

So a body with no await_signal anywhere in it is still not exempt: if its worker dies mid-node, the replacement runs it again from the top. The rule is to use ctx.run for saved results and require the receiving service to prevent duplicate actions when repeating the work would be harmful.

A loop with a wait inside a node

A loop in your body containing an await_signal means the node is orchestrating, which is the graph’s job, so reach for a weft Loop and let each iteration fire a node that does one thing.

If a genuine constraint forces the shape on you anyway, here is what you are signing up for. A resume re-runs the whole body from the top, so your loop restarts at iteration zero. Past await_signal and ctx.run calls replay instantly, but every other call runs for real again, once per replay. So an unwrapped paid API call inside that loop is charged again on every human response: wrap every side-effecting call in ctx.run.

Writing a trigger

A trigger node writes two bodies and never inspects any phase. The engine calls the right one.

use weft::signal::{LiveConnectionConfig, Route};

#[async_trait]
impl Node for MyTriggerNode {
    async fn setup_trigger(&self, ctx: ExecutionContext) -> WeftResult<()> {
        let common = LiveConnectionConfig::from_node_fields(ctx.inputs.object()?)
            .map_err(weft::node_error)?;
        ctx.register_signal(Route { common }).await
    }

    async fn run(&self, ctx: ExecutionContext) -> WeftResult<()> {
        // Runs once per external fire.
        let value: serde_json::Value = ctx.wake.get("value")?;
        ctx.pulse_downstream(NodeOutput::new().set("value", value)).await
    }
}

If you set features.isTrigger: true in the metadata, the engine calls setup_trigger at registration time instead of run.

The two value sources at fire time

ctx.inputs is a snapshot of what the trigger’s inputs held when it registered, saved alongside the registration. Nothing upstream runs again when the trigger fires: a trigger’s inputs are frozen at activation.

ctx.wake is this fire’s event payload, as a bag of named fields: the HTTP body, the SSE event JSON, the form submission, the timer info.

A trigger that forwards the whole payload reads it at once:

let data = serde_json::Value::Object(ctx.wake.object()?.clone());
ctx.pulse_downstream(ctx.fan_declared(&data)).await

ctx.wake.object() fails loudly when the fire delivered no keyed record, so a broken delivery can never pass as an empty one.

The signal kinds

Each is a struct in weft::signal. You construct one and pass it to register_signal.

Two families, pointing opposite ways. SocketListen and Socket sound alike and are easy to swap by mistake: SocketListen dials out to a service, Socket is what an outside caller dials into.

Outbound event sources

The listener reaches out to something and fires a fresh execution per event.

KindWhat it doesUse for
SseSubscribe { url, event_name }holds a one-way Server-Sent-Events stream, fires per matching event. Receive only.a service pushing an SSE feed
PollEndpoint { url, interval_secs, method?, body?, format?, delta? }hits a URL on a timer, fires with the body, or with delta once per new item. method: Post plus body polls a query endpoint; format: Feed parses RSS and Atom into { "items": [...] }. No held connection.a “give me what’s new” endpoint: a bot’s getUpdates loop, a database query, a feed
SocketListen { url, minted, handshake?, heartbeat?, heartbeat_secs }holds a bidirectional WebSocket alive, sends an optional handshake on open and an optional heartbeat on a schedule, fires per inbound framea gateway that needs login and keepalive or it drops you. The op-code protocol is yours, expressed as the literal handshake and heartbeat frames.
StreamListen { address, framing, script, replies?, heartbeat?, fire }holds a raw TCP or TLS pipe for services speaking neither HTTP nor WebSocketany wire protocol: IMAP, MQTT, Redis, XMPP

StreamListen is the kind that makes “no per-service engine code” literal. It runs a declared connect dialogue (send a frame, wait for a matching line), cuts the byte stream by a declared framing (delimiter, length prefix, or varint prefix), and fires every unit matching the fire pattern. Text frames interpolate {placeholders} from the attached connection, so credentials ride the dialogue without sitting in the spec.

The watch is the trigger. The fired body then talks the protocol properly itself, with a real library, where code is unrestricted. catalog/email/receive_email is the worked example, watching a mailbox over IMAP IDLE.

Inbound live-caller endpoints

An outside caller dials in and holds the connection; nodes talk back through ctx.caller().

KindFor
Route { common }an HTTP route people call; a node replies once or streams
Socket { common }an inbound WebSocket; a node holds a two-way conversation

Both share LiveConnectionConfig, built from the node’s merged values with LiveConnectionConfig::from_node_fields(ctx.inputs.object()?): the route pattern, the method, the body shape, the auth connection, and the suspension defaults. The caller’s opening request arrives as the fire payload, so a trigger of this shape reads it off ctx.wake (the shipped Route and Socket fan it onto their ports).

The wire protocol is the kind, not a config field. The runtime derives it from which struct you passed, which is why there is no protocol: knob to set wrong.

Always present

Timer { spec } for cron, after, and at. Form { .. } for a human submission, normally used with await_signal rather than here.

Reacting to provider events

A trigger that fires when something happens at a connected service registers one kind, whatever the service is and however its events travel.

async fn setup_trigger(&self, ctx: ExecutionContext) -> WeftResult<()> {
    let account: Access = ctx.inputs.get("account")?;
    ctx.register_signal(ProviderEvents::new(&account, "messages", vec![
        Predicate { field: "type".into(), op: PredicateOp::Eq,
                    value: Some("message".into()) },
        Predicate { field: "channel".into(), op: PredicateOp::Eq,
                    value: Some(channel) },
    ]))
    .await
}

The parts, and where each one’s knowledge lives:

  • The connection says whose events. Whether weft holds an outbound line to the service or takes its pushes at a public address is decided by the runtime from what the connection can do, and your code is the same either way.
  • The topic ("messages") names one of the event topologies the service’s recipe declares. One service may declare several; slack declares messages, reactions, interactions and files.
  • The filters are predicates over the topic’s named fields, evaluated before anything fires, so a non-matching event costs no execution. Translate the node’s plain config inputs into predicates here; anything the grammar cannot express runs as ordinary code in run, after the fire.
  • Topics whose subscribe call needs node-supplied values, such as the Drive file to watch, pass them with .with_params(...).

Everything mechanical is the runtime’s: holding the socket, acknowledging frames, verifying push signatures, and subscribing, renewing and stopping provider-side watch channels.

Registration fails loudly when the trigger cannot be served: the connection lacks a value the dial-out transport needs, or the install has no public address for a push-only service. The error names the fix, and Events from a service is the user-facing side of it.

The scope decision

Which scope a trigger subscribes at is a node split: one node, one process. ProviderEvents::app_wide() is how the app-owner node states which one it is.

A trigger that just fires

The simplest kinds take their fields directly:

use weft::signal::SseSubscribe;

ctx.register_signal(SseSubscribe {
    url: events_url,
    event_name: "message.received".into(),
}).await?;

register_signal returns once the dispatcher acknowledges. Any public URL is derived from the signal’s own path, so nodes never get one handed back and never have to store one.

Talking to a live caller

Suspension is a disconnected wait: the worker parks and dies.

When someone hits a Route (HTTP) or Socket (WebSocket) trigger, the dispatcher routes that held connection to one worker, which stays alive on the open socket for the life of the request. Any node downstream of the trigger can talk back over it.

The catalog answers most of it without Rust: Reply, Stream and Close (Building an API). This page is the handle those nodes are built on, for a node of your own: two readers of one socket, a body assembled from many chunks with logic between them, anything the graph cannot say.

A live caller is not durable: the connection is pinned to that worker and dies with it. Work that has to survive a restart goes through suspension instead.

Getting the handle

let http = ctx.http_caller().await?;   // fails loud if this run has no HTTP caller
let ws = ctx.ws_caller().await?;
let either = ctx.live_caller().await?; // CallerHandle::Http(_) | CallerHandle::Websocket(_)

Those are the one-call forms. Each folds the whole chain (a caller is present, it is the right protocol, the connection barrier passed) and fails loudly naming the trigger to wire it under.

For a node that branches without waiting:

ctx.caller()             // Option<CallerHandle>, an enum over the two protocols
ctx.caller_request()?    // Arc<LiveRequest>: what the caller sent to open the exchange
ctx.is_api_call()
ctx.is_websocket()
ctx.caller_data_type()   // the declared shape: Json, Text, Bytes

is_api_call and is_websocket are separate questions because there are three answers, not two: HTTP, WebSocket, or nobody on the line at all.

CallerHandle is protocol-typed, so the type is honest about what each side can do. An HTTP caller has no send; a WebSocket caller has no respond.

The request

Both protocols carry what the caller sent to open the exchange, as the gateway matched and gated it:

let req = ctx.caller_request()?;   // also handle.request()
req.method                          // "POST"
req.path                            // "chat/room7", as called, no tenant, no leading slash
req.params                          // the route's {name} captures
req.query                           // the query string, parsed
req.headers                         // Vec<(name, value)>; req.header("content-type") reads one
req.caller                          // Some(identity) when the route has an auth, else None

On HTTP the body sits beside it: http.request_parts()? is { request, body }, the body decoded per the trigger’s dataType (Json, Text, Bytes). An empty JSON body decodes to null, which is what a bodiless GET carries.

HTTP

http.write(chunk).await?                 // stream a chunk
http.write_with(head, chunk).await?      // the first chunk, with the status and headers
http.respond(body).await?                // the final body, 200
http.respond_with(head, body).await?     // the final body under this head
http.close().await?                      // end the response (204 if nothing went out)
http.close_with(head).await?             // end it bare under this head (a 404 with nothing to say)
http.wire_started()                      // has anything gone out yet?

The status line and headers are the program’s to set, and they are set by the FIRST outbound item. ResponseHead { status, headers } rides write_with, respond_with or close_with; a plain write or respond first commits a 200 with a content type matching the chunk’s shape. A head given after the first item is HeadAlreadySent, an error, never a silent drop. respond, close and their _with forms are terminal: the first one wins and a second errors loudly.

Nothing reaches the caller before your first item. The worker holds the response until then. If the run ends and the program never wrote, the caller gets a 500 whose body says the run ended without answering; if it wrote but never closed, the body simply ends. A socket the run leaves open gets a normal close frame.

WebSocket

ws.send(chunk).await?
ws.recv_next().await?       // Some(msg), or None when the stream ends
ws.receive().await?         // the typed-error form of the same read
ws.request(chunk).await?    // send, then await one reply
ws.close().await?           // a normal-closure frame (1000)
ws.close_with(CloseReason { code: 4001, reason: "done".into() }).await?

A head handed to a WebSocket connection is dropped: its only head was the upgrade.

Both protocols share is_connected() and one ensure_connected().await? barrier, which waits for the caller’s socket to actually attach before you talk into it.

A read is unbounded. A node may wait minutes or hours for the caller’s next message; only a disconnect or the trigger’s session cap ends the wait.

The loop

use weft::caller::{InboundMessage, OutboundChunk};

async fn run(&self, ctx: ExecutionContext) -> WeftResult<()> {
    let ws = ctx.ws_caller().await?;

    while let Some(msg) = ws.recv_next().await? {
        let v = match msg {
            InboundMessage::Json(v) => v,
            InboundMessage::Text(s) => Value::String(s),
            InboundMessage::Bytes(b) => json!({ "bytes": b.len() }),
        };
        ws.send(OutboundChunk::Json(json!({ "echo": v }))).await?;
    }

    let _ = ws.close().await;
    ctx.pulse_downstream(NodeOutput::new().set("done", true)).await
}

recv_next yields Some(msg) per message and Ok(None) when the stream ends for good: the caller disconnected, the session timed out, or it expired. A consumer that fell behind comes back as an Err instead, because that gap is resumable and must not be read as the end, so the language does the end-of-stream classification for you and a real failure propagates through ?. When you need to distinguish the exact outcome, receive() returns the typed error so you can match every case.

Two readers, no race

Inbound on a WebSocket is broadcast and forward-only, the same model as a bus. ws.receive() reads messages arriving after you got the handle, with the position pinned at the moment you obtain it, so a reader that attaches after a message was sent still sees it.

Every reader has its own position, so a responder and an observer can both run off one socket. This is the one thing the catalog’s Socket trigger does not give you: its inbound stream has one consumer.

Reading history

Mint a positioned cursor, the same concept as a bus:

ws.cursor_from_start()        // everything still retained in RAM
ws.cursor_at(offset)
ws.cursor_including_last()    // forward, plus the single most recent message
ws.now_offset()
ws.retained_floor()

Offsets are absolute over the connection’s whole life, so a saved offset keeps naming the same message as the retention window moves.

A cursor reads the in-RAM window only. When its offset has been trimmed out, the read returns FellBehind { oldest_resident } and the cursor is moved there, so the next read resumes at the earliest message still retained.

Lifetime: tied to the caller, or surviving it

One field on the trigger, outlivesCaller, is the whole lifetime axis.

Off (the default). The run is tied to the caller, so a disconnect cancels it and a node that hits a durable await_signal holds the worker briefly and then the run is killed. This is what a request-response API wants.

How a caller who leaves is noticed depends on whether anything can be written to them. If the program is still holding the response head (nothing has gone out yet), the connection itself carries the news: the handler is dropped, the body’s receiver goes with it, and the run ends there. Once the head is out, a write is the only way to find out, so on a framed stream (sse, ndjson) the worker writes a filler the format ignores every heartbeat, and a hang-up a proxy in the middle would otherwise hide behind a quiet feed is found by that write.

A raw body has no filler, because every byte of it is the program’s. That feed is watched anyway, by the connection itself: while it is quiet the machine asks the caller whether it is still there, in a packet carrying no payload at all, so the program’s byte stream is untouched. A caller who answers resets the clock; one that has vanished does not, and the connection fails. callerSilenceSecs on the trigger is how long that silence may last, thirty seconds unless the author says otherwise.

Both halves matter, and they cover different disappearances. The filler puts bytes on the wire, which is what catches a caller that vanishes with data in flight. The machine’s own questions catch one that vanishes while everything is quiet, which is the only thing a feed with nothing to write can rely on.

One shape is beyond both, and it is the only thing refused: a stream on a run that MAY OUTLIVE ITS CALLER. Noticing the caller left changes nothing there, because the run does not end with them, and a stream ends only when its bus closes, so nothing would ever end it. That fails before the first chunk, naming both ways out: maxSessionSecs on the trigger, or turning off the setting that lets the run outlive its caller. The refusal lives in the connection itself, so a node that builds its own framing meets it too, and the compiler catches the catalog’s Stream node before anything runs.

maxSessionSecs is the only deadline weft puts on a live exchange, and it is off unless the author sets it. There is deliberately no default: it ends a connection on the clock whatever the caller is doing, which is right for the one shape above and wrong everywhere else. So weft says what is missing instead of picking a number behind your back.

On. The run may suspend and resume later without the caller, becoming a background job. A disconnect does not kill it, and further sends go into the void.

Testing a node that talks to a caller

The fake rig attaches a scripted caller:

let conn = FakeCallerConnection::connected(config);   // CallerRuntimeConfig for the protocol
conn.set_handshake(request);                          // the LiveRequest
conn.set_http_body(InboundMessage::Json(json!({ "text": "hi" })));
rig.attach_caller(conn.clone());
let outcome = rig.run(&MyNode, json!({})).await.ok()?;
conn.heads();          // every ResponseHead handed over
conn.chunks();         // every chunk, streamed and final
conn.close_reason();   // the close frame, if the node closed

catalog/api/*/tests.rs are worked examples, one per shipped node.

Worked examples

Two ctx-driven nodes live in the end-to-end test fixtures, so they run on every pass of the suite:

  • crates/weft-e2e/fixtures/web_trigger/nodes/http_responder
  • crates/weft-e2e/fixtures/live_chat/nodes/ws_echo

Streams and buses in Rust

The graph-level story is in Live channels. This page is the node author’s side.

Producing a stream

A Generator[T] output accepts repeated emissions. Emit items with the call you already use, keep your state in ordinary local variables, and the stream ends when your body returns.

// metadata.json: { "name": "rows", "type": "Generator[Row]" }

for row in read_rows(&file) {
    if keep(&row) {
        ctx.yield_downstream(
            NodeOutput::new().set("rows",
                serde_json::to_value(row).node_err("encoding the row")?)
        ).await?;
    }
}
// body returns: the engine closes the stream

An open connection, a paging cursor, a decoder’s state: all of it is just locals across the loop, because the producer body stays alive for the whole stream.

If your body returns an error instead, the stream closes as failed, and the consumer’s pull gets your error rather than a clean end.

Yield or pulse

ctx.yield_downstream(output).await?;    // returns once the item was pulled
ctx.pulse_downstream(output).await?;    // returns immediately, item buffers

yield_downstream is lock step. Your body waits on each item until the consumer takes it, so the buffer never grows past one and you always know your items landed.

pulse_downstream runs ahead. Items buffer on the edge, bounded at 4096 by default, and an emission past the bound fails your node loudly rather than growing until the pod runs out of memory.

A producer that deliberately runs far ahead raises its own bound:

ctx.set_max_buffered_items("rows", 100_000)?;

Only legal on a Generator output, refused for 0, and it applies to the emissions that follow the call. Before or between emissions both work.

Which one to use

Ask what should happen if the consumer stops early.

Fire-and-forget items are dropped harmlessly when the consumer finishes, which is usually what you want for a stream the consumer is allowed to abandon.

A yielded item whose consumer finishes without taking it fails your body, which is what you want when your producer must know its items landed.

Getting this backwards produces a confusing failure at the end of a run that otherwise worked.

Ending early

ctx.close_port("rows").await? ends the stream, legal after any number of yields. See Explicit closure.

Consuming a stream

The consumer reads the stream from the input bag like any other input. Its node fires once, on the first item, and pulls the rest itself.

let rows = ctx.inputs.get::<Generator<Row>>("rows")?;
while let Some(row) = rows.next().await? {
    // one item at a time
}

On the handle:

CallAnswers
next()waiting take: Some(item), None on a clean end, the producer’s error on a failed one
try_next()no wait; distinguishes “nothing buffered yet” from “finished”
drain()the whole stream as a Vec, erroring on a failed end rather than handing back a truncated list
end()the end marker, once the producer’s side ended

A pull can also become impossible to satisfy, when every remaining node is waiting on one of the others. The engine’s stuck check spots that and fails the stream, so it reaches you through ? like any producer failure rather than hanging.

An empty stream still runs your node. A producer that closes without yielding delivers a stream whose first next() answers None, so your post-loop code runs the same over zero rows as over one.

yield_downstream also works on an ordinary port, where it waits for the consumer to be dispatched: Waiting for the value to be taken.

Buses

One node creates the channel and emits its marker; others resolve the marker and exchange messages.

// Producer. The returned guard closes the bus when dropped.
let bus = ctx.open_bus("channel", BusOptions::default(), "host").await?;
bus.send("msg", json!("hello")).node_err("sending on the bus")?;
drop(bus);   // the close IS the end-of-stream signal

// Consumer that participates: registers, and closes on exit.
let bus = ctx.join_bus("channel", "guest")?;
let mut cursor = bus.cursor();
while let Some((from, value)) =
    cursor.next_json("msg").await.node_err("reading the bus")? {
    // ...
}

// Observer that must NOT close the bus (a debug tap).
let bus = ctx.bus_from_input("channel")?;

The producer ritual (create, emit the marker, register a name, close on every exit path) is one call, and so is the consuming side. A bus left open parks its readers forever, so the API closes it for you.

BusOptions

Declared at creation, read back by every consumer off the handle or the marker.

OptionMeaning
payloadJson (default) for chat-shaped traffic, or Bytes for media frames, raw end to end with no base64 between nodes
metacreator-declared stream metadata, such as sample rate and encoding, read via bus.meta()
ephemeralkeeps payloads out of the journal entirely; a consumer that falls behind resumes at the oldest frame still in the window
windowhow many frames the bus keeps for a consumer that falls behind, 64 by default
journal_windowhow coarsely the trail is recorded: one row per bus per window, one second by default

payload is frozen at creation and the wrong shape is refused loudly. journal_window affects only how the trail is stored, never what travels the bus: what a window row contains is in The journal.

These last three, the mode, the window and the aggregation, are the same decision a live caller conversation makes, answered by the same code, so a bus and a route cannot disagree about where a payload gets trimmed or about what ephemeral means. If you change one, you have changed both.

Keep bus work on your own task

Nothing enforces this one, so you have to hold it yourself. Do all of a bus’s reads and waits directly in your node body, and never move a bus handle or cursor into a tokio::spawned background task.

The engine decides “every node is stuck, close the buses” by tracking whether each node execution is waiting or working, and it assumes those waits happen on the node’s own task. A wait on a task you spawned is invisible to that accounting, so the engine can wrongly tear down a live conversation, or hang.

If you need concurrent work, model it as another node and exchange with it over the bus.

Cancellation

Every execution carries a cancellation flag. When the user clicks stop, or a project is deactivated, or the dispatcher tears an execution down, the flag is set. The engine’s drive loop sees it at the next iteration and exits, dropping the task set holding every in-flight node future, which aborts each one at its next .await.

Ordinary async Rust is cancellable instantly, with no node-side code. A node has to do something unusual to escape cancellation.

The default

async fn run(&self, ctx: ExecutionContext) -> WeftResult<()> {
    let resp = ctx.http()
        .post("https://api.example.com/v1/messages")
        .json(&body)
        .send().await.node_err("posting to the API")?
        .json::<ApiResponse>().await.node_err("decoding the reply")?;

    ctx.pulse_downstream(NodeOutput::new().set("response", resp.text)).await
}

Cancelled mid-call, the future at .send().await is dropped, the client closes the underlying socket, the request is cancelled in flight, and the function exits. Nothing further is billed and nothing further runs.

The same holds for retry loops, streaming receives, and anything with regular awaits: every iteration is a cancellation point.

Tokio cancels every primitive that respects future drop, which is most of them: HTTP through reqwest, databases through sqlx, sleeps, file I/O, WebSocket streams, channel receives.

The quick reference

Node behaviorCancellable?What you do
async HTTP, database, sleep, file I/Oyes, instantlynothing
async with retriesyes, instantlynothing
streaming receiveyes, instantlynothing
suspended via await_signalyes, engine pathnothing
a measured call on a connectionyes, instantlynothing; the metering settles on its own
a subprocessthe process leaks.kill_on_drop(true)
CPU-bound spawn_blockingthe thread leakspass the flag, poll it
a resource needing explicit cleanupbest efforttokio::select! on the flag

Reaching the flag

let flag = ctx.cancellation();          // Arc<CancellationFlag>

flag.is_cancelled()                     // sync atomic load; cheap in tight loops
flag.cancelled().await                  // a future, for tokio::select!
flag.cancelled_err().await              // the same wait, resolving to the
                                        // error to return with `?`

The flag is persistent: once cancelled, every later is_cancelled() returns true and every new cancelled() future resolves immediately, so there is no window in which you can miss one.

The engine aborts your future as soon as it observes the cancel, so a cancelled() branch in your body only runs if it wins that race. Treat that cleanup as best effort.

Paid calls need nothing from you: the metering runs below your future and resolves an interrupted call’s real cost on its own.

Subprocesses

Dropping a tokio::process::Child does not kill the underlying process. It keeps running, forever, with nobody watching it.

// BAD: the future drops, `python` keeps running.
let mut child = tokio::process::Command::new("python")
    .arg(script_path)
    .spawn().node_err("starting python")?;
let status = child.wait().await.node_err("waiting on python")?;

One line fixes it:

// GOOD: cancel drops the future, drop kills the process.
let mut child = tokio::process::Command::new("python")
    .arg(script_path)
    .kill_on_drop(true)
    .spawn().node_err("starting python")?;
let status = child.wait().await.node_err("waiting on python")?;

For a graceful shutdown, letting the subprocess flush before it dies:

let mut child = tokio::process::Command::new("python").arg(script_path)
    .spawn().node_err("starting python")?;
let cancel = ctx.cancellation();

tokio::select! {
    status = child.wait() => Ok(format(status.node_err("waiting on python")?)),
    err = cancel.cancelled_err() => {
        let _ = child.start_kill();                    // SIGTERM
        let _ = tokio::time::timeout(
            std::time::Duration::from_secs(2),
            child.wait(),
        ).await;                                        // then drop kills it
        Err(err)
    }
}

Blocking CPU work

Dropping a JoinHandle from spawn_blocking does not kill the worker thread. The thread runs the closure to completion and only its result is discarded.

From the user’s point of view the cancel “worked”: the graph stopped, the loop exited. Meanwhile a core is still pinned.

If the work happens in chunks, pass the flag in and check it between them:

let cancel = ctx.cancellation();
let result = tokio::task::spawn_blocking(move || {
    for chunk in chunks_of(image) {
        if cancel.is_cancelled() {
            return Err(weft::node_error("cancelled"));
        }
        process_chunk(chunk);
    }
    Ok(...)
})
.await
.node_err("the image worker")??;

The closure is ordinary blocking code, so it cannot .await and builds its error the plain way. .node_err on the outside handles the task itself dying; the second ? is your closure’s own result.

is_cancelled() is one atomic load, so check it as often as you like.

Cleanup on a held resource

let mut conn = open_connection().await?;
let cancel = ctx.cancellation();

loop {
    tokio::select! {
        msg = conn.recv() => {
            match msg? { Some(m) => handle(m), None => break }
        }
        err = cancel.cancelled_err() => {
            conn.send_close_message().await.ok();
            return Err(err);
        }
    }
}

Write this when you have something to do at the end: notify a peer, release a lock you hold externally, flush to disk. Otherwise the default abort path closes the connection at drop.

Suspension is a different thing

ctx.await_signal is not a tokio wait. The engine journals a suspension and the worker exits.

Cancelling a suspended execution goes through the dispatcher: it strips the wake registration, so an external event cannot resume a dead execution, and records the terminal event. Nodes using await_signal need nothing special for cancel.

Stopping other runs

A run can put a label on itself, and any run of the same project can stop every run carrying that label. You reach for it when a second message should cancel the answer to the first.

The shape you will want first

Somebody sends your assistant three messages in a row. Each message starts a run, and each run takes a few seconds to answer, because it waits on the language model. Without help, the person gets three answers.

If you are writing weft, two catalog nodes at the top of the program fix it, and you never touch Rust:

telegram = TelegramAccess

ask = TelegramReceiveMessage { account: telegram.access }

claim = TagRun { sender: ask.chatId }

stop = StopTagged {
  _should_flow: claim.done
  sender: ask.chatId
}

draft = LlmInference {
  _should_flow: stop.done
  ...
}

Every input you wire onto TagRun is a tag; StopTagged reads its targets the same way, and includeSelf: true on it takes the current run down as well. The two _should_flow wires are the order: tag, then stop, then the work.

If you are writing a node, the same two moves are two ctx calls:

async fn run(&self, ctx: ExecutionContext) -> WeftResult<()> {
    let sender: String = ctx.inputs.get("sender")?;
    ctx.tag_execution([sender.as_str()]).await?;
    ctx.stop_tagged(sender.as_str(), StopSelf::Keep).await?;
    ctx.pulse_downstream(NodeOutput::new().set("sender", sender)).await
}

Put that node first in the chain. Every run tags itself with the sender, then stops every earlier run carrying the same sender, and keeps going. The third message kills the second, which had already killed the first, and the third run is the only one that answers. No queue and no state of your own to write.

tag_execution

ctx.tag_execution(["user_7", "batch_a"]).await?;

Adds labels to the run this node is part of. Any node can call it, at any point, as often as it likes; tags add up, and tagging the same thing twice changes nothing. A tag is one to sixty-four characters of [A-Za-z0-9_-], the same rule a node’s _tags follows; anything else fails here, naming the character, before anything is written.

The two catalog nodes take any string and make a tag of it the same way: a value that already is one stays as written, anything else has its other characters replaced by _ and a short fingerprint of the original appended (49151@s.whatsapp.net becomes 49151_s_whatsapp_net- and sixteen hex characters), so two values that only differ in the replaced characters never share a tag. If you write a node that tags with a value it did not choose, do the same, or call the ctx with what you know is clean.

The tags show on the run: in the inspector’s footer, in the Executions view, and in weft executions.

stop_tagged

ctx.stop_tagged("user_7", StopSelf::Keep).await?;
ctx.stop_tagged("exp_3", StopSelf::Include).await?;

Stops every live run of this project carrying the tag. StopSelf says whether this run is one of them:

  • Keep: stop the others, keep running. This is the opening example: the newest message survives, the older ones die.
  • Include: stop them all, this one too. One run of an experiment finds the experiment is broken and takes the whole batch down; its own body ends cancelled at its next await, exactly as weft stop would end it.

A stop reaches a run whatever it is doing:

  • Running. The node in flight is told to stop the same way cancellation always works: an HTTP call in flight is dropped, a node waiting on the flag wakes with the cancel.
  • Parked on a person, a webhook, or a timer, with no worker alive. The thing that would have woken it is erased: the form is gone, the timer is gone. Answering the old form does nothing.
  • Waking up at that exact moment. The wake finds the run already dead and does nothing with it.

The call returns as soon as the stop is durably queued; the runtime carries it out. Do not write the next node to depend on the siblings being gone by the time it fires.

A stop never crosses a project: the tag is only looked up among your project’s own runs.

Two runs at once

Two messages from the same sender land a few milliseconds apart, and both runs say “stop the others, keep me”. Left alone, they would kill each other.

They do not, because of one rule: a run only stops runs that tagged themselves before it did. The runtime numbers every tag in the order it was written, and a Keep stop reaches only the numbers below the caller’s own. So the later of the two survives and the earlier one dies. A run that asks for a tag it never put on itself (a supervisor clearing a sender’s whole backlog, say) has no position of its own to compare against, so it reaches everything tagged so far.

Include has no ordering: every live run carrying the tag goes, whenever it tagged itself.

What a stopped run looks like

A stopped run ends with execution_cancelled, and the event says who did it: the run that asked and the tag that matched. In weft events <color> that is the reason= on the last line:

[1725370001] execution_cancelled   reason=Stopped by execution 9d3f8f4e-... (tag user_7)

Every node that was still running or waiting gets a node_cancelled with the same reason, and the graph prints it on the node in place of the usual “Cancelled by user”.

After a crash

A body that re-runs after a worker crash re-tags and re-asks. Both are safe: a repeated tag lands on the same row it landed on the first time, so the run’s place in the order does not move, and a repeated stop finds its earlier targets already ended and stops nothing new. Neither call needs the ctx.run wrapper that Surviving a restart puts around work that must not happen twice.

Infrastructure nodes

Some capabilities need a long-running process the user cannot easily run themselves: a WhatsApp bridge holding a phone session, a headless browser, a local model server, a database.

Weft calls those infra nodes. The node returns a typed spec describing what should run, the supervisor compiles it to Kubernetes manifests and applies them, and the node talks to the running pods over HTTP at fire time.

You never write YAML. You build the spec with typed Rust structs, and the compiler turns it into Deployments, Services, PVCs, NetworkPolicies, and autoscalers, stamping every label it needs.

Two methods

An infra node sets requires_infra: true in its metadata and implements two bodies.

provision_infra(ctx, input) -> InfraSpec returns the desired shape. It emits no pulses, it just describes what should run, and its context carries project_id, node_id, namespace and tenant_id.

run(ctx) is the node’s actual logic. By the time it executes, the infrastructure is applied and it can resolve its endpoints.

Return the same spec every time

provision_infra runs on weft infra start and weft infra upgrade, and on nothing else. The supervisor compares what you returned against what is already applied: identical, it does nothing; different, it changes the cluster.

So build the spec only from your inputs and your node’s identity. No clocks, no random values, no generated passwords.

If your service needs a password, the container generates it on first boot and keeps it on its own volume, and run asks the container for it. A password in the spec would be a different password on every start, while the container keeps answering to the first one, and no restart would ever fix it.

async fn provision_infra(&self, _ctx: InfraProvisionContext, _input: ValueBag)
    -> WeftResult<InfraSpec>
{
    const PORT: u16 = 8090;
    Ok(InfraSpec {
        units: vec![Unit {
            name: "bridge".into(),
            on_upgrade: UpgradeBehavior::Recreate,
            containers: vec![
                Container::new("whatsapp", Image::Local { name: "bridge".into() })
                    .with_env(vec![EnvEntry::Literal {
                        name: "PORT".into(), value: PORT.to_string() }])
                    .with_ports(vec![ContainerPort {
                        name: "http".into(), port: PORT, protocol: Protocol::Tcp }])
                    .with_readiness(Probe::http("/health", PORT).with_initial_delay(5)),
            ],
            ..Default::default()
        }],
        endpoints: vec![Endpoint {
            name: "api".into(), unit: "bridge".into(), container: "whatsapp".into(),
            port: "http".into(), expose: Expose::ClusterInternal,
        }],
        ..Default::default()
    })
}

The spec

InfraSpec has all-defaulted fields, so InfraSpec::default() is valid.

FieldWhat it holds
unitspod templates. Most nodes have one.
volumesPVCs, emptyDirs, mounted ConfigMaps and Secrets
configSecrets and ConfigMaps to create, inline or by reference
endpointsnamed ports exposed through Services
accessnetwork policy: ingress and egress. Default is workers in, internet out.
lifecycleterminate policy, including which PVCs to preserve

Unit

One pod template, and the operational unit: each has its own status and its own stop behavior.

FieldMeaning
namerequired
kindDeployment (default), StatefulSet, DaemonSet, Job
containers, init_containers, pod_optionsthe pod’s contents
scalingreplicas, plus an optional autoscaler. Per unit.
on_upgradeRolling{...} (default) or Recreate. Honored for Deployments.
on_stopScaleToZero (default) or NoOp. See lifecycle below.
healthper-unit flaky and recovery windows. Unset means the supervisor’s default of 30 seconds.

Container

No Default, because the image is mandatory. Build with Container::new(name, image) and chain:

.with_env .with_ports .with_resources .with_mounts .with_readiness .with_liveness .with_startup .with_command .with_args .with_security_context .with_pre_stop

pre_stop is a Kubernetes preStop hook. Weft calls no Rust callback at stop time; graceful shutdown lives entirely in the container.

The rest

Image is Image::Local { name }, built from a directory listed in the node’s images and hash-tagged by the CLI, or Image::Upstream { reference } such as "postgres:18".

Endpoint is name, unit, container, port (the named container port), and expose: ClusterInternal (default), TenantPublic{path}, or NodePort{port}. The unit-container-port chain is validated at compile time.

Volume is Persistent { size, storage_class?, access_modes }, preserved across stop and upgrade and deleted on terminate unless listed in preserve_pvcs, or EmptyDir, ConfigMap, Secret.

Access is ingress rules (FromWorkers default, FromNode, FromInternet, FromCidrs, FromLabel) plus egress (ToInternet default, ToNode, ToCidrs), compiled to one NetworkPolicy on top of the namespace baseline.

A bad spec fails the apply loudly and the node shows Failed with the reason.

Talking to your infrastructure

let api = ctx.endpoint("api").await?;
let out = api.call(EndpointMethod::Get, "/outputs", None).await?;
let url = api.url();                        // the bare service URL
let (host, port) = api.host_and_port()?;    // for a client that stores them apart

ctx.endpoint does not return until something answers on that address.

An address exists as soon as the infrastructure is accepted, which is before your container has finished starting. So this waits out the gap and your first call never lands on a refused connection.

There is no time limit, because a first boot takes as long as it takes. A wait that is taking a while says so in the node’s log every few seconds, and weft stop ends the run.

The endpoint resolves only when the whole node is running, meaning all its units. An endpoint is a front door to the node, so a request must not land while a sibling unit is degraded.

Only the declaring node

ctx.endpoint(name) works only for the node that declared the endpoint. A sibling node gets the URL by the declaring node exporting it as an output port and wiring it downstream:

// the bridge node, in run:
let api = ctx.endpoint("api").await?;
ctx.pulse_downstream(NodeOutput::new().set("apiUrl", api.url())).await

// the send node, in run:
let base: String = ctx.inputs.get("apiUrl")?;
let resp = post(format!("{}/action", base.trim_end_matches('/')), body).await?;

The author chooses what to send downstream, and with several endpoints exports each by name.

Letting a client outside the cluster in

Everything above is for a node talking to its own infrastructure, and by default that is the only thing that can reach it: an endpoint is a ClusterIP, and the network policy lets workers in and nobody else.

Sometimes that is not enough. A frontend needs the program’s Postgres for its own sign-in tables. You want a psql session to see what a run wrote. A dashboard wants to read the same database the program writes. All of those are one thing: a client that is not a weft node, speaking the service’s own protocol.

If your endpoint can serve such a client, say so:

Endpoint {
    name: "sql".into(),
    unit: "db".into(),
    container: "postgres".into(),
    port: "sql".into(),
    expose: Expose::SameNetwork,
}

That is the whole of it. The endpoint says it is reachable and it is, the same way a volume you declare is a volume you get. Nothing opens a door after the fact, and nothing in the project’s source can open one your node did not declare, so reading the node tells you what is reachable.

“The same network” means the machine on a local install, where the door binds to loopback so nothing else on your network can reach it, and the cluster’s own subnet in a deployed one. It never means the internet. No Expose reaches the internet except TenantPublic, which is HTTP and goes through the ingress.

Let the author decide, with an input

A door is rarely something every user of your node wants, so make it theirs to choose. provision_infra runs with your node’s inputs already computed, so you branch on one like any other decision:

let reachable: bool = input.get("reachable")?;
...
expose: if reachable { Expose::SameNetwork } else { Expose::ClusterInternal },

That is how PostgresDatabase does it, with a reachable input that is off by default. The lever is an ordinary port with a label and a description, it shows up in the graph next to the disk size, and whether a database is reachable reads as part of what the program is.

Finding the address

The port is not yours and not the author’s: the cluster allocates it, so that two projects can each have a door without colliding. So the address is not in the source, and asking the runtime is how anyone learns it:

$ weft infra list-doors
db.sql  127.0.0.1:30080

That command only ever reports. There is nothing to open or close, because the node already said.

The rule: never a credential endpoint

If you write only one thing down from this page, write this one.

An endpoint that hands out a credential is never SameNetwork. Not when it would be convenient, not when it is guarded by a token, not when it only answers once.

PostgresDatabase is the worked example, because it has one of each. Postgres itself listens on sql, and reaching it still costs you a password, so that endpoint is SameNetwork. Beside it runs a small server that mints that password, on credential, and that one stays ClusterInternal for ever. A door onto it would hand the database away to anything that could reach the port.

The test on your own node: if reaching this port gives somebody something they could not already have, it is not SameNetwork.

Which connection the door hands out

A door is half of letting a client in. The other half is what that client connects AS.

The simple answer, and the one you get for free, is the connection your node already publishes: the same one the program’s own nodes use. That is what a door carries unless you do something about it, and for plenty of services it is the only answer there is.

It is worth doing something about it when your service can express a narrower identity AND the wider one can destroy things the program depends on. A database is the clear case: the connection your node publishes owns the tables, and the client coming through the door is usually somebody’s frontend, written fast, which should not be one typo from dropping them. Where the service supports it, a node can mint a second identity for the door (a database role with its own schema, a broker user scoped to its own topics) and publish that instead.

Two things to hold on to. This is a capability, not a rule: a service with no notion of a second identity has nothing to mint, and a node that publishes one connection is not wrong. And like the door itself, it is a choice you give the author rather than one you make for them: a second input, branched on in the same body, so a program that wants the full connection through the door says so and gets it.

The rule that IS absolute is the one above: an endpoint that hands out a credential is never SameNetwork. That one holds whatever anybody intends.

The routes your container serves

RouteMethodCalled byContract
/health, or any pathGETthe readiness probereturn 2xx when ready. Wire it with Probe::http("/health", port).
/liveGETthe dispatcher, which proxies the editor’s pollreturn { "items": [{ "type": ..., "label": "...", "data": "..." }] }, where the type is text, image, progress or secret. The editor asks every three seconds while the graph is open. An item may carry a button: "action": { "label": "Disconnect phone", "actionKind": "unpair", "confirm": "..." }, and payload if the press carries data.
/actionPOSTthe dispatcher, when a /live button is pressedthe same envelope sibling nodes use: { "action": "<actionKind>", "payload": {...} } in, { "result": {...} } out. A result.error string is your refusal, shown to the user as it is. The dispatcher re-polls /live right after, so whatever the press changed (a fresh QR code) shows at once.
/outputsGETthe declaring node’s own runreturn a flat JSON object; the node folds each key into an output port
/action, /events, …anysibling nodes, through the wired URLyour own convention

/live and its buttons’ /action are special because the dispatcher calls them, so it has to know which endpoint serves them:

"features": { "liveEndpoint": "api" }

Naming the endpoint is opting in. There is no separate flag, and unset means no live panel.

Lifecycle

Everything below acts on one unit at a time.

weft infra start brings down units up to spec, leaving units already up alone.

weft infra stop takes units down per their on_stop:

  • ScaleToZero (default): scale the workloads to 0. PVCs and Services are kept, so the endpoint URL stays stable and a later start is fast.
  • NoOp: the unit stays up. For a unit expensive or slow to recreate (a model that took an hour to download, a license server with live sessions) that downstream work depends on. Only terminate, or an explicit force-stop, takes a NoOp unit down.

weft infra upgrade is stop then start. ScaleToZero units cycle onto the new spec; NoOp units stayed up through the stop, so start leaves them frozen at their current version. If you want to update a frozen one, force it:

weft infra node-stop <node_id> --force   # ignores on_stop
weft infra start                          # recreate at the new spec

--force is the conscious “I accept the downtime”, which is why the graph’s per-node right-click stop uses it automatically.

weft infra terminate deletes everything for the node, PVCs included unless listed in lifecycle.on_terminate.preserve_pvcs. Deleting a node from the graph terminates it on the next sync; removing a single unit from a spec terminates that unit’s workloads on the next apply.

Health

The supervisor watches each unit’s replicas. A unit continuously below its readiness threshold for flaky_after_seconds is marked flaky; continuously ready for recovery_after_seconds returns it to running. Both default to 30 seconds and are overridable per unit through Unit.health.

Health is per unit, so one flaky sidecar does not drag a healthy primary down, and a project’s health protocols can target a specific unit for remediation: bounce pods, scale, park triggers.

Security and resources are yours

The compiler stamps labels and namespaces and adds no security context or resource limits on your behalf. Isolation between namespaces comes from the namespace boundary and the baseline network policies; what happens inside your own namespace is yours to set.

Set resource requests and limits, and a security context that satisfies the Kubernetes restricted baseline: run as non-root, read-only root filesystem, drop capabilities, default seccomp. Your image has to cooperate by running as the chosen user and tolerating a read-only filesystem, and if it cannot, leave the security context off. Without limits a runaway container can starve its own node.

Every state has a way out from the graph

A service reaches states only its operator can leave: a phone whose pairing died half way, a password that was handed over once and lost, a session a provider revoked. If the only way out is weft infra terminate, the user loses the disk to fix a login, and that is a dead end the node put them in.

So for every state your container can sit in, name the action that leaves it, and put that action on the node’s card as a button on a /live item (the contract is in the routes table above). The WhatsApp bridge offers Disconnect phone in every state, which drops the pairing and shows a fresh QR code; the Postgres node offers Reset password, which mints a new one over the database’s own socket and makes it readable again. A button works whether the service is healthy, stuck, or half way through something, because the stuck state is the one that needs it. The test: walk your container’s states and ask, for each, what a user does from the graph to leave it. If the answer is “restart the infra” or “delete the disk”, that state needs a button.

Nothing fails quietly

Your container is a service other nodes lean on, and its pod log is the only place anyone can read what it did. Two rules, and they are what makes a problem inside your image findable at all.

Every failure writes a line. Anything that goes wrong writes one line to the container’s stdout or stderr, with the cause, so weft infra logs <node> shows it. A library logger set to silent is the same as no log: set it to warn or up. A dependency that is optional at install time is a failure waiting to be silent (a step that quietly skips when the package is missing), so pin it in the image.

A success is earned or it is an error. Answer the node that asked with an error whenever the thing it asked for did not fully happen, and let the node fail on it. A message id for a message the recipient will never see, a partial result with no mention of what is missing, a step that could not run and was skipped: each of those is an error to the caller, however the underlying library reports it. When the library reports it only through its own logger, check the outcome yourself before answering.

The WhatsApp bridge is the example that fixed the rule: a voice note went out stamped with a mime WhatsApp accepts and never shows, the bridge’s Baileys logger was silent, and the node got a message id for a message that never arrived. Nothing anywhere said so.

Two behaviors to know

Mutable upstream tags do not trigger drift. Image::Upstream with a tag like :latest passes through verbatim and is never resolved to a digest. If the tag rolls underneath you, the spec hash does not change, so nothing surfaces an available upgrade. weft infra upgrade still re-applies if you know there is a new version. Use a digest for reproducibility.

/outputs against your declared output ports is a convention, not enforced. Keep them in sync by hand.

Packaging

nodes/whatsapp/
  package.toml
  bridge/
    mod.rs
    metadata.json         requires_infra, images, features, ports
    deps.toml
    images/bridge/        a Dockerfile and its source
{
  "type": "WhatsAppBridge",
  "requires_infra": true,
  "images": ["images/bridge"],
  "outputs": [{ "name": "apiUrl", "type": "String" }],
  "features": { "liveEndpoint": "api" }
}

Each entry in images is a directory relative to the package root containing a Dockerfile. Its basename is the Image::Local { name }. The CLI hashes the directory, tags the image, and loads it into the local cluster.

Packaging

A node is either a folder on its own or a member of a package. You want a package when several nodes share code.

A bare node

A directory with a metadata.json at its root. It stands alone.

nodes/reply/
  metadata.json
  mod.rs
  deps.toml        optional
  tests.rs         optional

A package

A directory with a package.toml at its root. Its members are auto-detected: every immediate subdirectory holding a metadata.json. Adding a node is adding a folder.

nodes/slack/
  package.toml
  api.rs                      shared code, reached as `super::api`
  metadata.json               optional PARTIAL: defaults every member inherits
  send_message/
    metadata.json
    mod.rs
    tests.rs
  receive_message/
    metadata.json
    mod.rs

package.toml carries the package name and the cargo dependencies its members share:

[package]
name = "slack"

[dependencies]
async-trait = "0.1"
serde_json = "1"
uuid = { version = "1", features = ["v4"] }

Any .rs file at the package root is shared code, reached from a member as use super::<filename>;. That is where the API wrapper goes, and where a package defines its own provider meter when its nodes call a paid service weft does not ship.

Nesting and discovery

The catalog walk recurses until it hits a unit, meaning a directory with either a metadata.json or a package.toml, and then stops descending. So units may sit at any depth (catalog/ai/llm/anthropic/), and a unit never nests inside a unit.

Symlinks are never followed, and target, node_modules, .git and .weft are skipped. Two units declaring the same node type is a loud collision rather than a last-one-wins.

What gets compiled

Only what your program actually uses.

The compiler reads every node’s metadata.json without compiling any node Rust, which is what makes the editor’s live feedback fast. Codegen then emits one cargo crate per referenced package, containing only the referenced nodes, plus a registry mapping node type names to implementations.

So a project using three nodes out of the whole catalog compiles three nodes. Nothing scans the filesystem at run time; the generated code names exactly what it needs.

Dependencies

deps.toml next to a mod.rs, for that one node:

[dependencies]
reqwest = { version = "0.12", features = ["json"] }

[build-dependencies]
cc = "1"

[system.build.apt]
default = ["pkg-config", "libssl-dev"]

[system.runtime.apt]
default = ["ca-certificates"]

[build.env]
SOME_PATH = "{{catalog_path}}/vendor"

Always available without declaring anything: weft, tokio, serde, serde_json, async-trait, anyhow, tracing, uuid.

[system.*] entries declare OS packages the node needs, keyed by package manager and optionally by distro version. That is what lets a node carry a native dependency without every user hand-installing it.

Comment each dependency with why it is there.

Package-level metadata

A package root may hold a partial metadata.json of defaults every member inherits, which is how a package’s nodes share a types block or a provider name: Package defaults.

Sharing a package

Copy the folder. A package is self-contained on disk, so putting one in your project’s nodes/ is the whole install.

Nothing pulls a package from git for you yet. That command is an open contribution slot, and the shape it should take is in CONTRIBUTING.

Whatever gets built has to hold one property. A project’s nodes/ is the complete list of what its programs can do, and nothing outside the project folder is reached during a build. That is what makes a project directory portable, and what stops an upgrade changing what an existing program does.

Testing a node

Every node can carry its own tests, in Rust, next to its code. They run without building a project and without a valid graph, and all but the last tier below need no credentials and no network.

End-to-end tests cover runtime mechanisms, one per mechanism, riding some real node as a vehicle. Node-level correctness goes here.

The three tiers

basic is for pure logic: no ctx, no I/O, just a function and some assertions.

fake runs the node’s full run (or setup_trigger) body against a fake ctx: canned provider responses, in-memory storage, canned signal payloads. No credentials, no network, no cost.

live runs the node’s body through the production access path: real connection resolution, real provider calls, metered and billed like any run. It needs a grant for the declared service and it can spend money, so runners require an explicit opt-in and confirm before the first run.

Where they live

In a tests.rs inside the node’s folder, never in mod.rs. mod.rs carries only the bridge, gated behind the node-tests feature, which is what keeps tests out of every project binary.

// mod.rs
#[cfg(feature = "node-tests")]
mod tests;

#[async_trait]
impl Node for MyNode {
    #[cfg(feature = "node-tests")]
    fn tests(&self) -> Vec<weft::NodeTest> {
        tests::tests()
    }

    async fn run(&self, ctx: ExecutionContext) -> WeftResult<()> { ... }
}
// tests.rs
use serde_json::json;
use weft::{FakeRig, LiveRig, NodeTest, WeftResult};
use super::MyNode;

pub fn tests() -> Vec<NodeTest> {
    vec![
        NodeTest::basic("parses_the_answer", || {
            assert_eq!(super::parse("x=1")?, 1);
            Ok(())
        }),
        NodeTest::fake("posts_and_emits", posts_and_emits),
        NodeTest::live("one_real_call", "myservice", one_real_call),
    ]
}

async fn posts_and_emits(rig: FakeRig) -> WeftResult<()> {
    rig.respond("POST", "/api/send", json!({ "ok": true, "id": "m1" }));

    let outcome = rig
        .run(&MyNode, json!({ "account": rig.access("myservice"), "text": "hi" }))
        .await
        .ok()?;

    assert_eq!(outcome.outputs["id"], json!("m1"));
    rig.assert_sent("POST", "/api/send");
    Ok(())
}

async fn one_real_call(rig: LiveRig) -> WeftResult<()> {
    let outcome = rig
        .run(&MyNode, json!({ "account": rig.access("myservice"), "text": "hi" }))
        .await
        .ok()?;

    assert!(outcome.output("id")?.is_string());
    Ok(())
}

Test names are snake_case sentences: posts_to_a_channel_and_emits_the_permalink, a_failed_permalink_read_never_fails_the_post.

Fake and live are separate declarations by design. A fake test asserts exact payloads against canned responses; a live test asserts loosely against real provider output. One shared function would force mushy assertions on both.

Assertions may panic. A panic fails that one test with its message, never the whole run.

The fake rig

CallWhat it does
rig.respond(method, path, json)a canned 200. Matching tries path?query first, then the bare path.
rig.respond_status(...)a refusal status
rig.respond_raw(...)XML, CSV, binary bodies
rig.access(service)a connection marker to place on the node’s access input
rig.connection_value(service, name, value)a stored value the opened connection answers
rig.connection_permissions(service, granted)the scopes the grant came back with
rig.signal(payload)queue a payload for the node’s next await_signal
rig.wake(payload)the wake payload for the next run: how a trigger fire is emulated
rig.run(node, inputs)run the body. inputs is a JSON object; declared defaults fill anything absent.
rig.run_setup_trigger(node, inputs)the trigger registration body
rig.requests() / rig.assert_sent(method, path)the request log
rig.registered_signals() / rig.logs()what registration and ctx.log recorded
rig.execution_tags() / rig.stops()what ctx.tag_execution and ctx.stop_tagged asked for (nothing is stopped: a fake run has no siblings)
rig.store_file(filename, mime, bytes)seed a stored file, get its value for a file input
rig.output_type(port, type)declare a port’s resolved type, for ports whose type the compiler normally works out from the .weft source
rig.seed_bus(opts)mint a bus, for driving a node’s bus INPUT: it hands back the writer and the marker you pass in
rig.bus(&outcome.outputs["port"])read the bus a node EMITTED, behind its marker
rig.run_provision_infra(node, inputs)run an infra node’s provision body and get the spec

A Generator[T] input takes its value as a plain JSON array. The rig pre-loads a live, already-finished feed with those items, so the body’s pull loop runs unmodified.

Storage is an in-memory map. ctx.run memo steps run fresh, because there is no journal.

Two properties to rely on:

A fake run can never reach the real network. ctx.http() and a connection-less ctx.client(None) answer from the same canned routes as rig-opened connections, so an undeclared plain call fails loudly rather than quietly hitting the internet from your test suite.

Anything the fake does not support yet fails loudly naming the gap, never as a silent no-op.

The rig also enforces the production emission contract: undeclared output ports and double emissions fail exactly as they would in a real run.

The live rig

rig.access(service) hands back the resolved grant’s marker for that service. rig.run mirrors the fake rig, but every capability behind the ctx is the production one. Each run mints a throwaway execution identity, so its cost records belong to that run alone and are reported with the result.

  • rig.connect() opens the grant as a real connection for the test’s own setup and teardown: create a resource before running the node, delete what the node created after.
  • rig.seed_bus(opts) is a real bus as a writer plus a marker, for driving a node’s bus input. (rig.bus(marker) is the other direction: it reads the bus a node emitted.)

A basic test runs inside a runtime

Basic tests take a plain fn, fake tests take an async one, and the runner drives both inside tokio. So never build a runtime inside a basic test: Runtime::new().block_on(...) there panics with “Cannot start a runtime from within a runtime”. Anything async, a bus included, goes on the fake tier.

A fake test that never finishes fails by name after 30 seconds rather than stalling the suite, and the message names the usual cause: a cursor reading a bus nothing closes.

Live tests spend real money

So drive the cost of each one as low as the provider allows.

Pick the cheapest model or tier that still exercises the node’s real path, and query the provider’s price catalog rather than assuming. Use the smallest inputs: one image, the shortest clip, a one-line prompt, the fastest quality setting. Mint any needed input material on the cheapest route too.

The live tier must still cover the node. Every node whose body talks to a provider gets at least one live test, and the set across a package should touch the node’s main use cases rather than one configuration’s happy path.

Clean up after yourself

Live tests prefer self-provisioned targets: act on a resource named weft-node-tests in the connected account, creating and cleaning up whatever the API allows. Everything a test creates is deleted before it returns, so repeated runs never pile artifacts onto someone’s account.

Deliberate exceptions only where the artifact is the proof: a sent email, a chat message.

Where cleanup is impossible, rig.fixture("NAME") reads a value you supply: see giving the live tier what it needs.

Fixtures are declared

You declare every fixture your live test reads, on the test itself.

NodeTest::live("one_real_send", "telegram", live_send).with_fixture(fixture_spec(
    "TELEGRAM_CHAT_ID",
    "Chat id",
    "The chat the test sends into.",
)),

A fixture is described with the same shape node inputs use, so a runner renders it with the machinery it already has, widgets included.

  • fixture_spec(name, label, description) for a plain text parameter.
  • fixture_spec_like(manifest, input_name, fixture_name) clones one of the node’s own inputs and renames it, so the fixture inherits that input’s widget. The Sheets tests declare GOOGLE_SHEET_ID off the node’s spreadsheet input and inherit its picker.

The CLI checks them before a live run starts, failing up front with every missing variable at once, before any grant or pod.

Giving the live tier what it needs

A live test talks to a real account, so weft has to resolve a connection for the service it declares before it can run one. Three ways to give it one, and the easiest depends on the service.

Connect the account in the editor, on any project of yours, and weft test-node --tier live picks that connection up by itself. This is the normal path, and the only one for a service you sign in to rather than paste a key for, such as Slack or Google. If you have several and want a specific one, --connection <service>=<grant-id>.

Paste a throwaway key with --key <service>, which prompts for each of that service’s fields on stdin and deletes the connection it made when the run finishes. Right for a key you do not want stored.

Put the key in the environment as WEFT_NODE_TEST_<SERVICE>_<FIELD>, for example WEFT_NODE_TEST_EXA_KEY. Same throwaway connection as --key, without the prompt, which is what a scripted run wants.

If none of the three is there, the run stops before touching anything and names all three.

Fixtures come from the environment too. A test that needs a target it cannot create for itself reads WEFT_NODE_TEST_<NAME>, so a declared SLACK_CHANNEL_ID fixture reads WEFT_NODE_TEST_SLACK_CHANNEL_ID. Every missing one is reported before the run starts rather than as you hit it.

These are ordinary environment variables, and the CLI reads the nearest .env walking up from wherever you ran it, so your project’s own .env is where they normally live. Gitignore it.

Running them

weft test-node                                  # every package's basic + fake tests
weft test-node slack                            # one package
weft test-node SlackSendMessage                 # one node
weft test-node SlackSendMessage --test posts_to_a_channel
weft test-node web --tier live                  # the live tier (confirms first)
weft test-node web --tier fake --tier live      # several tiers
weft test-node web --tier live --key exa        # a throwaway key, deleted after
weft test-node web --tier live --connection <grant-id>

--tier is repeatable. Without it, basic and fake run. Live never runs implicitly.

--parallel runs tests concurrently: bare for everything at once, --parallel N to cap in-flight tests. The report keeps declaration order either way.

Basic and fake runs compile the package’s test crate on the host and run it directly: no cluster, no daemon, no project build. Live runs go through the runtime, each test in a short-lived pod with a worker’s identity, so connection resolution and metering are exactly the production path.

That covers writing and running your own nodes. Sweeping the whole shipped catalog, which is what you do after changing something in weft that every node sits on, is a different job with its own runner: CONTRIBUTING.

Tests are never interactive

A test body must not read stdin or wait on a human. Every input comes from its own inputs, from self-provisioning, or from a declared fixture, and a missing one fails loudly naming what to set.

Once any grant field for a service comes from the environment, the whole grant is environment driven, so a sweep never stops on a prompt. The only prompt left is the one-time live-spend confirmation, and its “don’t ask again” answer silences it for good.

What not to write

  • No per-node end-to-end tests. If a test needs the dispatcher, the journal, or a real graph, it is testing a mechanism. Write or extend the one end-to-end test for that mechanism instead.
  • No tests in mod.rs. A node folder’s unit tests belong in tests.rs as basic entries. Package-level shared helper files, which are not nodes, keep ordinary #[cfg(test)] blocks.
  • No live tests on trigger or infra nodes. There is nothing to test live without the provider pushing real events at real infrastructure, so the runner refuses them and fake is the top tier for those.

How connections work

Adding a service to weft is a JSON file.

The one idea

A connection is an account somebody hooked up to an outside service. It lives in the access store and it holds everything secret, while a project’s source holds a bare id and nothing else, which is why a credential can never end up in git history.

Every kind of credential is the same concept: an OAuth sign-in, a pasted API key, a GitHub App’s private key, a mail server’s host and user and password. One store, one connect flow (the node’s Connect panel in the editor, or weft connect in a terminal), one way for a node to use it.

The four objects

Three of them sit somewhere and are easy to mix up, so here they are side by side. The fourth is the one that moves, and it comes after.

 RECIPE                     REGISTERED APP              CONNECTION
 in the access node's       in the operator's           a row in the access store
 metadata.json              apps file

 written by the node        written by whoever runs     created when a user
 author. Any user.          this weft. Trusted.         connects, or when a node
 Untrusted input.           Holds real secrets.         publishes one for a
                                                        service it runs itself.
───────────────────────    ────────────────────────    ─────────────────────────
 describes the SERVICE,     one OAuth app this weft     one account, hooked up:
 true for everybody:        signs users in with:        · who it is
 · how a credential is      · a label                   · through which app
   acquired                 · client id + secret        · which permissions, and
 · how a request is signed  · the fixed permission        whether they are verified
 · the permission           set it asks for             · the stored values
   catalogue                · pinned sign-in            · snapshots of the recipe
 · how it is verified         addresses                   and app it was made with
 · how events arrive        · event-receiving secrets
 · which doors exist

And the fourth: the Access value, which is what actually flows through a graph.

{ "__weft_access__": { "accessId": "…uuid…", "service": "slack",
                       "identity": "Acme Corp" } }

An access node emits it, action nodes consume it. It is not a secret: resolving it requires being authenticated as the tenant that owns the row, and a missing row and another tenant’s row give the same “not found”, so existence never leaks.

By the service name string, and nothing else.

A recipe saying "service": "slack" reaches the apps filed under "slack". The recipe travels with every connect request and the server looks the apps up itself. A connection snapshots both at creation, so using it later needs no catalog and no file lookup.

Because the recipe is user-authored and the apps file is operator-owned, the recipe may use a registered app and can never extract from it. Five rules follow:

  • Pinned addresses. Each registered app writes its own sign-in and token addresses beside its secret. A shared connect whose recipe names any other address is refused, so the app’s credentials only ever go where the operator wrote.
  • Fixed permissions. A shared connect’s permission set is replaced by the chosen app’s declared set, from the trusted file. Nothing a client sends widens it.
  • Secrets stay compartmentalized. An app’s client secret is snapshotted into its own field on the connection row. What a worker is handed comes from a different field, and nothing ever copies between the two, so the secret has no path to a node.
  • Event material joins own-door connections only. An app’s event-serving values (a socket token, a signing secret) merge into event resolution only when the app is the user’s own. A registered app’s material serves connecting, never event serving.
  • The server resolves the app. A connect request names a door and, for the shared door, an app label. It never carries an app. The client is not the security boundary.

Life of a connection

        the user clicks the access node's field
                        │
                        ▼
          doors probe: what can this weft offer?
          one option per registered app, plus
          "your own" when offered
                        │
        ┌───────────────┴───────────────┐
        ▼                               ▼
   SHARED door                     OWN door: one page with up to
   one click. The app is           three parts, whichever exist:
   resolved by label from          · mint   ("create it for me")
   the trusted file, and           · guide  (generated from the ticks)
   permissions are its             · fields (paste; always there)
   declared set.
        │                               │
        └───────────────┬───────────────┘
                        ▼
          the acquisition runs: consent, paste, a JWT mint,
          a server-to-server exchange. It runs on the broker,
          whose egress denies every private range.
                        │
                        ▼
          the verification ladder, and an identity capture
                        │
                        ▼
          one row: values, permissions and whether they are
          verified, owner, door, label, identity, snapshots

After that the connection appears in the picker for every project of that tenant, and picking it stores only its id on the node.

Doors

At most two kinds, and a door that is not offered is hidden, never greyed out.

shared: a credential this weft holds. A registered app for a consent-based service, or the runtime’s own key for a key-based service, whose calls spend its credit and say so.

own: the user brings or creates their own.

A service whose auth includes a cryptographic signing step can never offer shared, and declaring both is a parse error. The shared lane substitutes the secret into the request, and a signed request carries a hash computed from the secret instead, so there is nothing to substitute.

The verification ladder

What connect-time checking can learn about a fresh credential, best rung first.

RungWhat the provider tells usPermissions recorded as
reports_permissionswhat it granted, explicitlyverified
self_introspectthe credential describes itselfverified
reports_validityonly “alive”, never “what”claimed
probea real call’s refusal, read backclaimed
silentnothing knowableclaimed

A check’s cost is free, ambiguous, or paid, and anything but free is never auto-run: the first real call surfaces the truth instead.

Later, a permission shortfall hard-fails only on a verified set. A claimed set passes with a warning, because refusing would block every pasted key on every service that reports nothing.

Coexistence

grants on the recipe records how the provider behaves. You do not get to choose it.

coexisting is the Google class: one token per consent, and grants are per project.

exclusive is the Slack-bot and GitHub-App class: one grant per app and account, and there is no way to have two. Re-consent rotates it in place, reuse inherits it, a permission upgrade unions onto it, and every referencing project follows.

Life of a call

 node body                    runtime                        store / broker
──────────────────────────────────────────────────────────────────────────────
 ctx.open(&access) ────────▶ resolve request ─────────────▶ the tenant wall
                             (id, service, what it needs)  refreshes an expired
                                                            token once, however
                                                            many callers ask
                             ◀───────────────────────────── values + auth steps
                                                            + owner
                             build ONE client:
                               timeouts, redirect and
                               address limits
                             + auth           (always)
                             + metering       (only if the service has
                                               a meter)
                             + routing        (only if the credential
                                               says to)
 conn.client() ◀──────────── the signed-in client
 …the node makes calls…
 body finishes, any way ──▶ release the lease

What the worker receives is everything the connection stores except the store’s own keep-alive material, meaning the refresh token. An app secret is not a stored value and cannot travel at all.

Refresh is lazy, at resolution, single-flight through the row lock. A revoked credential is a loud “needs reconnecting” error naming the fix, never a silent retry.

Encryption at rest

Every stored secret is sealed with AES-256-GCM under CREDENTIAL_ENCRYPTION_KEY: a grant’s values, an app snapshot, a pending consent’s verifier, a subscription’s echo token.

A database dump alone carries no usable credential. Only non-secret row data stays plain (service names, permission sets, value names, the public client id) so queries can work on it.

Unset, a built-in development key is used with a logged warning. Set a real one (openssl rand -base64 32) before storing credentials you care about, and see the apps file for what changing it later costs you.

Measured and billed are separate switches

Measured and billed sound like one switch, and they answer different questions.

A registered meter for the service means its calls are measured, whoever pays.

The owner on the connection row decides whose money: the user’s own credential is measured and not billed; the runtime’s is measured and billed.

A node declares neither, and node code cannot tell which combination it is running in. The same node runs correctly in every combination.

Where things run

ComponentIts job
Editorrenders the picker and the doors, sends pasted values straight to the store, runs the live requirement check when a connection is picked. Never holds an app, never decides permissions.
Dispatcherthe authenticated front door. Forwards connect verbs, serves the OAuth callback and the picker page, answers pure-database reads.
Brokerevery verb whose work makes an outbound call to a URL the tenant influences: test calls, token exchanges, lookups, app mints, event subscribes. Its egress denies every private range, so a crafted URL aimed at an internal address dies at the network layer.
Access storethe rows, the tenant wall, the lazy refresh, the permission and value backstops.
Workeropens connections per firing, builds the one signed-in client, runs node code.
Listenerholds event sources, resolving the connection freshly through the broker on every reconnect, so credentials are never frozen into a loop.

Using a connection

Opening one

let account: Access = ctx.inputs.get("account")?;
let conn = ctx.open(&account).await?;   // one resolve, one lease, this firing

conn.client()          // &ClientWithMiddleware: signed in, measured if a meter exists
conn.credential()?     // &str: ONLY when the sign-in is one string
conn.value("imap_host")?    // one stored value by name, loud when absent
conn.opt_value("alias")     // Option<&str>, for genuinely optional values
conn.identity()             // the display identity, if recorded

For the common case there is sugar:

let gh = ctx.client(&access).await?;
gh.post(format!("https://api.github.com/repos/{repo}/issues"))
  .json(&body)
  .send().await.node_err("creating the issue")?;

Those four lines are the integration, and they are byte-identical across a pasted token, an OAuth grant, a minted installation token, and the runtime’s own credential.

When the body finishes, any way at all, the runtime releases the lease.

Secrets never go through config

Never add a config field asking the user to paste an API key, a token, or a password.

Node config and port values travel the execution journal and render in the inspector in plaintext. A connection’s values are sealed in the store and only exist in the worker’s memory while your node runs.

If your service needs a pasted key, declare it as an access node with a paste acquisition, and the value is sealed in the store instead of the journal. See Declaring a service.

When credential() exists

You never declare it. It is there exactly when the resolved auth is one step interpolating one stored value: a bearer key, a bot token.

A Basic pair, a signing step, or two steps have no single string that means anything, and the call fails loudly naming the service.

It is there for libraries that insist on a raw string. Do not stash it anywhere: not an output port, not a log, not an error, not a struct that outlives the call.

// A library that insists on a string: hand it both.
let generator = GeneratorInfo::openrouter(model)
    .with_api_key(conn.credential()?)
    .with_http_client(conn.client().clone());

The rules that matter

Never construct your own HTTP client for a connection’s calls. Always take it from the opened connection. A hand-rolled client is invisible to the cost trail, and a runtime-supplied credential only works through the connection client’s routing.

Address the service’s real API. The client does whatever routing a runtime-supplied credential needs, so your code never rewrites a URL.

Do not paper over a refusal. When the runtime declines to supply its own credential, that is a loud error naming the fix (“connect your own”), and passing it on is the correct behavior.

Redirects behave like any ordinary HTTP client’s: followed, with authorization and cookie headers dropped across a host change.

When you send media on a measured call, declare what you know about it (duration, dimensions). It sharpens the pre-flight estimate and never changes what is billed.

Sockets

If the service has a realtime API, the same connection opens it:

let mut session = conn.socket("wss://api.example.com/v1/realtime?model=m").await?;
session.send(SocketMessage::Text(payload)).await?;
while let Some(frame) = session.recv().await? { /* ... */ }
session.close().await?;

Same rules as the client. The runtime signs the handshake (the credential rides the handshake only, never a frame), routes the session, and measures it when the service’s meter prices sessions. Never hand-roll a socket client for a provider.

The lease window

Every connection call assumes your provider work fits the default window of 15 minutes, which is how long a runtime-supplied credential stays usable if your node crashes without finishing. On the user’s own connected account it changes nothing.

If your node wraps something that takes longer, say so:

let conn = ctx.open_within(&access, Duration::from_secs(3600)).await?;

Declaring what your node needs

Say it on the access input, and the checks then run where the answer lives: live in the editor when a connection is picked, at connect time, and at run-time resolution.

{ "name": "account", "type": "Access", "required": true,
  "requiresScopes": ["chat:write"],
  "requiresValues": ["imap_host", "imap_port"] }

There is deliberately no compile-time check, because source holds only a connection id.

requiresScopes

Declare only what your node’s own runtime calls need on every path.

A permission that only one picker source needs (a browse-everything scope backing a list) belongs on that source’s own requires, never here. Putting it here locks out every connection that would have used the picker or a pasted link.

A verified shortfall is a hard error. A claimed or unknown one is let through, because nobody actually knows, and a pasted key on a service that reports nothing must not be refused.

A required permission may be an own-account-only capability, which the service’s catalogue marks as such: the node’s work creates or reads durable things inside the connected account (a minted voice, a configured agent), so a shared runtime credential can never serve it, because the result would land in the runtime’s account. Declare it through the same requiresScopes. Resolution refuses the shared credential with that capability’s setup guide, and the editor marks the node the moment a shared connection is picked.

requiresValues

For services whose optional fields decide what a connection can do. A mailbox holding the incoming half, the outgoing half, or both.

Unlike a permission set, the answer here is never unknown: a value is stored or it is not. So a shortfall always refuses, naming the value to add.

Working without a connection at all

Some providers serve a link-shared resource with no sign-in: Google’s spreadsheet CSV export, GitHub’s normal API.

If a provider offers that, support it. It costs the author little and it turns “connect your Google account” into “paste the link” for the many people whose file is already shared.

Say the two limits when you say it works: only for a resource you already have the link to, and only where the provider really does serve anonymously.

Best case, one address serves both worlds and the body has no branch at all, which is what GitHub’s API does. Verify that before assuming it: Google’s CSV export is anonymous-only, ignores a bearer token, and answers 404 for a private sheet, while its Sheets API is signed-in-only. So catalog/google/sheets_read branches on whether an account is connected, and both branches share the parsing so they answer identically.

An access input declared "required": false is the declaration, and no new metadata is needed. ctx.client accepts the absent connection and answers a plain client:

let account: Option<Access> = ctx.inputs.opt("account")?;
let client = ctx.client(account.as_ref()).await?;

On a required: true input an absent value is an ordinary missing-input error, never a quiet bare request.

Picking a resource

A connection says which account. Almost every real node then needs which thing in it: a spreadsheet, a channel, a repo.

One field, and a list of sources you declare best-first. The editor uses the best one the picked connection actually supports and quietly drops the rest.

KindWhere options come fromNeeds
grantedrecorded on the connection during sign-innothing, no call
listcall the service and enumerateits requires permissions
pickerthe provider’s own chooser, declared entirely by you. Choosing grants the picked resource.a connection
from_urlpaste a link; the pattern’s first capture group is the idnothing at all

from_url needing nothing is what leaves the field standing with no connection: the works-without-signing-in path.

{ "name": "spreadsheet", "type": "String", "required": true,
  "widget": { "kind": "remote_select", "access": "account", "sources": [
    { "kind": "list",
      "requires": ["https://www.googleapis.com/auth/drive.readonly"],
      "get": "https://www.googleapis.com/drive/v3/files?...",
      "items": "files", "label": "name", "value": "id",
      "page": { "cursor_param": "pageToken", "cursor_path": "nextPageToken" } },
    { "kind": "picker",
      "script": "https://apis.google.com/js/api.js",
      "code": "await new Promise((r) => gapi.load('picker', r)); ...",
      "grants": ["https://www.googleapis.com/auth/drive.file"],
      "mime_types": ["application/vnd.google-apps.spreadsheet"] },
    { "kind": "from_url", "pattern": "/spreadsheets/d/([a-zA-Z0-9_-]+)" } ] } }

The stored value is the bare id, which is what your node reads and what the field’s String type holds. The human label the editor shows is a display cache, never source. Pasting a raw id fills the field just as well. A value arriving on the wire at run time skips the picker entirely, since there is nobody there to pick.

Writing a picker

A picker is yours end to end, and it is the one place a node carries browser JavaScript, the same way an ExecPython node carries Python. Your mod.rs stays pure Rust and never sees any of it.

FieldWhat it is
scriptthe https address of the provider’s own chooser library, the one their embed docs tell every web developer to load
codeplain browser JavaScript, usually adapted straight from the provider’s sample. It runs on a small page weft serves, opened in the user’s real browser, after script loaded, inside an async function, so top-level await works.
grantsthe permissions that choosing through this chooser grants on the picked resource, recorded when the pick lands
mime_typesnarrows the chooser, threaded to your glue

Your code talks to weft through one object, weft, already in scope:

weft.tokenthe connection’s access token: the string you hand the chooser where its docs say “your OAuth token”
weft.clientIdthe public client id of the app behind the connection, or null when no app made it. Some choosers require an app identifier.
weft.mimeTypesyour declared types, for choosers that filter
weft.done({id, label})the user picked this: the field fills with it
weft.cancel()the user closed it without picking: the field closes quietly
weft.fail(message)the chooser could not work: the message shows on the field in red

Call exactly one of the three enders. The first call wins, and a thrown exception or rejected await becomes weft.fail automatically, so a provider error surfaces on the field instead of hanging it.

So the recipe for any new provider is: open their “picker embed” docs, take their sample, replace their token slot with weft.token, and route their picked and cancelled callbacks into weft.done and weft.cancel. The page opens in the user’s real browser, so a chooser that leans on the provider’s own session finds it already there.

A picked resource is as person-scoped as the connection itself, so both are re-chosen when a project changes hands, and an unresolvable one is a loud node error.

Handing out a connection to something your node runs

If your node runs a service itself, a database it provisions say, it can hand out a connection to that service, and downstream nodes then reach it exactly like one a person connected.

"publishes": "postgres",
"outputs": [{ "name": "access", "type": "Access" }]
let values = BTreeMap::from([
    ("host".into(), host), ("database".into(), db),
    ("user".into(), user), ("password".into(), password),
]);
let access = ctx.publish_access(values).await?;
ctx.pulse_downstream(NodeOutput::new().set("access", access)).await

The call names no service: your metadata already did, and saying it twice is a way for the two to disagree. The values are the service’s own declared fields, the same ones a person would have filled in, and anything else is refused right there.

The service’s recipe stays where it always lives, on that service’s access node. The compiler looks it up and attaches it at build time, which is why the name has to be declared rather than passed at run time: a built project carries only the node types its graph uses, so the access node is usually not in there, while the compiler sees the whole catalog. A name nothing declares fails the build.

The connection belongs to your node. Publishing again updates it, and terminating the node’s infrastructure deletes it, so it lives exactly as long as the thing it opens. It is always the user’s own credential; nothing a node publishes can resolve to the runtime’s.

Ask for a once-only secret once

A well-built service mints its password on first boot and refuses to say it twice, so on later runs read it back from your own connection rather than asking the service again:

let password = match ctx.published_access().await? {
    Some(mine) => ctx.open(&mine).await?.value("password")?.to_string(),
    None => ask_the_container(&ctx).await?,
};

The two arms run on different runs, and a replay walks back through the steps it recorded, so the arms have to record the same ones. Give them the same sequence of ctx.run and ctx.await_signal calls, or none at all, and do not assume a body with no suspension point is exempt. Both rules and why they bite here: the replay rule.

catalog/postgres/database is the worked example. It retires the password only once a connection holds it, and asks again on every run rather than only the one that read it, so a run that dies in between cannot leave a service nothing can sign in to.

Declaring a service

The whole of a service is the service block in its access node’s metadata.json. There is never per-service Rust.

The pieces the JSON is built from are protocols weft implements once (an HMAC, SigV4, OIDC), and a service picks them and fills in the details. When a mechanism genuinely cannot be described as data, it becomes a new one of those, available to every service rather than to the one that needed it.

The whole node

{
  "type": "SlackAccess",
  "service": {
    "service": "slack",
    "doors": ["shared", "own"],
    "acquisition": { "kind": "oauth2", /* or "static", "mint_jwt" */ },
    "auth": [ { "kind": "header", "name": "Authorization", "value": "Bearer {token}" } ],
    "permissions": [ /* the catalogue, one human sentence each */ ],
    "test": { "url": "https://slack.com/api/auth.test", "method": "POST" },
    "identity": "{team}"
  },
  "inputs": [
    { "name": "account", "type": "JsonDict",
      "widget": { "kind": "access" }, "label": "Workspace" }
  ],
  "outputs": [ { "name": "access", "type": "Access" } ]
}

And the entire Rust:

weft::access_node!(SlackAccessNode);

The compiler finds the picker input by its access widget, never by name. The access_node! macro requires it to be named account; a node writing its own body (the LLM providers name theirs connection) picks its own name.

Every macro access node’s body is the same pass-through, so it cannot drift.

Declaring a service block also means the node requires a connection: the language synthesizes the runtime “no connection picked” rule for it, and the editor keeps the unconnected node expanded until one is picked. You never write that rule yourself. If the node genuinely runs without a connection (a custom endpoint that may be unauthenticated), declare "connection_optional": true in the service block and both behaviors turn off. Such a node cannot use access_node! (its pass-through body has nothing to pass through when no connection is picked, so the macro refuses the combination at run time): write your own body and read the pick with ctx.inputs.access("<your picker input's name>")?, which answers None when nothing is picked. The LLM providers do exactly this, with an input named connection.

Acquisition: how a credential is obtained

KindWhat happens
staticthe user pastes the declared fields
oauth2 + authorization_codebrowser consent, then code, then token. PKCE by default.
oauth2 + client_credentialsa server-to-server token from the app’s credentials, re-requested on expiry
mint_jwtpaste a private key; weft mints a short-lived JWT and optionally exchanges it for a working token, lazily. GitHub Apps, service accounts.

captures pull values off token and test responses by dotted JSON path, and become stored values that templates can interpolate.

An oauth2 acquisition may declare token_auth for how every token call authenticates the client: body (default) sends the client id and secret as form fields, basic sends them as HTTP Basic, for providers that ignore body credentials.

It may also declare refresh, its own renewal call, for providers whose renewal is not the standard refresh-token POST. It is a declared call like any other, running at refresh time over the stored values plus the app’s, with its captures writing back and a top-level expires_in setting the next expiry. On a registered app it may only address the app’s pinned sign-in origin.

A test capture named granted_permissions records the credential’s introspected permission set as verified on a self_introspect service.

Auth steps: how a request is signed

Applied by the client in the worker, in declaration order, with signing steps last so the signature covers the final request. Templates interpolate stored values by name, and an unknown name is a loud error rather than an empty substitution.

KindExample
headerAuthorization: Bearer {token}, or any header
query?key={token}
basicStripe’s key:, Twilio’s sid:token
path_prefixTelegram’s /bot{token}/
base_urlper-account API bases: an S3 endpoint, a captured instance URL
signper-request cryptographic signing: sigv4 for S3-compatible stores, oauth1a for X

The S3 access node gets AWS SigV4 request signing from that one sign block in its JSON, and its entire Rust is the access_node! macro line. The signing code belongs to the protocol, shared by every S3-compatible store.

A service whose credential is not a request transform at all declares no auth steps, and consumers read the stored values by name instead. That is how anything that is not an HTTP API gets a connection: PostgresAccess collects a host, port, database, user and password, seals them, and the query nodes open the connection and read what they need. Nothing about it is an ordinary node input, so the password is never in config and never on a wire.

The own page

  • own_page.mint: the provider’s create-an-app-by-API recipe, a URL plus a manifest payload. "{permissions}" in it is replaced by the ticked ids. Renders as “create it for me”.
  • own_page.guide: a pre-filled creation link plus ordered steps. {permissions} in a step interpolates the ticked labels. Renders foldable, unfolded.
  • own_page.paste: “I already have a credential”, pasted directly with no app. The fields have to cover every value the auth steps interpolate, which is validated at load. Refused on a natively static service.

Permissions

The catalogue: a provider id, a human label, and one sentence each.

On a provider with hundreds of permissions, list the ones the shipped nodes use and declare all_permissions_url so the picker can point at where the rest live.

Own-account-only capabilities

A catalogue entry may declare own_only: true plus its own guide.

Reach for it if the capability creates or reads durable things inside the connected account: minted voices, configured agents, phone numbers. A runtime-supplied credential can never serve one, because the result would land in the runtime’s account, so resolution refuses the shared credential for any node requiring it and the “your own” page shows that entry’s guide.

A capability like this never appears in the tick list and never rides a consent URL, so it works the same on a pasted-key service.

Capabilities: when optional fields decide what a connection can do

For a service where filling different optional fields unlocks different abilities, declare named all-or-nothing groups.

"capabilities": [
  { "label": "receive mail", "fields": ["imap_host", "imap_port"] },
  { "label": "send mail",    "fields": ["smtp_host", "smtp_port"] }
]

Two rules, both enforced at connect: each group is filled completely or not at all, and at least one group must be complete. Consumers then declare which values they need with requiresValues, and a shortfall always refuses, because a value is stored or it is not.

Do not build two access nodes for one account.

Verification and test

verification names the ladder rung and the cost. See the ladder.

test is the declarative connect-time check: a URL, a method, an expected status, captures. Identity captures usually hang here, and identity is a template over them such as "{team}".

callback_https says the provider refuses plain-http OAuth callbacks, as Slack does. Consents then use an https address, failing loudly when this weft has none.

Events

events is a map of named topics, because one provider really does report along independent topologies at once. Slack declares messages, reactions, interactions and files.

Each topic declares:

  • fields: named facts, and where each lives in the push (a dotted body path, or header:X-Some-Header). Subscriptions filter on the names and trigger nodes fan them out, so nobody downstream ever writes a provider path.
  • account: which stored value identifies the account on a connection, and where the same identifier sits on an incoming event. That pair is what routes a push to the right connection.
  • socket (dial out): an authenticated connect call answers a single-use socket address; weft connects out and holds the line. replies declare acknowledgement rules as data, and event_path and account_path say where the event and its account sit in a frame. Works from a laptop and needs no public address.
  • webhook (dial in): the provider posts to a published address. Declares the handshake (echo a body field, echo a query param, or visit a confirmation URL), verify, route_by (account or subscription), an optional decode for an event wrapped in a queue envelope, and optional subscribe and unsubscribe calls with a renewal margin.

Subscribe calls interpolate weft-minted values by reserved name: {subscription_id}, {subscription_token}, {receiver_url}, and capture expires_at when they renew.

Verification

verify names a protocol, parameterized by data. Never a provider.

// An HMAC over a declared concatenation (the Slack shape: a signed
// timestamp, replay-checked).
"verify": { "kind": "hmac",
            "signature_header": "X-Slack-Signature",
            "timestamp_header": "X-Slack-Request-Timestamp",
            "concat": "v0:{timestamp}:{body}",
            "prefix": "v0=" }

// Body-only (the GitHub shape; Shopify is the same with base64 encoding
// and no prefix). A declared timestamp and {timestamp} in the concat come
// and go together: a timestamp the signature does not cover is refused at
// load.
"verify": { "kind": "hmac",
            "signature_header": "X-Hub-Signature-256",
            "concat": "{body}",
            "prefix": "sha256=" }

// Packed header (the Stripe shape): the timestamp and digest ride the
// signature header's own k=v pairs. Any pair under the signature key that
// matches verifies, which covers a rolled secret's overlap.
"verify": { "kind": "hmac",
            "signature_header": "Stripe-Signature",
            "packed": { "timestamp": "t", "signature": "v1" },
            "concat": "{timestamp}.{body}" }

// Address signing (the Twilio shape): SHA1, base64, over the posted address
// plus the form params sorted by name. The concat vocabulary is {body},
// {timestamp}, {url}, {method}, {sorted_form_params}.
"verify": { "kind": "hmac",
            "signature_header": "X-Twilio-Signature",
            "concat": "{url}{sorted_form_params}",
            "algorithm": "sha1",
            "encoding": "base64" }

// A public-key signature (Discord's ed25519; SendGrid's ecdsa_p256 with
// base64 encoding). The public key is configured on the receiving app in
// the apps file, never in the recipe.
"verify": { "kind": "signature",
            "scheme": "ed25519",
            "signature_header": "X-Signature-Ed25519",
            "timestamp_header": "X-Signature-Timestamp",
            "concat": "{timestamp}{body}" }

// The provider echoes the token weft minted at subscribe time,
// per-subscription and unguessable (Google's watch channels).
"verify": { "kind": "token_echo" }

// A signed OIDC identity token, checked against the issuer's published keys.
"verify": { "kind": "oidc",
            "issuers": ["https://accounts.google.com"],
            "jwks_url": "https://www.googleapis.com/oauth2/v3/certs" }

The secret material is never in the recipe. An HMAC’s signing secret, and a signature scheme’s public key, belong to the registered app that receives. A token_echo compares against what weft minted.

Verification always runs before the handshake is answered, and its refusals are flat: which part failed is logged operator-side, never echoed back.

Which transport actually serves a trigger

Decided at activation, from what the service declares and what the environment can do. A trigger that cannot be served fails loudly right there, naming what is missing.

The user-facing side of that is Events from a service.

The rule to remember

Per-service knowledge lives in declared data. When a mechanism genuinely cannot be data (a signature algorithm, a browser picker handshake), it becomes a typed variant every service can reach for.

So the question to ask about a new capability is whether the next service needing it can express it without touching the engine.

The shared-credentials file

WEFT_ACCESS_APPS_FILE names one JSON file holding every credential this weft offers as a shared connection, whatever its shape. Start from access-apps.example.json.

It is keyed by service, and each service holds a list of entries, even for one. Every entry declares its kind.

{
  "_readme": "keys starting with _ are comments and are ignored",

  "google": [
    {
      "kind": "oauth_app",
      "_setup": "console.cloud.google.com -> credentials -> add the callback URL",
      "label": "Google Drive",
      "covers": [
        "https://www.googleapis.com/auth/drive.file"
      ],
      "auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
      "token_url": "https://oauth2.googleapis.com/token",
      "client_id": "…",
      "client_secret": "…",
      "events": {
        "signing_secret": "…"
      }
    }
  ],

  "openrouter": [
    { "kind": "api_key", "label": "Runtime key", "key": "sk-or-…" }
  ]
}

The two kinds

oauth_app is one OAuth application this weft signs users in with. Its auth_url and token_url are pinned: a shared connect whose recipe names any other address is refused, so these credentials only ever go where you wrote.

api_key is the runtime’s own key for a pasted-key service, and it is what the “use the runtime’s key” door hands the credential source. At most one per service.

How that key is handed out, directly to the worker or swapped for a stand-in behind a relay, is decided by the deployment’s credential source and deliberately never by a field in this file. A typo in here must not be able to change who gets the raw key.

The rules

Every one is checked loudly, because a typo must never silently remove a one-click door.

  • Every service’s value is a list, and every entry declares its kind.
  • Every entry has a non-empty label, unique within its service.
  • On an oauth_app, covers is mandatory. Empty means “may ask for nothing”, never “everything”. Every entry in it must exist in that service’s permission catalogue, which is checked at the door probe and at every shared connect. An api_key entry carries only a label and a key.
  • An events block on a service whose recipe declares no webhook transport is refused. That is configured receiving for pushes that can never arrive.
  • A malformed file is a loud error on every lookup.
  • If WEFT_ACCESS_APPS_FILE names a file that is not there, that is an error too. It is only when the variable is unset, and the default access-apps.json is absent, that this quietly means “no apps” and hides the shared doors.

The broker rereads the file on every lookup, but it reads a copy packed into the cluster at daemon-apply time, so editing access-apps.json on your machine changes nothing until you re-run weft daemon start.

Several apps per service

Each registered app is its own one-click option in the connect panel, labelled, with its covers shown as a fixed set.

That is how a cheap permission tier avoids a provider’s review process, and how one service gets product-shaped options (“Google Drive”, “Google Calendar”) while staying one account underneath.

Apps a project ships

A project may ship a public app, meaning PKCE and secretless, under accessApps in its metadata, inherited from the package root like any other key.

An entry carrying a client_secret fails the metadata load, because project metadata is source and source never holds secrets.

Encryption

Everything sealed at rest goes through CREDENTIAL_ENCRYPTION_KEY: base64 of 32 bytes.

openssl rand -base64 32

Unset, a built-in development key is used and a warning is logged. Set a real one before you store a credential you care about.

Set it once and do not change it. Every sealed row is opened with the key that sealed it, so a different key means every connection you have stops opening, and the way back is reconnecting each account by hand. There is no rotation ceremony, and the failure surfaces the first time something tries to use a credential rather than at boot.

Events from a service

Some triggers fire when something happens at an outside service: a Slack message lands, a Drive file changes, an email arrives.

The two directions

Every service delivers events in one of two directions, and some offer both.

Your weft calls the service and keeps a line open. Weft signs in, asks for a private connection address, connects out, and holds that line while the service sends each event down it. Because your weft made the call, this works anywhere with ordinary internet access, including a laptop behind a home router.

The service sends each event to your weft. The service is given an address and posts every event to it, the way any webhook works. That address has to be reachable from the internet, which a laptop normally is not.

A trigger never asks you to pick

It looks at the connection you wired in and uses what that connection can do.

TriggerWhat it uses
Slack Receive Message (your bot, your workspace)if your connection is through your own Slack app and you pasted its app-level token, weft holds a line open and nothing is exposed. Otherwise Slack must send events in.
Slack App Messages (you own the app; every workspace that installed it)always the held line, always through your app’s app-level token, never a public address
Google Drive changesGoogle only sends. There is no line to hold. This always needs your weft reachable.
Email arrivalsthe mailbox is watched over IMAP, a line weft dials out, so no public address is ever needed

Slack App Messages is a separate node from Slack Receive Message because it answers a different question: not “what lands in my channel” but “what lands anywhere my app is installed”. See one node, one process.

“requires your weft to be reachable from the internet”

When you activate a project whose trigger needs events sent in, and your weft has no public address, activation stops with that message.

Use the direction that needs no public address, where the service offers one. For Slack: connect through your own app and paste its app-level token on the connection.

Or give your weft a public address, covering exactly its trigger surface:

./setup.sh --public-url

Then activate the project again.

What --public-url actually does

Two small pieces start inside your local cluster.

An outbound tunnel: your machine dials out to a relay service and receives a random public https:// address. The relay forwards what arrives there back down the same connection. Nothing on your machine starts listening for inbound connections, and no router configuration is involved.

A filtering proxy between the tunnel and weft. Four addresses pass through it, and nothing else does:

  • /events/..., where services deliver event pushes. Every push is verified (a signature, a secret only the real service and your weft share) before anything acts on it.
  • /signal/..., the per-signal fire links weft mints, each protected by its own unguessable token.
  • /public/files/..., the share links you mint yourself for stored files. A link carries its own unguessable token, expires, and only ever serves the one file it names. See Files.
  • /access/oauth/callback, exactly that path, where a provider hands back the code at the end of a sign-in you started.

Plus the root, which shows a small weft page, and the icon on it. Every other address answers “not found”, so your projects, executions, and settings are not reachable through the tunnel, and it cannot be used to browse or operate your weft.

The address is printed when the install finishes, and weft daemon status prints it any time after.

Two things to know about the address

It is random, and it changes whenever the tunnel reconnects. A reboot, a cluster restart, a dropped connection. Anything registered against the old address at a provider (Slack’s event request URL, an OAuth redirect URL) stops working until you re-register it.

When it changes, run ./setup.sh --public-url again so active triggers are re-pointed at the new address, then update the provider-side registrations. If you own any domain, give your weft a permanent hostname instead and the registrations never rot: A public address.

Anyone who knows the address can send requests at those four addresses, which is what they are built for: every push is signature-verified and every fire link carries its own unguessable token. When you no longer need triggers delivered from outside, close it:

./setup.sh --no-public-url

Per-service setup notes

Slack, through your own app (no public address needed): in your app’s settings, enable Socket Mode and mint an app-level token with the connections:write scope, under Basic Information then App-Level Tokens. Paste it in the connection’s app token field.

Slack, events sent to your weft: under Event Subscriptions, set the Request URL to <your public address>/events/slack/messages, subscribe the bot to message.channels, and put the app’s Signing Secret in your access-apps.json under that app’s events block.

Google Drive watch: Google only pushes to addresses on domains verified in the app’s Google console, so register your public address’s domain there. Weft renews the watch automatically while the trigger is active.

Email: nothing to set up. The email package watches the inbox over IMAP, a connection weft dials out, so it works with no public address on any provider serving IMAP. With Gmail or Outlook, use an app password.

A public address

If you want services to be able to reach your weft, ./setup.sh --public-url gives it a public https address. By default that address comes from a free quick tunnel and is random: it changes whenever the tunnel reconnects, and everything you registered at a provider against the old one silently stops working until you go and re-register it.

And if you own a domain on Cloudflare, the setup below gives your weft a permanent address instead, so those registrations never rot.

What you need

A domain managed by Cloudflare. Any plan; the free one works.

A subdomain name for this weft. Pick something unguessable, for example weft-dev-amber-comet.example.com. Nothing publishes it, so treat it as a secret you happen to have typed into DNS, and do not rely on it staying unknown: what actually protects the surface is that only four addresses pass through it, each with its own check.

What it exposes is weft’s filtered trigger surface, which is built to face the internet. See what the proxy passes.

Create the tunnel

  1. Open the Zero Trust dashboard at one.dash.cloudflare.com.

  2. Networks then Tunnels then Create a tunnel, connector type Cloudflared. Name it anything, such as weft-local. Save.

  3. The next step shows connector install commands. Ignore them: weft runs the connector inside its own cluster. You only need the token, the long eyJ... string after --token. Copy it.

    You can get it again any time by opening the tunnel and clicking Edit, where a refresh option also rotates it if it ever leaks.

  4. Public Hostname tab, then Add a public hostname:

    FieldValue
    Subdomainyour chosen name
    Domainyour domain
    Pathleave empty
    TypeHTTP

    | URL | weft-public-proxy.weft-system.svc.cluster.local:8080 |

    That tells Cloudflare “whatever arrives at that subdomain, hand it down the tunnel to weft’s filtering proxy”. It creates the DNS record itself.

Point weft at it

In your shell, or the repo’s .env:

WEFT_PUBLIC_TUNNEL_TOKEN=eyJ...
WEFT_PUBLIC_TUNNEL_HOSTNAME=https://weft-dev-amber-comet.example.com

Both must be set together. Setting one without the other fails loudly.

./setup.sh --public-url      # first time; persists the choice
weft daemon start            # any later restart

Two signs it worked

The daemon prints public trigger surface reachable at https://<your hostname>.

The tunnel’s page in the Cloudflare dashboard flips to Healthy.

To check from the outside, open https://<your-host>/ in a browser. You should see a small weft page. That page is served by weft’s filtering proxy inside your cluster, so seeing it proves the whole chain: DNS, Cloudflare, the tunnel, into your cluster.

Every other path answers “not found” on purpose. If something were broken you would see a Cloudflare error page or a timeout instead.

Turn off the browser check for this hostname

Cloudflare’s Browser Integrity Check refuses some client signatures with a 403 and the text error code: 1010, at Cloudflare’s edge, before anything reaches your cluster. Python’s standard library is one of them: a node fetching a file link with urllib gets that 403 while curl and requests get the bytes. The links weft hands out under this address (/public/files/...) are read by programs, never by browsers, so the check only ever refuses your own nodes. Turn it off for this hostname, and leave it on for the rest of your domain.

  1. In the Cloudflare dashboard (dash.cloudflare.com, not the Zero Trust one), open your domain, then Rules, then Overview, then Create rule, then Configuration Rule.
  2. Name it anything, such as weft no browser check.
  3. Under When incoming requests match, pick the field Hostname, the operator equals, and type your weft hostname without the https://, such as weft-dev-amber-comet.example.com.
  4. Under Then the settings are, add Browser Integrity Check and set it to Off.
  5. Deploy.

If you would rather switch it off for the whole domain, it is the Browser integrity check toggle under Security, then Settings.

To confirm, from any shell:

curl -s -o /dev/null -w "%{http_code}\n" -A "Python-urllib/3.12" https://<your-host>/

200 means fixed; 403 means the check is still on (a rule takes a minute or so to apply).

Register the address at providers, once

Slack: OAuth redirect URL https://<your-host>/access/oauth/callback. Event Subscriptions request URL https://<your-host>/events/slack/messages. Interactivity, for button approvals, https://<your-host>/events/slack/interactions.

Everything else in Events from a service that says “your public address” means this hostname now.

Going back

Unset the two variables and rerun the daemon to fall back to the random quick tunnel. ./setup.sh --no-public-url closes the surface entirely.

Measuring what a call costs

A meter is the per-provider Rust that computes the real cost of a paid API call from the bytes of the request and the response.

The runtime runs the provider’s meter around every call made on an opened connection’s client, so every cost figure in the system is a meter’s output. A node never states a cost and cannot reach that path.

Where a meter lives

A meter can live in two places and runs the same way in both. The worker runs it, so nothing central has to know it exists.

In weft, one file under crates/weft-providers/src/providers/. This is a provider weft ships and reviews.

In your own project, beside the nodes that call the provider: a shared .rs file at a package root, or the bottom of a bare node’s mod.rs. That is how a project supports a provider weft does not ship yet, with a key you set yourself.

Either way, adding a provider is a file plus one line:

weft_providers::register_meter!(MY_METER);
// inside weft's own crate: crate::register_meter!(MY_METER);

The registry collects every registration at link time, weft’s meters and your project’s alike, since your project compiles into the same worker. Forget the line and the provider is simply unsupported: a loud refusal wherever a measured call is required, never a silent wrong number.

The node and the meter connect only through the provider name string, so the node never imports the meter.

A meter in your project measures spend on your key: if it gets a number wrong, the money it got wrong is yours. A meter shipped in weft is the one that will run on the platform key, the credential weft will hold and bill against a user’s balance once that is built, where a wrong number is somebody else’s money.

If you cannot find where the provider reports what it charged, the meter stays in your project, on your own key. Do not ship a meter that prices only some of its billable routes: the key opens on all of them, so the unpriced calls spend real money and land in the trail as unknown.

Who can pay

Your own connection, your key or your signed-in account, works with any meter wherever it lives.

The platform key, where weft holds the credential and bills a user’s balance, is being designed now. It will only ever spend on a provider weft ships a meter for, because weft can only charge a user for spend it has a reviewed meter to measure. That is why a shipped meter is held to the bar below today: the meter you write now is the one that will be charging real balances then. Getting a provider onto that key means getting its meter shipped in weft. For what that takes, go and read Getting a meter shipped in weft.

The trait

#[async_trait::async_trait]
impl ProviderMeter for MyProviderMeter {
    fn service(&self) -> &'static str;
    fn base_url(&self) -> &'static str;
    fn classify(&self, method: &str, path: &str) -> RouteClass;
    fn prepare(&self, path: &str, body: &[u8]) -> anyhow::Result<Option<Vec<u8>>> { Ok(None) }
    async fn ceiling_usd(&self, path: &str, body: &[u8], follow_up: FollowUp<'_>)
        -> anyhow::Result<f64>;
    fn observe(&self, path: &str, query: &str, request_body: &[u8])
        -> Box<dyn CallObservation>;
    async fn resolve(&self, path: &str, observed: ObservedCall, follow_up: FollowUp<'_>)
        -> MeasuredCost;
    async fn priceable(&self, path: &str, follow_up: FollowUp<'_>) -> anyhow::Result<()>;
    fn opens_charge(&self, path: &str, observed: &ObservedCall) -> Option<String>;
    fn charge_reported_on(&self, path: &str, observed: &ObservedCall) -> Option<String>;
    async fn fold_report(&self, path: &str, observed: ObservedCall,
        scratch: &mut Value, follow_up: FollowUp<'_>) -> Option<MeasuredCost>;
    fn observe_session(&self, path: &str, query: &str)
        -> anyhow::Result<Box<dyn SessionObservation>>;
    fn session_slice_usd(&self, path: &str) -> anyhow::Result<f64>;
    fn session_max_frame_bytes(&self, path: &str) -> anyhow::Result<usize>;
}

base_url is the single authority for where the provider lives. No caller ever accepts a host from a request instead; requests are rebuilt against this base, so a request cannot be aimed at a host the meter did not name.

classify maps a method and relative path to a class, matching against the raw path and never a normalized one. An unknown route can be refused by the caller’s policy, so traversal (../), encoded traversal (%2e%2e), userinfo (@host), and backslash tricks all have to fail to match and come back Unknown. Matching raw is what gives you that. If a route has a parameterized segment, match its prefix and guard the segment’s character set, the way elevenlabs.rs does for text-to-speech/{voice}; normalizing first would take the protection away.

prepare rewrites a billable call’s outgoing body so its cost becomes reportable at all, for example forcing the provider’s usage-accounting opt-in, overriding whatever the caller set. It also sheds anything internal with no business going upstream. An unparseable body on a route needing a rewrite is a loud error, because an unpreparable call would be an unmeasurable spend. Most providers report what a call cost without being asked, so this one has a default that sends the body untouched: write it only if you have something to opt into.

ceiling_usd is a worst-case price computable before the call goes out. It must be computed only from the request bytes, never from anything the caller could hand over separately: a side channel for “here is my conversation, for estimation purposes” would let a caller understate what it is about to spend. Lean high; the measured actual is the figure that counts. A call that cannot be priced is a loud error, never a guess. When the rates live behind the provider’s own authenticated API, follow_up is how the meter asks for them: a signed request the meter makes on its own, outside the call it is pricing.

You only need this if you want the provider on the shared keys. On the user’s own credential nothing has to be bounded before the call, so the default, which refuses, is the right one to leave in place. session_slice_usd works the same way.

observe mints a fresh observer for one call. It is handed the query string and the request body as well as the path, because some routes are priced from what was sent rather than what came back: a text-to-speech call prices its text’s characters, and an output format in the query decides bytes per second. The observer then sees every byte as it flows through to the real consumer, so it must never buffer, delay, or reorder chunks, and it must stay small in memory however long the stream runs.

resolve turns the observation into dollars. If the provider reported the cost inline, this is pure. If the provider only answers out of band, the meter makes that follow-up query itself; the node and its client library are never involved and never trusted to do it. A cost that genuinely cannot be resolved is an honest None, recorded as unknown, never a fake zero, because a zero would read as a call that cost nothing.

Read what the provider charged, never what the request implies

Find the number the provider itself reports and price on that number. It may be in the response body, in a header, or behind a follow-up query on the provider’s own ledger. Read the provider’s billing docs, and dump the headers of a real response, before you conclude there is no such number.

Do not price a call by re-deriving the quantity from the request. A model priced by tokens whose quality setting moves the token count cannot be priced from its request at all.

fal reports in two different ways, depending on the model. If fal can count what a model produced, it puts that count in the X-Fal-Billable-Units header on the result fetch, in the same unit the catalog prices, so the cost is that number times the catalog’s unit_price. A model billed by GPU time has nothing countable to state, sends no such header, and reports its measured run time as metrics.inference_time on the status route instead. Its catalog unit is compute seconds, so that run time is the quantity the unit price multiplies.

Every fal model measured so far is one or the other. The header is optional by fal’s design, though, so a finished job that states no count on a model priced by anything other than compute seconds has no honest figure and books as unknown.

Measured against fal on 2026-09-10: ask fal-ai/flux/dev for one 512x512 image and the request says a quarter of a megapixel, while fal bills one whole megapixel. A request-derived price is four times under, and nothing in the trail says so.

observe_session mints the per-session tap for a route classified as a session. It is fed every frame in both directions, answers the running accrued cost, and closes into a measured cost. Required for any meter classifying a route as a session; the default refuses loudly.

session_slice_usd is the session’s version of a ceiling. A session has no knowable total before it runs, so instead of reserving the whole thing the runtime reserves a slice at a time (price the dearest configuration for a fixed span, say one minute) and reserves another as the accrued cost catches up. It is only read where a call has to be paid for before it is allowed to start.

session_max_frame_bytes bounds one frame, sized so a single frame can never accrue more than one slice’s worth at the route’s dearest rate. Account for the wire form, such as base64 expansion.

Three answers a status gives you, and only one of them is zero

If the provider answered in the 200s, the amount is yours to work out. If it refused in the 400s, nothing was billed, and zero is a fact you can write down. If it answered in the 500s, you do not know: the work may have run and been billed with something in front of it falling over afterwards, and a gateway timing out over a job that is queued and spending looks exactly the same from here. Writing zero there claims a real spend was free.

weft_providers::providers::cost_from_status is that rule, in one place. Call it first and price only what it hands back to you:

if let Some(cost) = weft_providers::providers::cost_from_status(observed.status, "the call") {
    return cost;
}

Price on every parameter that moves the price

If the request can ask for something dearer, read it and use it. Firecrawl bills a plain page fetch at one credit and several for a stealth proxy or for structured extraction; fal bills a higher resolution above the unit price. All of that is in the request body your meter is handed.

The tempting shortcut is a flat rate justified by what one node happens to send. That is not a fact about the call. A meter sits on the CONNECTION, so any program holding that connection can send the dear options, and the flat rate then understates every one of those calls. If the interface cannot carry what the price depends on, extend the interface rather than approximate around it.

And if you know the flat figure is wrong but not what the right one is, record the spend as unknown and name the option that made it so. An honest gap in the trail is worth more than a confident wrong number.

Refuse what you could never price, before the call goes out

priceable runs before a billable call is sent. Say Err and the call never happens, and the caller is told why.

This is for the one case nothing downstream can recover from: a call your meter could never put a figure on, whatever the provider answers. fal’s is a model its pricing catalog lists no price for, because the unit price is the only place a fal price exists. Asking costs nothing (the catalog is free and cached), and it is the last moment the spend can still be prevented.

It is a different question from ceiling_usd, which asks what the worst case costs. A call can be perfectly priceable afterwards and have no bound before: a model billed by GPU time can run as long as it likes, and its run time still prices it exactly. So the two gates are separate, and neither blocks the other.

When the amount arrives after the call

Some providers commit the money on one call and only state the amount on a later one. A queue is the usual shape: you submit a job and get an id back, and the amount only exists once the job has run.

The node’s client polls the queue the way it always has, and because those polls ride the same connection client, the worker sees every one of them. Your meter never polls anything itself: classify the routes that report as Reports, a class that costs nothing to call but is still watched (it is in the route table below), and answer three things.

A charge is one spend the worker is holding on to because its amount is not known yet. It is opened by the billable call and closed by whichever later response states the figure.

  • opens_charge on the billable call: the id that ties this spend to the responses that will report on it, read straight off the observation. Say Some(id) and the worker holds that charge open; resolve is never called for the submit itself. Say None (the default) and the call is priced inline as usual.
  • charge_reported_on on a reporting response: which open charge it speaks for. If the provider has several routes for one job (/{id}, /{id}/status, /{id}/errors), every one of them has to answer the same id, or the job never closes and its spend books as unknown while its own response was stating the figure.

The id has to be one the PROVIDER assigned, read out of the response: a job id, a request id, a task id. Never one you invented and never one taken from the request. A worker holds the charges of every run it is driving under (service, id), and a report arrives carrying nothing but its own response, so two runs whose ids the meter chose rather than read will collide, and the figure one reports is booked against the other run’s spend.

  • fold_report: read the figure off this response. Return Some(cost) and the charge closes and is booked; return None and it stays open for the next report. A finished job you have no honest figure for is still Some, with amount_usd: None, so it books as unknown rather than staying open forever.

Each charge carries a scratch: a small JSON blob only your meter reads and writes. It starts as whatever the billable call’s observation recorded (the model, for one), and it survives from one report to the next, so a provider that states its figure across two responses can stash the first half and wait for the second.

The worker matches each report to the charge it names and writes the figure to the cost trail once your fold_report answers one. If the pod exits with a charge still open, that charge is booked as unknown: a job you submitted and never read back still leaves a row saying money went out.

Route classification, and the double-charge trap

A cost-lookup route looks like a call and must cost nothing.

RouteClassWhy
POST chat/completionsBillable(Metered)the actual spend
GET generationFreethe cost lookup for a spend
GET modelsFreethe public price catalog
GET <app>/requests/<id> and .../statusReportscosts nothing itself; its response is where the amount of an earlier spend shows up
speech-to-text/realtime (a WebSocket)BillableSessiona long-lived two-way channel; no total knowable up front
anything elseUnknowncannot be measured, so cannot be billed

If the cost-lookup route were billable, a node re-querying its own cost would be billed a second time, and the meter’s own follow-up query would be billed too, recursively. Classifying it Free makes that impossible: the route table already says the call bills nothing, wherever it came from.

A billable route also declares how it prices, which doubles as the policy for an unresolvable cost:

  • Fixed: one search equals one credit. The price is known without measurement, but whether the call was actually charged is not, since a provider may answer 200 with a failure body it never bills. So fixed routes still resolve through the meter: read the observed status and body and answer the declared price, zero for an unbilled failure, or unknown when the outcome was unreadable.
  • Metered: the price is only knowable from the response, as with LLM tokens or fal’s billed units. An unresolvable metered cost is recorded as unknown. If you are about to record unknown, go and read reading what the provider charged first.

The runtime-credential allowlist

A runtime-supplied credential only ever travels on routes its meter explicitly classifies: billable, or declared Free. An Unknown route, or a URL outside the meter’s base, is refused loudly before the credential is attached. Nothing is sent.

Three consequences:

  • A service cannot open a shared door without a registered meter. The meter is the allowlist, so a service whose calls all cost nothing still registers one classifying its routes Free.
  • Adding a node that calls a new provider route on a shared door means adding that route to the meter first, with its pricing.
  • A key the user pasted is theirs, so unknown routes pass through unmeasured. The gate keys off the credential’s origin, never the service.

A multi-route meter opens each method with a match on the route and delegates to per-route functions. It never infers the route from a response’s shape.

Cover the provider’s whole surface, dynamically

A meter must cover everything the provider’s nodes let the user ask for.

If a node exposes a model picker, the meter covers every model that picker can produce. A hard-coded priced-models table turns a valid user choice into a refusal, and it is stale the day the provider ships a model.

So fetch the provider’s own rate catalog at call time, cached with a TTL, instead of copying numbers into constants. Two shipped patterns to copy:

  • OpenRouter: the billable route accepts any model; rates come from the provider’s public price catalog, fetched and cached by the estimator.
  • fal: a POST to any model path is billable, with no model list gating it; the unit price and billing unit come from fal’s own pricing catalog at api.fal.ai, asked through follow_up and cached for an hour. That catalog is on a different host from the queue the calls ride, which is fine: base_url bounds where a caller’s request may be aimed, and a follow_up is the meter’s own call to one of the provider’s origins.

Hard-coded rates are acceptable only when the provider publishes no machine-readable catalog and the rate is a property of the route rather than a user-selectable model: a per-page OCR price, a per-hour transcription price. Even then, price by family or prefix where the provider versions its models, and refuse rather than guess on a name the mapping does not recognize.

Ceilings are estimates, not blanket caps

A prepaid balance admits a call only if it can cover the ceiling. So a lazy worst-case cap blocks users whose budget would comfortably cover the real cost.

Squeeze every pre-call signal before falling back to a provider-wide maximum:

  • Price at the request’s model and tier rate, fetched from the provider’s catalog, never at “the dearest model we carry”.
  • Count what the request actually asks for: its token estimate, its result count, its page selection, its crawl limit, the content types it enables.
  • When the priced quantity only exists in the response, look for a cheap pre-call proxy (a HEAD for the document’s byte size) before reaching for the per-call cap. The cap is the last resort.

Over-estimation is still correct, since the measured figure settles the charge. The overshoot just has to shrink as the request tells you more.

Under-estimation is not correct, and it is the easy mistake. A ceiling is what a prepaid balance reserves against, so every step of it rounds UP: whole units rather than fractions of one where the provider counts whole units, the resolution multiplier wherever it applies, and a refusal rather than a clamp when the request asks for more than you can bound. Clamping a two minute video down to the one minute your bound knows about is a ceiling that is half the real charge, which is the one thing a ceiling may never be.

Media estimation metadata

A request’s media parts may carry estimation metadata the node’s client library kept on the wire: duration for audio and video, dimensions for images. Read it in ceiling_usd, so a declared 90-second clip prices like a 90-second clip rather than a default guess.

It only ever sharpens the ceiling. Lying in it, or omitting it, moves the pre-call estimate and never the cost figure, which is always the measured actual, so it is not a trust surface.

prepare sheds it before the bytes go upstream.

Tests a meter must ship

Route classification. Every route in the table, plus the trick paths (../, %2e%2e, @host, backslash, trailing slash, case changes) all classifying Unknown.

The double-charge pin. The cost-lookup route is Free.

prepare. The accounting opt-in is forced even when the caller opted out, estimation metadata is shed, garbage bodies error loudly.

Observation and resolve against recorded real responses. A meter is a function of bytes, so record a real non-streaming response, a real streaming response fed in awkward chunk splits to prove reassembly, and a refused call, then assert the exact dollars. This is what catches a meter that prices low on every call.

Interruption honesty. An interrupted observation with nothing to anchor a lookup on resolves to unknown.

Sessions, when the meter has any. The session route classifies as a session; an observation fed recorded frames in both directions accrues and closes to the expected dollars; the slice prices the dearest configuration.

Getting a meter shipped in weft

To be shipped in weft, and so usable on the shared keys, a meter also holds all four of these:

  1. Every billable route reads the figure the provider reports: a field in the response, a header, or the provider’s ledger through follow_up. A route whose real cost the provider will not report stays Unknown, meaning own-key only, with a comment naming what you checked and what it returned. For why a request-derived quantity is not that figure, go and read reading what the provider charged.
  2. Cost-lookup and status routes are Free, so nothing double-charges.
  3. No account-asset route is reachable on a shared key. A route that creates or modifies durable things inside the credential’s account (minting a voice, registering an agent, adding a webhook) classifies Unknown, never Free and never Billable, because on the shared key those assets would land in one account everybody shares. Listing routes that only serve pickers may be Free. Pair the refusal with an own-account-only capability on the service’s permission catalogue, so the editor guides the user to their own account instead of failing at run time.
  4. The ceiling is the tightest bound the request allows, and every test below is present, including resolves against recorded real responses.

Write it as a pure function of bytes

A meter must assume nothing about the process running it.

Write it as a pure function of the request and response bytes plus its own follow-up query, and it measures correctly wherever a paid call is measured. No globals beyond your own rate caches, no environment reads beyond what the follow-up lane hands you.

A meter never touches a credential, so the same meter works on a pasted key and on a sign-in.

The CLI

Every command takes --dispatcher <url> (or the WEFT_DISPATCHER_URL environment variable) and --json, which prints one JSON object per line on stdout while logs go to stderr. That is how the VS Code extension drives the CLI.

Everyday

CommandWhat it does
weft new <name>scaffold a project: weft.toml, src/main.weft, nodes/ with the standard library seeded in
weft new <name> --assistant <who>the same, plus Tangle, the AI builder persona, copied in for that assistant (cc Claude Code, kc Kilo Code, cu Cursor, and the rest; repeat the flag for several). The choice is remembered for later weft new runs; --assistant none stops it. See Your first program.
weft tangle update [--assistant <who>]re-copy this project’s Tangle from the installed weft, the way weft catalog update re-copies the standard library. With no flag it refreshes the assistants the project already has; with one it also installs an assistant that is not here yet. It replaces every file Tangle owns, so your own edits to those personas, skills and commands go; anything your assistant wrote beside them stays.
weft buildbuild the project’s worker image and register the project, starting nothing. Registering is also how you put back code the dispatcher no longer holds: it records the compiled program under its own hash, which is what a past run’s values are worked out from, so the run reads again. It does not add a version to the tree; weft checkpoint does that
weft run [--detach] [--referenced]compile, register, fire one execution, stream its events until completion, including across waits. --detach returns after starting it. The whole catalog is compiled by default; --referenced opts into compiling only the graph’s node types. Every run records the code it ran as a version: Versions.
weft run --target <node>the same, but it runs only these nodes and whatever they need. Repeatable, and several targets run the union of what each needs; any node can be a target. See what actually runs.
weft run --seed [--seed-before <node>] [--seed-until <node>]reuse compatible completed work from head’s run. Before excludes the named node from reuse; until permits it too. Changed code, inputs and dependencies still invalidate reuse. See Seeding.
weft run --before <node>run what that node needs and NOT the node. The mirror of --target, and how you run a program up to the act you do not want performed: weft run --before post does everything the posting step needs and stops there. Repeatable.
weft run [<example>] [--referenced] [--seed] [--root] [--from <node>=<ports-json>]... [--emit <node>=<ports-json>]... [--target <id>]... [--before <id>]... [--group <id>=<ports-json>] [--fire <trigger>=<wake-json>] [--save <name>]build and start one execution; --detach returns its color. --from supplies backup inputs at a start, --emit supplies outputs without executing that node. Real producers take precedence over backups. --target includes the endpoint; --before excludes it. --group selects a whole group or included file, with its input payload. --fire runs exactly one trigger using a matching bake. Ordinary groups can be cut precisely; loops stay whole. --seed-before / --seed-until bound compatible reuse. Named examples supply saved starting parameters; current code runs. Clear and replacement rules are in Versions
weft follow <project>live event stream for a project
weft stop <color>cancel a running execution. Every command that takes a color also takes the first characters of one (weft stop 3f2a), as long as they name a single run
weft pslist registered projects
weft statusthe runtime’s overall state. If the project is not registered, explains how to run it or activate its triggers.

Executions

CommandWhat it does
weft executions [--limit N] [--project <id>] [--phase fire|trigger_setup|infra_setup]recent executions, newest first, with each run’s status, phase, local start time, entry node and the tags it put on itself (stopping other runs). “Has my trigger fired since the change” is --phase fire: it hides the setup runs an activate or resync makes.
weft events <color> [--node <id>] [--kind <kind>] [--full]one execution’s events in order, one line each: local time, kind, node, and a short summary of the value or error. Values are cut short so a long run stays readable; --node keeps one node’s events, spelled the way the source reads (triage.classify is the node classify of the file the site triage includes, and only that use of it), --kind one kind (node_failed; a substring works, so failed catches both failure kinds), --full prints the values whole, and --json prints the replay rows the graph view reads.
weft logs [color]the run’s log: the lines its nodes wrote, and every failure the journal recorded about it (a node failing, a port refusing a value, the run failing or being cancelled) as error and warn lines naming the node they are about. The last 1000 lines; --limit raises that (up to 20000), and a full page says the run may have written more.
weft clean [color]purge journal data. Asks before deleting runs; pass --yes when scripting. Naming a subject takes all of it: a color deletes that run, --project <id> deletes that project’s whole history (runs outlive the project, so this is how a removed project’s history is erased). With no subject it deletes runs older than --keep-days (30), or everything with --all. Also --images (reclaim worker images nothing runs any more, scoped to the current project’s images; a global sweep of dangling untagged build leftovers rides along. With --all: every project’s, the kind node’s copies, stale weft-infra-* tags, and old builder-base images; whatever the dispatcher’s referenced set covers survives) and prints the size of both compile caches on the machine: the node-test cache weft test-node builds into (it wipes itself past WEFT_TEST_CACHE_CAP_GB, 6 by default) and the cache every worker image build shares. That second cache bounds itself: each build drops the compiled node packages and worker crates no build has linked for 30 days, and --all also drops a whole cache no build has touched for 30 days (one is left behind whenever the toolchain or the builder changes). --build-cache throws away the whole BuildKit cache, that compile cache included, so the next build seeds a fresh one from the builder base, which already holds every stock node compiled; it drops the node-test cache too, so the next weft test-node builds cold. setup.sh runs --images --all after every daemon refresh.

Versions and examples

CommandWhat it does
weft checkpoint [<label>] [--root]record the files as a version under head, no run, no build. Works as the first thing you do in a project: it tells the dispatcher the project exists, without building anything. already at <id> when nothing changed. --root (on run too) records a version with no parent and refuses if that same version already has a parent. On run it also means nothing is seeded.
weft treethe version tree: every version, what changed in it against its parent, and its runs beneath it. It marks HEAD’s version, the activated version, and HEAD’s run, which is the one your next --seed inherits from. --json adds disk_version: the version your files on disk match right now, or null when they match none.
weft branch <version|label|color> [--discard]restore that version’s files and move head there. Restoring a version clears head’s run, so the next --seed falls back to the newest finished or parked run on that version, or on the nearest ancestor version that has one. If you want a particular run as your seed, name its color instead of a version. Refuses on unkept edits, naming the files.
weft diff <ref> <ref> [--full]compare observed outputs for human or AI review, including frozen focus nodes. A ref is a color, its unambiguous prefix or example:<name>. Differences are evidence and do not fail the command
weft freeze <name> [<run>] [--expect <node>]...save that run’s starting parameters and observed outputs in examples/<name>.json; default is head’s run. --expect marks nodes to focus on during review. Run and diff leave the accepted file intact; freeze again after accepting its replacement
weft exampleslist saved parameters and frozen examples; inspect them, rerun with weft run <name>, then compare with weft diff
weft bake [--referenced]prepare trigger inputs without arming listeners. A manual --fire requires a matching bake; use --referenced here when the run uses --referenced. Activation also prepares and records a bake before arming
weft wake <color> <node>resolve a pure time wait now. A wait that expects a value is refused, naming its kind.
weft prune <version> [--yes]delete a version, everything under it, and their runs. Asks first. Refuses on head’s version, the activated version, any version a frozen example was frozen from or any ancestor of one, and while a run in the subtree is running. It names each reason.

For what each verb does and what every refusal means, go and read Versions, seeded runs and frozen examples.

Triggers

CommandWhat it does
weft activate [project]register every trigger and mint its address
weft deactivate [project]drop the registrations
weft cancel-activateabort an activation in progress
weft cancel-buildabort a build in progress
weft cancel-runningcancel executions currently in flight
weft resyncre-register an active project’s triggers against the current source (a parked or hibernated project refuses; weft activate brings it back)

“Turn this project off” means different things depending on what is in flight, so deactivate takes a --mode to say which you meant:

If you want work in flight to bePass
thrown away--mode wipe, the default
kept, and resumable when you turn the project back on--mode hibernate
kept, and queued to run the moment you turn it back on--mode park

Every verb that turns triggers off takes this same flag: deactivate, resync, and infra stop / terminate / upgrade on an active project. Ask from a terminal and it offers you the three; from a script or an agent, with no flag, it wipes. That default is deliberate. The two preserving modes carry signals and suspended runs across a change to the program, and on a project you are still building that is how a run ends up waiting for something nobody will ever answer. Keeping the work in flight is the thing you say out loud.

Executions that are running right now are a separate question, answered by --running-policy. The default, wait, lets them finish while new fires are held; --drain-timeout <seconds> caps that wait at 600 seconds by default, after which the stragglers are cancelled. --running-policy cancel kills them immediately. With --mode hibernate, --grace <minutes> is how long the hibernation window lasts, 15 by default.

Waiting is a hibernate thing. Under --mode park the running executions are left exactly as they are and the command lands at once, because park’s whole promise is that they stay alive with no time limit; a park that waited would end by cancelling the very runs it was keeping. If you want them stopped under park, say so with --running-policy cancel.

Turning a project back on asks the mirror question, because reactivating has to decide what happens to whatever the deactivation kept. On a terminal weft activate prompts you. To answer up front, pass one of:

--reactivate-choiceWhat happens
execute_parked_keep_suspendedqueued work runs, paused work stays paused waiting for its answer
keep_suspended_onlypaused work stays paused, queued work is dropped
wipe_allboth are dropped and the project starts clean

Infrastructure

CommandWhat it does
weft infra startbring every unit up to spec, then wait until they are ready
weft infra stoptake units down per their stop behavior
weft infra upgradestop then start. On an active project this deactivates the triggers and leaves them off, so activate again when you are ready.
weft infra terminatedelete every infra resource, disks included unless a node’s spec preserves them
weft infra statusper-node health and endpoint URLs
weft infra logs [node] [--tail N] [-f]what the infra containers wrote: every infra node of the project, or one node’s, each line prefixed with its pod and container. The place a failure inside an image is read from.
weft infra cancelabort an operation in progress
weft infra node-stop <id> [--force]stop one node. --force overrides a unit’s stop behavior.
weft infra node-terminate <id>terminate one node

See Infrastructure nodes for what the verbs actually do to a unit.

The daemon

CommandWhat it does
weft daemon start [--rebuild] [--rebuild-cluster] [--public-url|--no-public-url]start the runtime. --rebuild re-makes the shared images under their existing tags and rolls everything onto the new bytes (kind only; on a k8s cluster, publish with weft build-images --push instead). --rebuild-cluster deletes and recreates the kind cluster even when its shape did not change (a shape change, a port or a kind version, rebuilds on its own, saying so first); every project’s own database lives inside the node and is destroyed with it.
weft daemon stopstop it
weft daemon statusis it up, and its public address if it has one
weft daemon restartthe same reconcile as weft daemon start (an alias): apply what changed, roll what needs it
weft daemon logs [--tail N] [-f]tail the runtime log
weft build-images [--push | --push-suffix <s> | --print]make every shared image (the four system images, worker builder base and full-library worker) exist locally under its content-addressed ref. --push publishes them to the registry (the release workflow’s verb); --print only prints the refs this tree resolves to, without building images
weft build-basemake just the worker builder-base image exist locally

Aliased to weft d.

Nodes and the catalog

CommandWhat it does
weft test-node [target]run node self-tests: the basic and fake tiers, locally, no cluster. --tier live adds the real-credential tier, which can spend money, so it asks first (--yes to skip the prompt). See Testing a node.
weft node-test-hash [target]the content hash of a package’s test inputs. Run it when you want to know whether anything a package’s tests depend on has changed since they last passed.
weft infra list-doorsthe pieces of this project’s infrastructure a client on this machine can reach, and the address each answers on. A door is part of what a node IS (its endpoint declares it, usually behind an input like PostgresDatabase’s reachable), so this only reports: there is nothing here to open or close. The address is not in the source because the cluster allocates the port, which is why you ask.
weft catalog updatere-sync nodes/base_catalog/ to the installed weft’s standard library. It wipes and recopies that folder, so copy anything you edited in there out first.
weft describe-nodes [--stdlib] [--list | --node <Type> [--compact]]print the catalog. --list is one line per node type (type, tags, one-line description): the cheap first look, and how you find a node. --node <Type> --compact is one node’s wiring view (no labels, icons, connect recipes, or null knobs), the token-cheap thing a model reads before wiring it; without --compact it is the full resolved metadata. The bare form prints the whole catalog as JSON for the editor’s palette, and it is large.

Compiler surfaces

CommandWhat it does
weft parse [--file]parse leniently, print the project as JSON
weft validate [--file]validate strictly, print diagnostics as JSON
weft parse-servera long-lived line-delimited JSON server, keeping the catalog warm

These three are how the editor gets live feedback. parse is lenient, so it answers on incomplete source; validate is strict.

Files and access

CommandWhat it does
weft connectthe editor’s Connect panel, in the terminal: pick a stored connection for an access node, connect a new account (paste a key, browser sign-in, shared app), upgrade one, forget one, or disconnect the node. Sees access nodes inside @included files too, however deeply nested; a subgraph included in two places is one file, so one pick connects every inclusion. Interactive by default; every choice has a flag (--help lists them) so scripts never hang on a prompt. --json works with the flag-driven actions (--list, --grant, --disconnect, --forget); the walkthroughs print for a person.
weft files lsstored runtime files
weft files inspect <key>one file’s metadata
weft files download <key>fetch it
weft files rm <key>delete one file, or a whole space if the target ends in /. Non-interactively --yes is required, since a prefix wipe can take kept files with it.
weft files usagehow much is stored
weft token mint --name "..."mint a browser-extension token. Prints the connect URL once.
weft token lslist minted tokens
weft token revoke <id>kill one
weft listener inspectwhat the listener tier is currently holding. Reach for it when a trigger stopped firing: if what it holds disagrees with what the project registered, a cleanup went wrong.
weft rm [project]remove a project: triggers wiped, runs cancelled, infra terminated, stored data reclaimed. Asks first; --yes answers it (required when scripting). --journal, --local, --all, --force

weft token mint also takes repeatable --projects and --tags flags that narrow what a token can ever see: Scoping a token.

The environment

VariableDefaultSet it when
WEFT_DISPATCHER_URLhttp://localhost:9999the runtime is not on this machine, or you moved its port
WEFT_DISPATCHER_PORT9999something else already has 9999. The port is baked into the local cluster, so changing it makes the next weft daemon start rebuild the cluster; go and read Port 9999 is taken before you do
CREDENTIAL_ENCRYPTION_KEYa development key, with a warning on every bootbefore you store a credential you care about. It seals them at rest, and changing it later strands every connection you had.
WEFT_ACCESS_APPS_FILEyou are pointing weft at a shared-credentials file
WEFT_PUBLIC_TUNNEL_TOKENyou want a permanent public address instead of the random one
WEFT_PUBLIC_TUNNEL_HOSTNAMEthe same, and both have to be set together

A .env in the project is loaded automatically. A malformed one is fatal rather than partially applied.

How the runtime is built

Weft runs as four tiers plus a broker, one job each. This page describes how they share work and recover from failures. Recovery depends on what reached the journal before a crash.

flowchart TD
    CLI["CLI / editor / webhooks"] -->|HTTP| D
    D["<b>Dispatcher</b><br/>routing, lifecycle, journal<br/>never runs user code"]
    D -->|HTTP| L["<b>Listener</b><br/>holds live event sources<br/>never touches the database"]
    D -.->|"task rows"| S["<b>Supervisor</b><br/>runs kubectl for user infra<br/>one lease per project"]
    D -.->|"task rows"| W["<b>Worker</b><br/>the compiled project binary<br/>a pool per project"]
    Caller["Live callers"] --> G["Live-connection gateway"] --> W
    L -->|HTTP| B
    S -->|HTTP| B
    W -->|HTTP| B["<b>Broker</b><br/>the only door to the database<br/>for tenant pods"]
    D --> PG[("Postgres")]
    B --> PG

Dispatcher

Routes events, manages worker lifecycle, orchestrates infrastructure, owns the journal, aggregates cost.

It and the broker are the only two things that open a database connection. Webhooks, form links and fire tokens reach the dispatcher. Live callers get a signed address through a separate public gateway, which connects them to their worker.

It never executes user node code. It never does node-aware work either: parsing, validation, and catalog reading are client-side, in the CLI, because the dispatcher pod cannot see your nodes/ folder and should not need to.

Listener

Holds live event sources: timers, held sockets, subscriptions, IMAP pipes, poll loops.

It is the only tier that knows about signal kinds. A new kind of trigger is listener code and nothing else; the dispatcher acts on a kind-agnostic action.

It never touches Postgres and never executes node code. Listener pods are pooled and tenant-agnostic, and report saturation from real memory pressure rather than a count, so a pod holding ten cheap timers and one holding one expensive stream are measured by what they actually cost.

Supervisor

Runs kubectl for user infrastructure: apply, stop, terminate, and health watching.

Each project has exactly one supervisor holding a lease on it, so one process issues cluster commands for that project. The lease expires if the pod dies, and a sibling claims it. It never touches Postgres and never serves HTTP.

Worker

The compiled project binary. Each project has a pool that adds workers as memory pressure grows. Each worker handles multiple executions and shuts itself down after thirty seconds with nothing to do.

It claims work from a queue, runs the drive loop, writes journal rows, and exits when idle. It holds no project definition of its own: each claim fetches the definition by hash and caches it by hash.

The broker

Everything except the dispatcher reaches Postgres through the broker.

The broker sits in its own namespace behind a network policy, verifies each caller’s Kubernetes service-account token, derives what that caller is allowed to touch, and only then delegates to the database.

It also owns the object store, and it is the one place outbound calls to tenant-influenced URLs are made: OAuth token exchanges, provider subscribes, resource lookups. Its egress policy denies every private range, so a crafted URL aimed at an internal address dies at the network layer rather than at a validation function somebody has to remember to write.

If your object store sits on a private range, WEFT_STORE_ALLOW_CIDR is the one knob that lets the broker reach it. Keep it as tight as the store needs.

Tenant pods are untrusted and reach the database only through the broker.

How they actually talk

FromToOver
dispatcherlistenerHTTP, for registration and inspection
dispatchersupervisordatabase rows. The dispatcher writes a command, the supervisor claims it.
dispatcherworkerdatabase rows. Same shape.
listener, supervisor, workerbrokerHTTP
dispatcherPostgresdirectly

The dispatcher does not call the supervisor or the worker. It writes a row, and whichever pod is free claims it with a locking select, so a worker starting late or a dispatcher pod dying between the write and the claim are ordinary.

Coordination lives in the database

No dispatcher pod holds anything the others need. Postgres is the single source of truth and a pod’s memory is only a cache.

Ownership is a lease: a row with an expiry, renewed by its owner, claimable by anyone once it expires. That is how a listener pod, a supervisor pod, and a project’s infrastructure each get exactly one owner without a coordination service.

The rule that follows, for anyone changing the dispatcher: before adding an in-memory map or counter to shared state, ask what happens if a sibling pod handles the next request. If the answer involves a stale read or a lost update, it belongs in Postgres.

Fencing

Every journal write is stamped with the pod that made it, and a database trigger rejects writes from a pod whose registration row is gone.

So a worker that was evicted, hung, then woke up cannot corrupt an execution that has already been taken over: it writes, the write is rejected, and it learns it is dead.

The seams

The dispatcher carries a small number of trait-shaped decision points, filled at construction:

SeamDecides
Authenticatorwhich tenant is making this request
TenantRouterwhich tenant owns this project, for background loops with no request
PlacementPolicywhich namespace a worker goes in
SandboxPolicywhat runtime class it gets
WorkerBackendhow a worker pod is spawned
ImageBuilderhow a staged build context becomes a pullable image
Journalwhere events are written

Schema

This section and the one after it are for people changing weft itself. Running programs on it needs neither.

Every table is written down twice, and the two answer different questions.

The canonical CREATE TABLE lives in Rust, in a group beside the code that reads and writes the table, and it says what the table is. You edit it in place. A new database is built from it in one shot, in one transaction under an advisory lock, with a stamp recording what was applied.

A migration is one SQL file under crates/weft-task-store/migrations/, named so the files sort in the order they were written, and it says how the table changed. The build walks that directory, so nothing registers a migration; the file being there is all of it. A database that already exists runs the ones it has not seen yet. Each database records which files it has run rather than which release it came from, so any old database reaches today the same way, and two branches that each add a file converge whichever order they merge in.

Editing a file that has already run is refused, since a database that ran the old text can never be told about the new one. Change your mind by writing another file.

Changing the canonical CREATE TABLE with no migration to match fails the boot, naming the group. Nothing catches that at compile time, so there is also a test, schema_agreement, that builds a database each way and compares what Postgres ended up holding, down to the columns, indexes, constraints, triggers and functions.

Testing, in four layers

Named explicitly in the codebase, so a test’s file tells you what kind it is.

Layer 1, pure functions. No I/O at all, sub-millisecond, in a #[cfg(test)] block next to the function. Most of the test count lives here.

Layer 2, wire shapes. Round-trip every cross-process type through its serialization. One per public wire struct, next to the type. Catches “renamed a field, broke the contract”.

Layer 3, contracts with fakes. One subsystem’s real code against in-memory fakes of its I/O, in the crate’s tests/. Fakes are hand-rolled, behind a test-helpers feature so they never link into a release binary. No mock libraries: a macro-generated mock hides what is actually being tested.

Layer 4, end to end. Real binaries on a real cluster with real Postgres, behind a feature that is off by default, so cargo test --workspace compiles them and runs none. Run them through scripts/run-e2e.sh, which is also where what they need from your .env is written down.

Layer 3 is where orchestration bugs surface.

A node’s own tests sit across layers 1, 3 and 4 rather than in one of them: its basic tier is layer 1, fake is layer 3, and live is layer 4 pointed at a real provider account. Testing a node is that side.

Flakes are bugs

A test that fails intermittently is a bug.

So timing-sensitive tests are written to run many times at once. A stress_test! macro runs the body in many concurrent tasks on a multi-thread runtime and reports which iteration broke, so a race shows up on an ordinary test run instead of waiting for somebody to notice. Anything touching a multi-thread runtime, a notification primitive, a firing order, or a stuck-detection deadline goes through it.

Retries, sleeps, longer timeouts, and ignore attributes are never the fix, because they only make the test tolerate a race the production code still has.

The journal

Every execution writes an append-only record: one row per event, in order.

It is not a log. A log is prose a human reads when something breaks. The journal is the state of the execution, in a form that can be replayed to reconstruct it exactly.

That is what lets weft pick a program back up long after the process that started it is gone.

What is in it

GroupEvents
Lifecycleexecution started, node kicked, node started, completed, failed, skipped, suspended, resumed, cancelled
Data flowport emitted, port closed, port type mismatch, pulses consumed, run output
Loopsloop instantiated, iteration launched, loop out fired, stream ended, terminated
Suspensionssuspension registered, suspension resolved
Money and logscost reported, log line
Terminalsexecution completed, failed, cancelled
Busesjoined, left, window, closed
Live callersconnected, inbound, outbound, errored, disconnected

Each row carries the execution’s color, so reading an execution is one indexed query ordered by row id.

A value a node emits is written once, on the port emitted row, however many wires it fans out on. Which wires carried it, which ports a firing closed, what a group boundary forwarded, and what each loop iteration received are never written: they are worked out again from the program when the journal is read. Group boundaries have no rows at all. So the journal is the list of facts the engine learned from outside (a trigger payload, a node’s emission, a person’s answer, a log line, a cost, a stream take, a cancellation), and reading it means replaying those facts over the program.

How it is used

During a normal run, nothing reads it. The worker holds the whole execution in memory and writes rows as it goes. Each write is a checkpoint.

It matters at two moments. When a worker has to rebuild an execution it did not run, on a resume or after the previous worker died, it fetches the program the execution was started against and folds the rows over it in order, reconstructing the pulse table, which nodes completed, which are suspended, and where each loop got to, and carries on. And whenever something wants to show a run (the editor’s execution view, weft events), the dispatcher folds the same way and hands out what the fold derived: the values each firing received and emitted, the group boundaries that ran or were skipped.

Reading it yourself

If you want to see what a run actually did:

weft executions --phase fire   # recent runs, without the setup runs an activate makes
weft logs <color>              # what the run's nodes wrote, plus every failure it recorded
weft events <color>            # every event for one execution, in order, one line each
weft follow <project>          # live, as they happen

If a run failed, weft logs names the node and the error, and that is usually all you need. If you want the values on the wires, weft events prints one line per event and takes --node, --kind and --full to open only the part you want; the flags are in the CLI.

The editor’s execution view is the same data rendered as a graph: click a node and you see the values that firing actually received and emitted.

A failed run from last Tuesday is still readable node by node, with the real values on the real wires, so you rarely have to make a bug happen again to study it.

Why debugging scales

If you are hunting a bug, the journal plus groups turn it into descending a tree.

Look at the top-level boxes, find the first one whose output is already wrong, open it, and repeat inside. Each level divides the search space, because each boundary is a place where the value was either already wrong or still fine, so you never have to scan the flat graph.

What the journal costs

A ten-node execution is a few dozen rows.

Two things make that number grow.

Buses write one row per bus per window, one second by default, instead of one row per message. A journaled bus’s window row carries every message in it. Joining, leaving and closing still cost a row each. An ephemeral bus journals no payloads: it keeps them in memory for the consumers reading it, and its window row carries only a count and a byte total per sender per message kind, plus the window’s offset range.

A caller conversation, an HTTP route or a socket, works exactly the same way and by the same rules, because it is the same code deciding. One row per second holds every message of that second in both directions, the caller’s and the program’s, so a socket at fifty messages a second is one row rather than fifty. Connecting, erroring and disconnecting cost a row each. Set journalEphemeral on the trigger and only the sizes and counts are kept.

Three things are true of both, and of anything else the language grows that carries a stream of messages:

  • Content over 100 KB is recorded trimmed, never whole. The trim keeps the shape and cuts the long text fields, so a row still reads as what it was about, and the true size travels beside it. Nothing is refused for being big; what a channel carries and what the journal keeps of it are separate questions.
  • Raw bytes are never written down, whatever the setting says. The size is the whole of what is worth keeping: the content would be a third bigger as text and unreadable to whoever is looking at it.
  • Ephemeral means metadata only, and the content then lives only in that channel’s own in-memory window. Once the window rolls past, it is gone; there is no copy in the database to fall back on.

Streams write one emitted and one consumed row per item, so a stream of ten million items writes twenty million rows. We are building the same windowing for streams; for where that stands, go and read the roadmap.

Seeded runs

If you ran with --seed, the new run reuses part of an earlier one without copying a single row. The new run’s first row names its parent run and which earlier execution supplied each reused node. The inherited facts stay under their original run’s color.

Readers interpret inherited history against its original program, then combine the selected facts with the child’s own events. Inherited firings retain their input and output evidence, questions, answers, node logs and costs, marked with the original run. Historical costs are not new charges. Execution-wide state such as the parent’s terminal status and live caller connection is not transferred to the child.

If you are looking at a seeded run in the editor, a firing taken from the seed is outlined in blue, because the fold marks it with the run it came from. An input you handed in yourself is marked provided, and the editor labels it provided by hand.

Reused history depends on its original journal. If required history has been cleaned, replay reports the missing source run. Start a new run without seeding, or select an available seed with weft branch <run>.

Cleaning up

If you want the journal smaller, weft clean is the only thing that removes rows:

weft clean                       # purge, keeping the last 30 days
weft clean <color>               # one execution
weft clean --all                 # everything
weft clean --keep-days 7

The same verb also reclaims build output, and those forms touch no journal rows at all: weft clean --images (worker images nothing runs any more; with --all, every project’s, the kind node’s copies, stale weft-infra-* tags and old builder bases) and weft clean --build-cache. For what each one removes, go and read the weft clean row in the CLI page.

Cleaning is per subject, and naming a subject takes all of it. A color takes that one run. --project <id> takes a whole project’s history, which is how you erase a project you have already removed (removing a project deliberately leaves its runs behind). With no subject you get the age sweep: everything older than --keep-days, 30 by default.

During a run the journal is append-only, and the dispatcher never edits a row. Deleting a run also removes it from the version tree (Versions), and a version the deletion left bare (no runs, no versions under it, no checkpoint name, not where head is) goes with it. A checkpoint you named is never swept.

weft rm is the other half of this and works the other way round: with no flags it unregisters the project and KEEPS its runs, along with everything needed to read them back (the code each one ran, and its row in the version tree). weft rm --journal is what throws those away.

A past run that shows nothing

A run’s rows say what happened, not what it meant. Every input and output you see in the editor is worked out afterwards by replaying those rows against the code the run ran, so a run whose code the dispatcher no longer holds paints as a graph with all its nodes and none of its values. The editor says so on the run itself rather than leaving you to guess: the code is not recorded any more, here is what to do.

The code is kept for as long as any run points at it, removing a project included, so this is rare. What gets you there is deleting the last run that needed a version, or a journal older than the release that started keeping them.

If you still have those files, weft build in the project folder puts the code back: registering records the compiled program under its own hash, and that hash is what the run names, so unchanged files restore exactly what it was folded against and it reads as it did. If you do not have them, weft clean <color> removes the run.

Holes

A lifecycle write can fail because the database is unavailable or the worker has lost permission to write. The worker stops driving that execution at its next check. It does not deliberately continue with unsaved state. Bus-history write failures are reported separately on the affected bus.

The failed write is logged in weft daemon logs. A replacement worker can only recover what was saved, so work done after the last saved result may repeat.

An unreadable saved event prevents the worker from loading the execution. Some events decode but cannot be applied to the program: a row naming a node the program does not have, a loop row with no instance behind it, a malformed pulse identifier. The execution view reports each one, and a worker refuses to resume over any of them, because a state rebuilt from a partial journal is a state that never existed. A journal written by an older version of weft does not decode at all and is refused the same way.

Inspect failures with weft logs <color> and weft events <color> before starting a new run. A new run has its own history and can repeat external actions. weft clean <color> removes the old history when no longer needed.

The execution guarantee

At-least-once for a node whose completion never reached disk.

A worker that dies mid-execution is replaced, and the replacement folds the journal. A node that had finished but whose completion row was lost gets re-run.

For an action that charges money or sends a message, repeating it can matter. ctx.run reuses a result once it has been saved. If the action succeeded but saving its result failed, the action can still repeat. Preventing a duplicate requires the receiving service to recognize repeated requests, using the same request identifier each time.

Versions, seeded runs and frozen examples

Every run records the code and parameters it used. You can reuse compatible completed work while developing, or run the current code with a saved use case and inspect how its answers changed.

The tree

weft tree shows source versions and their runs. A color identifies one execution. Freeze and diff also accept an unambiguous prefix of that color. Each version records the project’s source files by content hash.

CommandEffect
weft checkpoint [label]Record the current files without building or running, and move head to that version.
weft branch <version|label|color>Restore that version’s files and move head. A color also selects that run as the next seed.
weft treeShow versions, runs and head. --json includes the version matching disk.
weft checkpoint --root / weft run --rootRecord a source version without a parent. Refuses if that version already has a parent. On run, root disables seeding.
weft prune <version>Remove the subtree and its runs after confirmation.

Head is shared per project. Checkpoint clears head’s selected run; a later seed looks for a settled run on that version or its nearest ancestor. Branch refuses to overwrite unkept edits; checkpoint them first. --discard explicitly permits replacing them.

Prune refuses when head, an activated listener, running work or a frozen example still needs the source history. Unused bakes belonging to explicitly pruned versions are removed too. Source files still referenced by a registered build or surviving history remain retained.

Running one group, or one node onward

Run builds automatically. Keep the graph intact and select the work to exercise:

weft run --from classify='{"text":"the invoice is wrong"}' --target reply --save invoice --detach
weft run --group triage='{"text":"the invoice is wrong"}' --detach
weft run --from triage='{"text":"the invoice is wrong"}' --before publish --detach
FlagMeaning
--from node='{"port":value}'Start at the node with backup inputs. A bare node supplies no backup. Repeat for several starts.
--emit node='{"port":value}'Supply that node’s outputs without executing its body, then continue downstream.
--target node-or-groupInclude this endpoint and stop propagation beyond the cut. A group or loop name includes its whole body. Repeat for several endpoints.
--before node-or-groupStop before this endpoint. A group or loop name excludes its whole body. Repeat for several endpoints.
--group group='{"port":value}'Run a whole group or loop alone, using its input ports. An included file uses its group alias.

Downstream work brings the other producers it needs. The upstream walk stops at each --from, --emit, and trigger. For A feeding C and B also feeding C, --from A includes B; --from C stops before both producers and uses C’s supplied backups or normal closed-input behaviour. Unrelated branches stay out. A cut selecting no work or output evidence is refused before building an image.

--group is a complete selection: it cannot combine with from, emit, target or before. --from group=... starts at the whole group and continues downstream instead. Cuts inside ordinary groups stay at the named node. Their _should_flow gates still apply, and a true gate dispatches only selected work. Loops are indivisible: select the whole loop or move the cut outside it. Trigger setup and infra preparation follow the same restrictions.

Supplied inputs are backups at the named starts. The runtime waits for real producers first. A real value wins, including null; a clean closure without a value permits the backup. An error or invalid real value remains an error. Inputs at arbitrary interior nodes are not part of the run parameters.

For a generator output, --emit batches='{"items":["first","second"]}' emits those items in order and closes the port. An empty array closes it without an item. An ordinary list port receives its array as one value. Read the port type before supplying it.

Preparing and firing triggers

weft bake
weft run --fire incoming='{"event":"the trigger wake payload"}' --detach
weft run --emit incoming='{"message":"the emitted output value"}' --detach

Bake runs preparation and saves the resulting trigger settings without arming listeners. A fire uses those settings and gives exactly one trigger its wake payload. An emit supplies declared outputs without executing that trigger.

The payload you type is checked against what that trigger declares it wakes with, before anything is built or started, and the check is exact: a missing field is refused, and so is a field the trigger does not declare, each named. weft run --fire prints the shape it wanted, which is the quickest way to see what a trigger takes (firesWith). The same trigger cannot use both forms. Trigger inputs are prepared through bake; a trigger cannot be a from start.

The bake must match the code and configuration being run. Bake again after changes. Closed group gates can leave triggers unprepared; inspect preparation events when fire refuses. weft bake <project-id> uses the registered build. Builds include the whole catalog by default. If using --referenced, use it for both bake and run so their code identities match.

weft activate prepares and arms listeners. Each real event starts its own run from its one trigger, using the program that prepared that listener.

Seeding: run only what changed

weft run --seed reuses eligible completed work from head’s selected run. When head names only a version, it finds a settled run there or on the nearest ancestor. To select a particular older run, branch to its color first; branch also restores its code, so checkpoint edits you want to keep.

--seed-before node permits reuse before that node. --seed-until node also permits reusing the node itself. Both require --seed. These flags bound reuse; target and before bound execution.

Changed implementations, inputs or dependencies invalidate affected work. Failed work and live handles cannot be reused. Loops are reused whole. The run reports what was inherited and what ran. A requested reuse boundary does not make incompatible results eligible; read the warning and move the cut earlier if the old values cannot supply it. A fully inherited run is valid.

--from chooses a boundary, not a forced rerun. With --seed, unchanged work inside that cut can be reused, including identical used backups. Omit --seed to run it again, or use the seed endpoints to limit reuse.

The unchanged standard library shares a finished worker image across projects. Setup prepares it, and releases publish it so a project can pull it without compiling. Project names, IDs and graph settings do not change that image. Node edits, added nodes, custom build settings, or --referenced need their own image. Building one compiles only what no earlier build on this machine already compiled: every worker build shares one compile cache, and a node whose files have not changed is taken from it. Once the standard library has been compiled once, a project with one custom node compiles that node and links. weft clean --build-cache throws that cache away.

Builds include the whole catalog by default. Adding an unchanged catalog node or editing graph configuration needs no new image. --referenced opts into compiling only the graph’s node types. Implementation edits still rebuild.

Freezing an accepted run

--save name saves starting parameters to examples/name.json. weft run name runs the current program with those parameters, whether the file contains only parameters or a frozen result.

weft run invoice --detach
weft events <color> --full
weft freeze invoice <color> --expect reply
# After changing the program:
weft run invoice --detach
weft diff <new-color> example:invoice --full
# After accepting the new result:
weft freeze invoice <new-color> --expect reply

Freeze requires a completed run. It preserves that run’s starting parameters and observed outputs. A seeded whole run preserves its original starting inputs; a carved run preserves its cut and inputs entering it. Interior reused results do not become hidden fixed inputs.

expected holds output history, including finite streams and closures. Repeat --expect node to focus review on particular outputs. Focus affects comparison, not execution. A focused output that disappeared remains visible as a difference. Stored media compares by content hash.

Run the example without seed when reviewing the current computation. Diff presents changed values for a person or AI to judge; differences do not produce a failing exit status. Inspect execution status separately. Run and diff leave the accepted file intact; freeze again only after accepting its replacement. weft examples lists saved parameters and frozen examples.

Repairing saved parameters after a graph change

Removed input ports are ignored with a warning. Missing starting nodes or cut endpoints are errors, so move the cut explicitly:

weft run invoice --clear from --from new_classifier='{"text":"the invoice is wrong"}' --target reply --detach
weft run invoice --clear group --from triage='{"text":"the invoice is wrong"}' --before publish --detach

Explicit from, target, before, group and fire flags replace their corresponding saved settings. Repeated new from flags form the replacement map. Emit flags replace the named ports while retaining other saved emit entries. --clear from|emit|target|before|group|fire clears a field before edits; repeat it for several fields. Duplicate newly supplied ports are errors. Use --save another-name to preserve revised parameters separately.

When a run waits

Frozen examples retain human questions and answers and incoming caller messages for inspection. They are not automatic replies. Compare the new question with the recorded question and answer before answering the current token. For a live connection, send messages through a new connection.

weft wake <color> <node> resolves a pure timer wait. A wait expecting a value must receive that value.

Reading the result

weft executions shows status. weft logs <color> shows failures and node logs; weft events <color> --node <id> --full shows values. Inherited history names its original run and is interpreted against its original program. Historical costs are not new charges. The editor can display the same run on its graph, including inherited work.

Completion establishes that execution finished. The output evidence tells you whether the program answered the use case.

Files at run time

Two different things are called “files” in weft.

Project assets live with your source: an image you dropped onto a node, a prompt in its own file, a CSV a program reads. They are referenced with @asset or @file and they are part of the project.

Runtime files are written by running programs: a generated image, a transcription, a cache a project builds.

The asset sync is the bridge: before every build it makes storage mirror exactly what the code references.

How long a runtime file lasts

If you want to know when something you wrote will disappear, look at the scope it was written with.

ScopePathDeleted when
Executionexec/<run>/shortly after the run ends, unless kept
Projectproject/<project_id>/the project is deleted
Sharedshared/<name>/the owner deletes it

If you want a file your node emits to survive its own run, mark it kept. Otherwise it is swept shortly afterwards and turns up in the editor weeks later as expired media.

KeepTtl::Default is 30 days and every access bumps the clock, so artifacts still in use never expire while abandoned ones age out. The rule and the KeepFile node are in Storage.

Finding one afterwards

If you want to see what a project has written, or pull one file down:

weft files ls
weft files inspect <key>
weft files download <key>
weft files rm <key>
weft files usage

The editor has the same thing as a browser, and if you want a stored file’s address in your source, its picker will paste it in for you.

If you want somebody outside weft to be able to fetch a stored file, whether that is a person you are sharing a generated image with or a provider you are handing media to, there are two ways and they are for different jobs.

  • Presigned, a signed URL carrying its own credentials. This is the one for handing a provider bytes during a single call.
  • A public link, a shorter address protected by an unguessable token and served through the same filtered surface as the trigger paths. This is the one for sharing with a person. See what the proxy passes.

Both take a time to live and both expire: 15 minutes if you do not say, 7 days at the most. So never emit either on a port, because the URL outlives its own validity in the journal and turns into a broken artifact later. Emit the stored file itself and mint the link where it is used.

Media inside typed values

If your type has file-shaped fields inside it, you do not have to walk it. The runtime converts the whole value at a provider boundary, which is what keeps a conversation carrying forty images cheap to journal.

See media inside a custom type.

The object store

Underneath, files live in an S3-compatible object store. Locally that is a container the installer runs; elsewhere it is whatever S3 endpoint is configured.

Nothing in the language or the node API depends on which, because a node writes through ctx.storage and never names a bucket.

The browser extension

The extension is how a running program asks a person a question and waits for the answer.

When an execution reaches a HumanQuery node it suspends, and the task appears in the extension of everyone whose token is allowed to see it. Someone answers, and the execution resumes exactly where it stopped, whether that took a minute or a week.

Build it

If you want the extension, ask for it, because the default install skips it: rebuilding it bumps versions and signs for Firefox, which is slower than a normal build and rarely what you are after.

./setup.sh --browser --no-sign

That writes an unpacked build per browser under extension-browser/build/ (for Chrome, build/chrome-mv3) plus a zip per browser. You need Node 20 or newer and pnpm; the script checks and tells you if either is missing.

Drop the --no-sign only if you want a Firefox install that survives closing the browser. Signing needs web-ext on your PATH and Mozilla AMO keys in .env.extension, and the script stops before building anything if either is missing, printing where to get them.

Load it

If you are on Chrome, Edge, Opera, or another Chromium browser: open chrome://extensions, turn on Developer mode, click “Load unpacked”, and pick that browser’s folder under extension-browser/build/.

If you are on Firefox: a temporary install loads the zip from about:debugging#/runtime/this-firefox under “Load Temporary Add-on”, and it goes away when you close the browser. For one that sticks, install the signed .xpi that ./setup.sh --browser drops into extension-browser/build/ when you leave signing on.

Connect it

weft token mint --name "my laptop"

This prints the connect URL, then the bare token on a second line for a script that wants only that. Both hold the same secret, shown once: the server stores only a hash and can never show it to you again. Paste it into the extension’s popup and pending tasks start appearing. Lose it and there is no recovery: mint a second token and weft token revoke the one you lost.

Scoping a token

weft token mint --name "reviewer" --projects <id> --tags approvals

A token with no scope flags sees every task in your tenant, in every project, until you revoke it. So if you are handing one to somebody else, narrow it: --projects restricts it to specific projects and --tags to specific task tags, and both flags repeat.

weft token ls
weft token revoke <id>

The doors a token opens

If you are writing your own consumer instead of using the extension, the token opens three doors on the dispatcher, the token as bearer on the first and the last:

DoorSet it when
GET /signal-token/signalsyou want the tasks this token may see: one entry per parked question or registered trigger, form fields included
POST /signal/{signal token}you are answering one; the per-task token in the listing is the credential, no bearer
GET /signal-token/signals/{signal token}/files/{field}a field carries a stored file and you want to show it. A file arrives in the listing as its facts only (mimeType, sizeBytes, filename, no link); this door answers a fresh link that lives an hour, so ask each time you render. A file that expired answers a 404 saying so; show that in the image’s place rather than a broken picture

The files door is scoped like the listing: a task the token lists, a field the form declares, a file that belongs to that task’s project or run. Anything else is a 404, and the storage key never travels.

Try it end to end

Add a HumanQuery node to any program and run it with the graph open.

The execution parks on the node, the task pops up in the extension, and your answer wakes the program. The graph shows the node in its waiting state the whole time, so you can watch the handoff from both sides.

Then do it again, but close your laptop first and answer tomorrow. Same result, because the execution is rows in a table rather than a process holding a socket.

Working on it

The extension is a WXT and Svelte app in extension-browser/.

cd extension-browser
pnpm install
pnpm dev

pnpm dev launches a browser with the extension loaded and hot-reloads as you edit.

Sequential Diffusion Programming

This is how to get the most out of weft. It is also why the editor looks the way it does.

Start with the thing you are actually doing

You are building a system that turns some input into some output through a sequence of transformations: an email becomes a classification becomes a decision becomes a message.

The normal way to build that is to design it, write it, and then find out what the real data looks like. You write the parser against the API docs, then you run it and the API sends something else.

Weft is built for the other way: against a real example, one stage at a time.

Take one real input, an actual email rather than a made-up one, and build the first step. Run it, click the node, and look at the value that came out. When that step produces what you want, grow the next one and run it again.

Once the whole chain works end to end, feed in a second real example and fix whichever stages break while the earlier ones keep passing. Then a third. By about the third the stages that still break are usually only the parsing ones.

We call it Sequential Diffusion Programming, because the program sharpens pass after pass the way an image sharpens out of noise, and each pass is anchored to a concrete case.

Why now

Repeated passes over a whole program used to be wasteful. When humans wrote every line, a full pass was expensive, so code had to be grown carefully into the right shape from the start, and designing up front was cheaper than iterating.

An AI pass over a weft program is fast and cheap, and once passes are nearly free, refining against reality beats designing correctness up front.

Weft is built for it specifically:

  • Programs are short, because the orchestration is declarative rather than glue.
  • The compiler catches structural mistakes before a run, so a pass costs a compile rather than a debugging session.
  • Every value from every run is in the journal, so “look at what actually came out” is one click.
  • Groups mean a pass can touch one stage without disturbing the rest.

Debugging is the same motion, backwards

Something breaks in production three weeks later.

You open the failed run, look at the top-level groups, and find the one whose output is already wrong. Descend into it and repeat, each level leaving you a smaller piece. When you reach the step whose value went wrong, you are holding a concrete failing case, which is exactly what you needed to iterate on that step.

Why the tree does so much work

Because a group is a typed contract, building a branch is a delegable task. “Build the thing that turns a raw email into a normalised ticket, here are its input and output types” is complete and self-contained. Whoever builds it, person or model, never needs to see the rest of the program, and whoever wires it in only has to check the boundary.

So several agents can build parts of one program at once without talking to each other, because the boundaries already say everything they would have had to agree on.

It also makes each of those tasks a better task, because whoever builds it sees two types and one job instead of a repository.

The verbs a pass uses

weft run --seed reuses compatible completed work while you iterate. Changed code and inputs invalidate the affected work and its consumers.

To exercise one piece, --from node='{"port":value}' starts at that node with backup inputs. --target includes an endpoint; --before excludes it. --group group='{"port":value}' runs a whole group alone. For a trigger, weft bake prepares its settings without listening, then weft run --fire trigger='<wake-json>' fires that one trigger.

When a run comes out right, weft freeze <name> preserves its starting parameters and accepted outputs. After a change, weft run <name> runs the current code with those parameters. Inspect the result with weft diff <color> example:<name>: you or Tangle judge whether the change is acceptable. Freezing the new run replaces the accepted example. For the commands and their boundaries, read Versions, seeded runs and frozen examples.

What is landing next

  • The editor will let you descend into a group, fix one stage there and come back out.
  • Several agents will be able to build branches in parallel under one plan.

Design principles

The rules we use when deciding whether something belongs in weft.

Put the coordination where you can read it

Whatever decides what connects to what, or what runs next, should be something you can read. In most systems that logic is buried in code you never open: a retry loop, a queue, an if three calls deep. In weft it is written down in the graph and checked before the run, so you can find where a decision is made and change it without touching the steps around it. You read it from the outside in: a group’s interface says what goes in and what comes out, and its boundary is real to the compiler, so nothing can reach inside without going through it. A group does not survive into the running program, so folding and nesting cost nothing.

The plumbing belongs to weft, not to your node

A node’s code should be its own job and nothing else. Handling a credential, keeping a subscription alive, saving state, writing down what happened: those belong to the runtime, where they are written once and hardened for every node. When two nodes would otherwise write the same thing, that thing gets built once for both. For where the line sits today, read the commandments of plumbing.

The language knows nothing about your nodes

The compiler, the dispatcher and the runtime never mention a node by name or hardcode its fields. A Postgres step and a model step look the same to the language; everything a node needs, it asks for through the ctx. So adding a node that needs something new means building a general mechanism in weft, never a branch that already knows that node.

Refuse a mistake as soon as anything can see it

A badly typed wire should fail at compile time, and a missing credential when you connect the account, not when the request goes out. Put each check at the earliest place with enough information to make it, and have it say what went wrong and what to do about it.

Fail loudly, never silently

When something does go wrong, it says so. A fallback that quietly returns a second-best value turns a broken program into one that looks like it works, and the user never finds out. The failure is part of the design too: name what broke and what the person can do next.

The three fronts

When a feature lands in weft, it lands on three fronts at once. Miss one and the feature is half built, usually in a way nobody notices until somebody is stuck.

Levers. What can a person reach, and from where? A setting on a node, a flag on the CLI, a control in the graph, something the node’s Rust asks the ctx for, a key in metadata.json, a word in the language itself. Name the lever and name where it lives. A knob nobody can turn is not a lever, and a knob nobody needs is clutter.

Defaults. What does everybody get without asking? This is the part a person should never have to know exists. If the right behaviour only happens when somebody sets something, either the default is wrong, or the setting has to be required, and then leaving it out is a validation error that says which setting is missing.

Two questions decide it. Is it tedious and useful to make somebody fill this in every time? Is there a number or a choice that is right for almost everybody? Yes to both and you set it, and the lever is there for whoever wants to change it. But never invent a default to paper over a setting that genuinely depends on the case. If there is an honest reason to leave it unset, leaving it unset has to stay possible, and then the setting is explicit and the validation is what makes the bad shape impossible. A hidden default that is right half the time is worse than a refusal that says what is missing.

Protection. What stops somebody building the broken version by accident? The compiler, the parser, and a node’s own validation rules know things before anything runs. If a shape is provably wrong, refuse it at the earliest point that can see it, and say what to do instead. The obvious ways to hurt yourself (a loop with no way out, a run left waiting forever on somebody who is gone) should be impossible to write down in the first place, and where something can only be caught while it runs, the runtime stops it and the message points at the shape that works.

Why we hunt em dashes

The problem with AI slop is not that a machine wrote it. It is that nobody went back over it afterwards.

Models reach for the em dash far more than people do, so the mark is a tell. Not a tell that a machine wrote the line, a tell that nobody has read it again since it appeared.

We use it as one. The review pass ends by stripping every em dash, so the two can never be in the same file. Find one in this repo and you have found a paragraph, a comment or a function that skipped review. Find none and somebody went through it line by line.

That only holds if the sweep is the last thing the review does, and if nothing else in the pipeline ever removes them quietly. So: comma, colon, parentheses, or two sentences. Never the dash, in prose, in code comments, in commit messages, anywhere.

The commandments of plumbing

The docs keep telling you that a node does no plumbing. None of them say what plumbing is.

Here is where it stops today. It is a line that can move, and asking us to move it is a normal thing to do.

They are also aspirations. A commandment can be right and the thing weft actually hands you can still not cover your case, or be missing an option you need. That is worth raising too, and it is the easier fix of the two: the line is fine, our application of it is not.

The test underneath all of them: something is weft’s job when every node that needs it needs the same answer. If two good nodes would reasonably do it differently, it is yours.


I. Thou shalt not handle credentials.

Signing in, signing requests, refreshing, revoking, deciding whose account to use. Your node is handed a client that already works.

II. Thou shalt not listen for anything.

A trigger declares what should wake it, and the runtime does the listening: holding the socket open, taking the push, polling on a timer, checking the event is genuine, renewing the subscription. Your body runs once per event that has already arrived.

III. Thou shalt not build the channel.

Two nodes alive at the same time exchange messages over a channel weft opens and holds for them. Sockets, reconnects, acknowledgements and backpressure are already written.

IV. Thou shalt not build control flow in Rust.

Looping, branching, retrying, fanning out, gathering results: you decide all of it, in the graph, where it is journaled and where somebody can read it. Not inside one node’s body, where nobody can.

V. Thou shalt not save your own state.

If the worker dies, a fresh one rebuilds the execution from the journal and carries on. You keep nothing of your own between runs, and ctx.run is how you say a step must not happen twice.

VI. Thou shalt not shift bytes around yourself.

You say what a file is for and how long it should last, and you get all of that control. Fetching it, storing it, giving it a public address, expiring it, turning it into whatever a provider wants: that happens underneath you.

VII. Thou shalt not keep the books.

Every paid call is priced and attributed as it happens. You never total anything up, carry a running cost between nodes, or work out whose spend a call was.

VIII. Thou shalt not run infrastructure.

Containers, health checks, volumes, lifecycle. You describe what should be running and the runtime keeps it that way.

IX. Thou shalt not police your inputs.

Every wire was checked before anything ran, so a value arrives as what it was declared to be.

X. Thou shalt not keep records.

Every event is written down as it happens, so there is nothing for you to log to a file or a table of your own.

XI. Thou shalt not hand-deliver information to whoever is watching.

Every value on every wire is already there in the inspector, live and afterwards. If a firing makes something worth looking at, declare a display and the editor renders it on the node. You never print, and you never add a port whose only job is to tell a person what happened.


And what is left is yours

Your own logic and nothing else: building this call’s request body, reading this reply, knowing what this provider’s errors mean, doing the actual work.

It is a small job on purpose. Everything hard sits behind the ctx, and when something behind there goes wrong it fails loudly and names what to do next.

Moving the line

The line sits where it does because of the nodes people have written so far. Yours might be the one that shows it is in the wrong place.

If you are about to write something the commandments say is ours, come and say so in Discord. Bring the node you are building and the code you would otherwise have to put in its body, because that code makes the case better than any description of it. Or build the general version yourself and send a PR, which is usually faster and always welcome.

Same if a commandment holds but what weft gives you falls short: the socket handling that does not fit your protocol, the display that cannot show your kind of result, the scope that does not last long enough. Tell us what you hit, because a commandment we cannot deliver on is worse than one we never made.

One question decides most of these. Can you name another case the mechanism would serve, besides your own? If the only answer is your provider, what you have is a hook for it, and we will probably end up talking you out of that. If you can name others, the line is in the wrong place, and these conversations are how it gets moved.

Things people say to me

Collected as I hear them, with what I actually think.

“This is just Python with extra steps”

No. Three things below cannot be done in Python at all, and “awkward in” is not what I mean.

A Python program waiting three days for an approval is a process that exists for three days. You can hide that behind a queue and a state machine, which is what everyone does, but then the thing that waited is your infrastructure, and the thing that resumed is a different invocation rebuilding its own context by hand. In weft the process exits and the execution is rows in a table. That is not an optimisation of the Python version, it is a different object.

Nothing can check your orchestration, because there is no orchestration to check. There is control flow, spread across a dozen files and two frameworks, and no artifact any tool could read. In weft the wires are the source, so a type mismatch, an unwired input, or a cycle is refused before anything runs.

Adding a service means writing the auth. The author of the S3 node wrote a JSON block declaring SigV4 and got AWS request signing. Not a helper that made it easier: they wrote no auth code, and neither will you.

What you can do in Python is build all of that yourself, which is exactly what everyone is doing and where the several hundred lines of plumbing came from. The question was never whether Python is capable. It is whether you want to be the person maintaining the durable executor you wrote by accident.

“The compiler checks the wiring, not whether the program is right”

True today, and it is the interesting half of what comes next rather than a limit.

What a compiler can check scales with what is legible to it. Because the orchestration is data, it can be asked to prove properties about the program itself: flags that turn a policy into a property of compilation.

None of it is shipped, and all of it is reachable. What each flag would buy, with a worked example, is in our approach to AI safety.

“Nobody adopts new languages”

New languages die because somebody has to learn them, and that cost is gone here. Nobody learns weft. A model writes it and you read the graph, the way nobody learns SQL’s grammar to look at a query and see what it selects.

“Visual programming always fails”

This one has a graveyard behind it. It fails for three reasons.

It becomes unreadable past about fifty boxes. Groups collapse recursively, and a group is a typed contract you can reason about without opening it, so a two-hundred-node program is five boxes at the top level. It costs nothing at run time either, because groups are compiled away before anything executes.

You cannot diff or merge it. The source is text: the .weft file lives in git and merges like any file. The picture is a view, and a GUI gesture goes through the compiler, which rewrites the source and hands it back, so your comments and formatting survive.

The boxes eventually cannot express what you need. The boxes are typed nodes whose insides are Rust. When the graph cannot express something you write a node, in minutes, and it is vocabulary forever.

“You will never keep up with the integrations”

I am not trying to.

The design goal is that adding a service is a JSON file and adding a node is a folder with two files. While that holds, whoever needs an integration builds it in an afternoon and it is vocabulary for everyone afterwards. When it stops holding, that is a bug in the language and gets fixed as one.

“Rust is a barrier”

Nobody writing weft writes the Rust by hand.

The premise of the whole project is that models write the code. A node body is usually under a hundred lines because everything hard sits behind the ctx, and a small self-contained typed unit with its own test rig is the single thing models are best at producing. Nodes are the easiest part of weft to generate, not the hardest. And you only reach for one at all when what you need is not already vocabulary.

The version of this objection that lands is about reading Rust when something goes wrong, which is fair, and is why node bodies are kept small enough to read in one sitting.

“Kubernetes on my laptop is absurd”

It sounds absurd right up until you want a Postgres.

Weft can provision a database, a headless browser, or a model server as a node you drop on the graph, and something has to manage containers, networks, storage, health and lifecycle for that.

Using the real one means the manifests that work on your laptop work in production, so there is no separate production setup quietly drifting from your development one. You write no YAML and you will not think about the cluster again after installing it.

“It is a graph, so it cannot do X”

Two of the three things people mean by this are deliberate.

Cycles are refused. You iterate with a Loop and exchange feedback over a bus. Refusing cycles is why the compiler can prove things about the rest.

Two nodes talking while both run is not expressible with pulses alone, which is why buses exist. A parallel loop can launch fifty agents, gather their channels, and have a coordinator talking to all fifty while they keep working.

A synchronous call and return between nodes is the real one. Weft is structurally a process network rather than a call graph, and node-to-node function callbacks are designed, with one architectural question still open about how they ride the replay machinery: see the roadmap. Until they land, agent loops work but are less elegant than they will be.

“The docs claim things the code does not do”

If you find one, report it and I will fix it.

Every claim in this book is grounded: read in the source, run, or written by the person who built the thing. Where something is a direction rather than shipped, the page says so.

Our approach to AI safety

Weft is built as a capability tool. The design has a second consequence that some readers care about, and this page is where it lives.

Why a language

Today an AI system’s actual structure, which model sees what, what a human gates, which step can spend money or delete something, exists as control flow spread across a dozen files. No tool can read it, so every guarantee about it is a promise somebody made in a review.

Weft makes that structure a first-class artifact the compiler reads, so properties about it become checkable.

Rigor as a dial

Rigor is a dial here, set per program, at the level that program needs.

A side project connects things fast, iterates raw, leaves the model free, and nothing gets in the way. A system where the stakes are real turns on the checks it needs, and gets a machine-checked answer instead of a code review convention.

What becomes provable

None of this is shipped. It is what the design is aimed at.

Because the orchestration is data, a compiler flag can turn a policy into a property of compilation:

  • every path into this node passes a validation step,
  • this use case requires these audited nodes between the model and the effect,
  • every path out of this black-box node goes through a deterministic switch,
  • this value must be deterministic.

Take that last one concretely. An email address a program is about to send to, if it came out of a model, is not deterministic, and a determinism flag refuses to compile the program. If it came from a node whose output is proven deterministic, the compiler can demonstrate the property rather than take anyone’s word.

Extend that to a node whose determinism was established by an audit and stamped as trusted, and a compiler flag becomes the difference between “we believe this pipeline is compliant” and “this pipeline does not compile unless it is”.

Why this needs a language

All of it depends on the structure being legible to a machine before anything can be proved about it. A library sits inside a language that cannot see the shape, so the best it can offer is a convention and a runtime check. The orchestration has to be the source for a compiler to have anything to work with.

What exists today

The compiler checks types, connection completeness, graph shape, and each node’s own declared validation rules. Every execution is journaled, so what a program actually did is recoverable rather than reconstructed.

The policy flags above are designed and not built. They are the direction the type system and the validator are being grown toward.

The longer argument

The reasoning behind this, including why the interesting unit of control is the system around a model rather than the model itself, is in The future of programming and Three properties for alignment.

Glossary

Terms weft uses in a specific way. If a page used a word and you were not sure it meant what you assumed, it is here.

Access. The port type for the authorized ability to call a third party. One type for every service. What flows on the wire is a small reference, never a credential. See How connections work.

Access node. The node that owns the connect for one service and emits an Access value. Its whole body is one macro.

Activation. Turning a project’s triggers on: registers every trigger and mints its address. One that cannot be served refuses here, loudly.

Broker. The scoped HTTP front door to the database that every tenant-side component uses. The dispatcher bypasses it. See How the runtime is built.

Bus. A live channel between nodes alive at the same time. Any number of participants, any direction. See Live channels.

Closed pulse. A pulse carrying no value, meaning “nothing will ever arrive here, at this color, at these frames”. On a required input it skips the node and cascades. This is how branching works. See the closure rule.

Color. One execution. A re-run is a new color, so “per color” always means per execution.

Connection. An account somebody hooked up to a service. It lives in the access store and holds everything secret. A project’s source holds a bare id.

Dispatcher. The control plane. Routes events, manages lifecycle, owns the journal, hosts every public URL. Never runs user code.

Door. How a connection is obtained. shared means a credential this weft holds; own means the user brings or creates their own.

Example. A run spec saved as examples/<name>.json: which part of the graph to run, what to hand it, and which trigger to fire. weft run <name> runs it again. See Versions, seeded runs and frozen examples.

Firing. One call to a node’s body, at one color and one frame stack. A node can be firing several times at once inside a parallel loop.

Frames. A stack of loop iteration indices. Two pulses only meet at a node if their frames match, which is what keeps iterations from mixing.

Frozen example. Saved starting parameters plus accepted output history in expected, with optional nodes to focus on during review. Run it on current code, then inspect its diff. See Freezing an accepted run.

Gather port. A loop output that collects one value per iteration. Typed List[T | Null], because an iteration can fail to write.

Generator[T]. A typed one-way terminating stream. Exactly one producer, exactly one consumer.

Group. A subgraph with typed boundary ports, behaving as one node from outside. See Groups.

HEAD. The version your next checkpoint or run is recorded beneath, and weft branch moves it too. It is also where --seed starts looking: see the Seed entry.

Infra node. A node that needs a long-running process, declared as a typed spec that the supervisor compiles to Kubernetes manifests.

Journal. The append-only record of an execution: one row per event. Not a log. It is the state, in replayable form. See The journal.

Listener. The tier that holds live event sources. The only tier that knows about signal kinds. Never touches the database.

Meter. The per-provider code that computes the real cost of a paid call from the bytes. A node never states a cost.

Pulse. One emission travelling to one input port, carrying a value, a color, and a frame stack. The only thing that moves in a running program.

Provided. A backup input supplied at a --from or --group start, or an output supplied through --emit. Real execution input takes precedence over a backup. Changed supplied values invalidate affected reuse.

Recipe. The service block in an access node’s metadata: how a credential is acquired, how a request is signed, what the permissions are, how events arrive.

Registered app. One OAuth application this weft signs users in with, living in the operator’s trusted apps file. A recipe may use one and can never extract from it.

Root. A node no wire feeds. A manual run kicks ordinary roots in its selection. Triggers require an explicit fire or supplied outputs.

Scope (run). Which part of the graph a run executes, set by --from, --emit, --target, --before or --group, or by saved parameters. See Running one group, or one node onward.

Scope (storage). Which of Execution, Project, or Shared a file is written under. It is a lifetime contract, not a folder name.

Seed. The run a --seed run inherits from. By default it is head’s run, once that run has finished or parked on a signal; if head has no run, it is the newest finished or parked run on head’s version, or on the nearest ancestor version that has one. For which of its nodes are taken and which run again, go and read the Stale entry below; for how weft picks a seed when head has no run, go and read Seeding.

Signal. A wake source: a timer, a form, an endpoint, a subscription, a held socket. Registered by a trigger, or awaited mid-flow.

Slice. A node plus everything upstream of it, hashed together. A seeded run compares each node’s slice against the seed’s and re-runs the ones that differ.

Stale. A node a seeded run must run itself rather than inherit. A node is stale when:

  • you edited it, or anything upstream of it;
  • it is new since the seed, or the seed’s run never covered it;
  • it is a root whose kick payload changed, or one the seed never kicked;
  • its supplied starting inputs changed;
  • the seed ran it but it failed, was cancelled, is still running, or is parked waiting on somebody: the question it asked belongs to the seed’s run, so answering it would wake the seed rather than this run, and the node asks again;
  • it fired several times inside a loop and did not complete or get skipped in every one of them;
  • it is beyond the permitted --seed-before or --seed-until boundary;
  • its output contains a live handle tied to the earlier run.

Anything downstream of a stale node is stale, and a loop goes stale whole the moment any node inside it does.

Supervisor. The tier that runs kubectl for user infrastructure. One holds a lease per project.

Suspension. A parked firing waiting on a signal. The worker exits. The execution costs rows and no compute.

Trigger. A node whose firing starts from outside. Two phases: setup at activation, then a fire per event.

Unit. One pod template inside an infra spec. Each has its own status and its own stop behavior, and the infra verbs act on one at a time.

Version. The project’s program files (src/, weft.toml, nodes/, assets/, examples/, plus the installed weft’s own version), named by a hash of their contents, so the same code is always the same version however many times you run it. Your layouts/ and your notes are not in it. The seeded nodes/base_catalog/ is not listed file by file, but its content hash rides along with the installed weft’s version, so upgrading the catalog changes the version like any edit. Every run and every checkpoint records one.

Worker. The compiled project binary, running as a pod, multiplexing executions and shutting down when idle.


Two words used in a particular sense

Egregore. What emerges from a weft program. An egregore is a thing that emerges from a collective’s structure, and a weft program is a small collective of models, people and long-lived nodes behaving as one thing. It does not have to contain a model: what is actually being coordinated.

Sequential Diffusion Programming. Building a program stage by stage against a real example, then a second, then a third, until new inputs just work. Named for the way the program sharpens pass after pass, the way an image sharpens out of noise. See the chapter.

Where this is going

What is being built next, and the design behind each piece.

Language

Function callbacks. A node declares named entry points it can call, with typed arguments and returns, as part of its declared interface. At fire time the runtime injects a callable; the node calls it, the pulse flows through the connected subgraph, and the result comes back. From the node’s side it feels synchronous. This is what makes higher-order nodes (run this subgraph per element) and first-class agent loops fall out of the existing vocabulary.

The architectural decision still open is whether a callback rides the journal and replay machinery as a suspension whose resolver is an internal subgraph, or gets its own path.

A reason on a closed port. A closure today says “nothing will arrive here” and nothing more, so three situations produce the same signal: the producer failed, the producer declined, or the producer was never going to run because a different trigger fired. Attaching the reason is straightforward. The design work is deciding what a downstream node may do with it without reinventing exceptions.

A concurrency bound on parallel loops. max_parallel, so a loop over ten thousand items runs at the width you choose.

Suspendable live channels. Buses and streams surviving a suspension, which is what lets a stream consumer’s body await_signal.

Execution

Held suspensions. The durable model kills the worker on every suspension, which is what makes parking thousands of cheap flows free. A node holding in-process state too expensive to rebuild (a browser session with thousands of cookies, a loaded local model, a warm connection pool) gets an opt-in primitive where the future awaits in place and the worker stays alive. Die-and-resume stays the default, and the language makes the cost of holding visible, since holding pins a pod for the whole duration.

One concept for journal holes. A write that failed and a stored row that cannot be read are the same thing: the journal cannot give a correct event at some position. One “hole” concept replaces the two mechanisms handling them today, with severity decided by where the hole sits: cosmetic in dead history, where the replay view degrades and nothing else does, and fatal when it intersects the state a resume depends on, where it refuses rather than resume on state it had to guess at.

Defining that resume frontier precisely is the hard part. See The journal.

Stream journaling volume. Every stream item writes two journal rows, which carries real workloads today and does not carry a stream of ten million items. Buses already solve this with windowing, and streams take the same route.

Operations

One surface for degraded state. When something goes wrong that the runtime cannot fix on its own (a health action that keeps failing, an infra node stuck, a trigger setup that errored, a worker crash-looping), one place answers “what is wrong with my project right now, and what do I do about it”.

A project-scoped meta log. Observability for the things that belong to no single execution and therefore have no journal to live in.

Per-node infrastructure drift detection. Noticing, per unit, that what is running no longer matches what the spec says.

Catalog

A GitHub package, done properly. Covering what people actually automate: issues, pull requests, and repository triggers. The old package covered issue creation alone and was removed. Both credential doors already exist in the access system.

Model-list filtering by capability. A model picker offers only the models that can do what the node needs.

What the rest of it is for

Most of this list clears the way for Sequential Diffusion Programming, building a program by refining it against real examples pass after pass, which works now that a pass is cheap. What is in use today and what is being built next is on that page.